mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: merge contacts and calendar sync with the phone on Android #34
Brings feat/device-sync into main: on Android, each account's address books and calendars can sync both ways with the phone's Contacts and Calendar apps (Settings -> Contacts / Calendar -> Sync to this device), through a "Bulwark" Android account with a contacts and a calendar sync adapter. - native side: the account, both sync adapters, provider access scoped to the account, push routing to the syncs, sync problem notices - the sync engine in `src/device-sync`, run as a headless task - the settings sections, sign-out and removal handling, translations - design, limitations and verification in `docs/device-sync.md`
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
startCalendarReminderTapHandling,
|
||||
} from './src/lib/calendar-notifications';
|
||||
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
||||
import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
|
||||
import {
|
||||
setPendingCalendarOpen,
|
||||
type CalendarReminderTarget,
|
||||
@@ -412,6 +413,9 @@ export default function App() {
|
||||
// Calendar reminders are kept scheduled from launch, not only once the
|
||||
// Calendar tab has been opened.
|
||||
startCalendarNotificationSync();
|
||||
// Device sync (Android, #34): live changes, the app's own edits and the
|
||||
// foreground ask Android to sync; accounts are checked against Android's.
|
||||
startDeviceSyncTriggers();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ Open split them by tick; the P columns count the priority tags on those items
|
||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
|
||||
| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
|
||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||
|
||||
@@ -22,11 +22,12 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||
- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
|
||||
@@ -154,6 +154,11 @@ android {
|
||||
versionCode appVersionCode.toInteger()
|
||||
versionName appVersionName
|
||||
|
||||
// Android account type of device sync (#34), derived from the package
|
||||
// name so a later rename needs no other change. The XML in res/xml and
|
||||
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
||||
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
@@ -280,6 +285,11 @@ dependencies {
|
||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||
|
||||
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
|
||||
// android.jar only stubs org.json, so the tests use the real one.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
|
||||
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
|
||||
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Debug builds only: lets adb set up device sync accounts and drive the
|
||||
* provider bridge without the settings UI or JS, for the device tests in
|
||||
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
|
||||
* which only the shell and the system hold. Syncs themselves are triggered
|
||||
* with `adb shell requestsync`.
|
||||
*
|
||||
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
|
||||
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
|
||||
* --es enable contacts,calendar
|
||||
*
|
||||
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
|
||||
* authority, optional --ez on false), `remove` (name), `list`, and the
|
||||
* provider calls `query` and `batch` (name, authority, and the
|
||||
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
|
||||
* the shell's quoting, or pushed to the app's external files directory and
|
||||
* named by `file` when it is too long for a command line), `syncstate`
|
||||
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
|
||||
* the sync-problem notification, `problem` (name, title, text, uri, optional
|
||||
* channel) and `clearproblem` (name). Results go to logcat (tag
|
||||
* BulwarkDeviceSync) and the broadcast's result data.
|
||||
*/
|
||||
class DeviceSyncDebugReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
// Provider calls block; keep them off the main thread.
|
||||
thread(name = "DeviceSyncDebugReceiver") {
|
||||
val result = try {
|
||||
run(context, intent)
|
||||
} catch (e: Exception) {
|
||||
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||
}
|
||||
Log.i(TAG, "debug command: $result")
|
||||
pending.resultData = result
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun run(context: Context, intent: Intent): String {
|
||||
val name = intent.getStringExtra("name")
|
||||
return when (intent.getStringExtra("cmd")) {
|
||||
"ensure" -> {
|
||||
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
|
||||
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
|
||||
}
|
||||
"created=$created"
|
||||
}
|
||||
"enable" -> {
|
||||
val on = intent.getBooleanExtra("on", true)
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
|
||||
"enabled=$on"
|
||||
}
|
||||
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
|
||||
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
|
||||
"query" -> io(context, intent) { it.query(json(context, intent)) }
|
||||
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
|
||||
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
|
||||
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
|
||||
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
|
||||
}
|
||||
"problem" -> {
|
||||
SyncProblemNotifier.show(
|
||||
context,
|
||||
name!!,
|
||||
intent.getStringExtra("title").orEmpty(),
|
||||
intent.getStringExtra("text").orEmpty(),
|
||||
intent.getStringExtra("uri").orEmpty(),
|
||||
intent.getStringExtra("channel"),
|
||||
)
|
||||
"shown"
|
||||
}
|
||||
"clearproblem" -> {
|
||||
SyncProblemNotifier.clear(context, name!!)
|
||||
"cleared"
|
||||
}
|
||||
else -> "unknown cmd"
|
||||
}
|
||||
}
|
||||
|
||||
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
|
||||
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
|
||||
|
||||
private fun json(context: Context, intent: Intent): String {
|
||||
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
|
||||
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun authority(which: String) = when (which) {
|
||||
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
else -> which
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
@@ -73,6 +81,28 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
|
||||
They run in the app's main process on purpose: the adapters start the sync engine as a
|
||||
headless JS task in the same React Native runtime as the UI. -->
|
||||
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
|
||||
</service>
|
||||
<service android:name=".sync.ContactsSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
|
||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
<service android:name=".sync.CalendarSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
|
||||
</service>
|
||||
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
@@ -12,6 +13,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkFcmModule(reactContext))
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
@@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
|
||||
val data = message.data
|
||||
|
||||
// Contact and calendar changes become syncs of the Android accounts
|
||||
// they feed (device sync, #34); only mail needs the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
if (mail && !isAppInForeground()) {
|
||||
startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,11 @@ import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
||||
@@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
||||
withReadyReactContext { startTask(it, config, params) }
|
||||
// JobService callbacks run on the main thread, where HeadlessJs wants to be called.
|
||||
HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
tasks.addTaskEventListener(this)
|
||||
taskContext = tasks
|
||||
}
|
||||
UiThreadUtil.runOnUiThread {
|
||||
try {
|
||||
val taskId = tasks.startTask(config)
|
||||
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
|
||||
} catch (e: IllegalStateException) {
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
|
||||
onRefused = { e ->
|
||||
// The app came to the foreground since the push arrived and
|
||||
// syncs on its own there.
|
||||
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
||||
jobFinished(params, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this job.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*/
|
||||
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
|
||||
val done = AtomicBoolean(false)
|
||||
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
|
||||
val app = application as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
host.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
host.addReactInstanceEventListener(listener)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
host.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else {
|
||||
host.start()
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = app.reactNativeHost.reactInstanceManager
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
manager.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
manager.addReactInstanceEventListener(listener)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
manager.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else if (!manager.hasStartedCreatingInitialContext()) {
|
||||
manager.createReactContextInBackground()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() {
|
||||
BulwarkMessagingService.ensureChannel(this)
|
||||
val data = parsePayload(message.content) ?: return
|
||||
|
||||
if (!BulwarkMessagingService.isAppInForeground()) {
|
||||
// Same routing as FCM: contact and calendar changes request device
|
||||
// syncs, and only mail starts the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
if (mail && !BulwarkMessagingService.isAppInForeground()) {
|
||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Running a headless JS task without HeadlessJsTaskService: starting React
|
||||
* Native when the process was woken without any UI, and starting the task on
|
||||
* its context. Shared by the push job (BulwarkPushJobService) and the device
|
||||
* sync adapters.
|
||||
*/
|
||||
object HeadlessJs {
|
||||
private const val TAG = "HeadlessJs"
|
||||
|
||||
/** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
|
||||
private const val MAX_WAIT_MS = 600_000L
|
||||
private const val WATCH_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this work.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*
|
||||
* The wait ends once, and its listener goes with it: with a ready context,
|
||||
* or without one when React fails to start (the start task faults) or
|
||||
* after [MAX_WAIT_MS], when no caller can still be waiting. A context that
|
||||
* comes up later runs nothing of it, so waits given up on don't pile up on
|
||||
* the host with everything their [onReady] holds. (A caller that gives up
|
||||
* sooner, like the sync adapter at its deadline, checks that in [onReady].)
|
||||
*
|
||||
* Call it on the main thread; [onReady] runs there too. MainApplication
|
||||
* creates the ReactHost on first use without a lock (ExpoReactHostFactory),
|
||||
* so a background caller racing MainActivity could create a second one,
|
||||
* with a second JS runtime.
|
||||
*
|
||||
* Returns React Native's start task when it had to be started, so a caller
|
||||
* can notice a start that fails (a debug build with no Metro to load the
|
||||
* bundle from), or null when the context was already up.
|
||||
*/
|
||||
@MainThread
|
||||
fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface<Void>? {
|
||||
val reactApp = app as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
|
||||
host.addReactInstanceEventListener(wait)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
return host.start().also { wait.watch(it) }
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = reactApp.reactNativeHost.reactInstanceManager
|
||||
val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
|
||||
manager.addReactInstanceEventListener(wait)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
|
||||
wait.watch(null)
|
||||
return null
|
||||
}
|
||||
|
||||
/** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
|
||||
private class ContextWait(
|
||||
private val onReady: (ReactContext) -> Unit,
|
||||
private val removeListener: (ReactInstanceEventListener) -> Unit,
|
||||
) : ReactInstanceEventListener {
|
||||
private val ended = AtomicBoolean(false)
|
||||
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
if (end()) onReady(context)
|
||||
}
|
||||
|
||||
/** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
|
||||
fun watch(boot: TaskInterface<Void>?) {
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
|
||||
main.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (ended.get()) return
|
||||
val faulted = boot?.isFaulted() == true
|
||||
if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
|
||||
main.postDelayed(this, WATCH_MS)
|
||||
} else if (end()) {
|
||||
val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
|
||||
Log.w(TAG, "$why; no longer waiting")
|
||||
}
|
||||
}
|
||||
}, WATCH_MS)
|
||||
}
|
||||
|
||||
private fun end(): Boolean {
|
||||
if (!ended.compareAndSet(false, true)) return false
|
||||
removeListener(this)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
|
||||
* [onStarted] gets the task id in that same UI-thread turn, so before any
|
||||
* finish callback (HeadlessJsTaskContext posts those to the UI thread).
|
||||
* [onRefused] gets the refusal when the context is resumed and [config]
|
||||
* isn't allowed in the foreground.
|
||||
*/
|
||||
fun startTask(
|
||||
tasks: HeadlessJsTaskContext,
|
||||
config: HeadlessJsTaskConfig,
|
||||
onStarted: (taskId: Int) -> Unit,
|
||||
onRefused: (IllegalStateException) -> Unit,
|
||||
) {
|
||||
UiThreadUtil.runOnUiThread {
|
||||
val taskId = try {
|
||||
tasks.startTask(config)
|
||||
} catch (e: IllegalStateException) {
|
||||
onRefused(e)
|
||||
return@runOnUiThread
|
||||
}
|
||||
onStarted(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
|
||||
/**
|
||||
* The authenticator of the "Bulwark" account type. The app holds the
|
||||
* credentials, not the account: accounts are only ever created by the app,
|
||||
* from Settings → Contacts or Calendar, for an account that is signed in.
|
||||
*/
|
||||
class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
/** Settings → Accounts → Add account opens the app's device sync settings instead. */
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<out String>?,
|
||||
options: Bundle?,
|
||||
): Bundle {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(SETTINGS_URI))
|
||||
return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
|
||||
}
|
||||
|
||||
/** Removal from Android Settings is allowed; the app notices and turns device sync off. */
|
||||
override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle =
|
||||
unsupported()
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported()
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String?): String? = null
|
||||
|
||||
override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array<out String>?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
|
||||
|
||||
private fun unsupported() = Bundle().apply {
|
||||
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
|
||||
putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */
|
||||
const val SETTINGS_URI = "bulwarkmobile://settings/contacts"
|
||||
}
|
||||
}
|
||||
|
||||
class BulwarkAuthenticatorService : Service() {
|
||||
private val authenticator by lazy { BulwarkAuthenticator(this) }
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run
|
||||
* handshake, provider I/O, push routes and the sync-problem notification of
|
||||
* device sync (#34). The contract is `DeviceSyncNativeModule` in
|
||||
* src/device-sync/types.ts; docs/device-sync.md explains it.
|
||||
*/
|
||||
class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val accountsListener = OnAccountsUpdateListener {
|
||||
emit(ACCOUNTS_CHANGED_EVENT, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider I/O runs here, one call at a time: a batch can hold the
|
||||
* provider for seconds (it sleeps at a contended yield point), which would
|
||||
* stall every other native module call on React's native-modules thread.
|
||||
*/
|
||||
private val io: ExecutorService = Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this }
|
||||
}
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
val manager = AccountManager.get(reactApplicationContext)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType()))
|
||||
} else {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Account change listener not registered", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
try {
|
||||
AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener)
|
||||
} catch (e: RuntimeException) {
|
||||
// Not registered.
|
||||
}
|
||||
io.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext)
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getInfo(promise: Promise) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("accountType", accountType())
|
||||
putInt("sdkInt", Build.VERSION.SDK_INT)
|
||||
})
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun listAccounts(promise: Promise) = settle(promise) {
|
||||
Arguments.createArray().apply {
|
||||
for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) {
|
||||
pushMap(Arguments.createMap().apply {
|
||||
putString("name", account.name)
|
||||
putString("registryId", registryId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun removeAccount(name: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.remove(reactApplicationContext, name)
|
||||
}
|
||||
|
||||
// ── sync settings ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getSyncSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
Arguments.createMap().apply {
|
||||
putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically())
|
||||
putMap("authorities", Arguments.createMap().apply {
|
||||
for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply {
|
||||
putInt("syncable", ContentResolver.getIsSyncable(account, authority))
|
||||
putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority))
|
||||
val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L
|
||||
putDouble("periodicSeconds", periodic.toDouble())
|
||||
putBoolean("active", ContentResolver.isSyncActive(account, authority))
|
||||
putBoolean("pending", ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong())
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) {
|
||||
val options = JSONObject(optionsJson)
|
||||
val flags = SyncRequestFlags(
|
||||
manual = options.optBoolean("manual"),
|
||||
expedited = options.optBoolean("expedited"),
|
||||
upload = options.optBoolean("upload"),
|
||||
overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"),
|
||||
discardLocalDeletions = options.optBoolean("discardLocalDeletions"),
|
||||
)
|
||||
DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags)
|
||||
null
|
||||
}
|
||||
|
||||
/** The account's own sync screen where the platform has one, else the list of synced accounts. */
|
||||
@ReactMethod
|
||||
fun openAccountSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
|
||||
.putExtra(EXTRA_ACCOUNT, account)
|
||||
// Newer Settings apps pass only these arguments on to the account's
|
||||
// screen; without them it closes at once ("Account provided does
|
||||
// not exist: null", Android 12L), and no exception tells us so.
|
||||
.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) })
|
||||
val overview = Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type))
|
||||
val activity = reactApplicationContext.currentActivity
|
||||
for (intent in listOf(perAccount, overview)) {
|
||||
try {
|
||||
if (activity != null) {
|
||||
activity.startActivity(intent)
|
||||
} else {
|
||||
reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
return@settle null
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Try the next one.
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("No settings screen for sync accounts")
|
||||
}
|
||||
|
||||
// ── run handshake ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun finishRun(runId: String, reportJson: String, promise: Promise) {
|
||||
val accepted = DeviceSyncRuns.finish(runId, reportJson)
|
||||
if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it")
|
||||
promise.resolve(accepted)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isRunCancelled(runId: String, promise: Promise) {
|
||||
promise.resolve(DeviceSyncRuns.isCancelled(runId))
|
||||
}
|
||||
|
||||
// ── provider I/O ────────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */
|
||||
@ReactMethod
|
||||
fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) }
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */
|
||||
@ReactMethod
|
||||
fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) {
|
||||
val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) }
|
||||
try {
|
||||
io.execute {
|
||||
val result = try {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) }
|
||||
} catch (e: Throwable) {
|
||||
failed(e)
|
||||
}
|
||||
promise.resolve(result)
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.resolve(failed(e))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() }
|
||||
}
|
||||
|
||||
/** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */
|
||||
@ReactMethod
|
||||
fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY)
|
||||
.use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) }
|
||||
}
|
||||
|
||||
/** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */
|
||||
private fun onIo(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
io.execute {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: Throwable) {
|
||||
promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.reject("provider", "Device sync is shutting down", e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── push routes and notifications ───────────────────────────
|
||||
|
||||
/** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */
|
||||
@ReactMethod
|
||||
fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts (or replaces) the account's sync-problem notification. [channelName]
|
||||
* names the notification channel in the app's language; null keeps English.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun showSyncProblem(
|
||||
accountName: String,
|
||||
title: String,
|
||||
text: String,
|
||||
uri: String,
|
||||
channelName: String?,
|
||||
promise: Promise,
|
||||
) = settle(promise) {
|
||||
SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) {
|
||||
SyncProblemNotifier.clear(reactApplicationContext, accountName)
|
||||
null
|
||||
}
|
||||
|
||||
// NativeEventEmitter bookkeeping.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
private inline fun settle(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: AccountConflictException) {
|
||||
promise.reject("conflict", e.message, e)
|
||||
} catch (e: SecurityException) {
|
||||
promise.reject("permission", e.message, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
promise.reject("bad_args", e.message, e)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "BulwarkDeviceSync"
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged"
|
||||
|
||||
/** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */
|
||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||
private const val EXTRA_ACCOUNT = "account"
|
||||
/** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */
|
||||
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||
|
||||
@Volatile private var currentInstance: BulwarkDeviceSyncModule? = null
|
||||
|
||||
/** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
if (!ctx.hasActiveReactInstance()) return
|
||||
ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The Android accounts of device sync (#34) and their sync settings. One
|
||||
* account per app account that has device sync on; see docs/device-sync.md.
|
||||
*
|
||||
* Android's own sync settings are the source of truth for on/off: the
|
||||
* Contacts and Calendar toggles in Settings → Accounts are
|
||||
* `getSyncAutomatically`, and the app reads them back instead of keeping a
|
||||
* copy.
|
||||
*/
|
||||
object DeviceSyncAccounts {
|
||||
const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY
|
||||
const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY
|
||||
val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY)
|
||||
|
||||
/** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */
|
||||
const val USER_DATA_REGISTRY_ID = "registryId"
|
||||
|
||||
/** `<applicationId>.account`, set in build.gradle so the package name is never hard-coded. */
|
||||
fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type)
|
||||
|
||||
fun account(context: Context, name: String) = Account(name, accountType(context))
|
||||
|
||||
fun list(context: Context): List<Pair<Account, String?>> {
|
||||
val manager = AccountManager.get(context)
|
||||
return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) }
|
||||
}
|
||||
|
||||
fun exists(context: Context, name: String): Boolean =
|
||||
AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name }
|
||||
|
||||
/**
|
||||
* Adds the account when it is missing. Both authorities start syncable
|
||||
* with automatic sync off; the app turns on the ones the user picked.
|
||||
* Returns true when the account was created, false when it exists for
|
||||
* [registryId] already. An account that belongs to another registry id
|
||||
* keeps it ([AccountConflictException]): its rows and SyncState were
|
||||
* written for that app account.
|
||||
*/
|
||||
fun ensure(context: Context, name: String, registryId: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
val exists = manager.getAccountsByType(account.type).any { it.name == name }
|
||||
val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null
|
||||
when (ensureAction(exists, current, registryId)) {
|
||||
EnsureAction.KEEP -> return false
|
||||
EnsureAction.ADOPT -> {
|
||||
manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId)
|
||||
return false
|
||||
}
|
||||
EnsureAction.CONFLICT -> throw AccountConflictException(
|
||||
"The Android account $name belongs to another app account ($current)",
|
||||
)
|
||||
EnsureAction.CREATE -> Unit
|
||||
}
|
||||
val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) }
|
||||
check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" }
|
||||
for (authority in AUTHORITIES) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT }
|
||||
|
||||
/** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */
|
||||
fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when {
|
||||
!exists -> EnsureAction.CREATE
|
||||
currentRegistryId == registryId -> EnsureAction.KEEP
|
||||
currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT
|
||||
else -> EnsureAction.CONFLICT
|
||||
}
|
||||
|
||||
/** Removes the account; the Contacts and Calendar providers drop all of its rows. */
|
||||
fun remove(context: Context, name: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
if (manager.getAccountsByType(account.type).none { it.name == name }) return false
|
||||
return manager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, enabled)
|
||||
}
|
||||
|
||||
/** Replaces our periodic sync for the authority; 0 removes it. */
|
||||
fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
ContentResolver.removePeriodicSync(account, authority, periodic.extras)
|
||||
}
|
||||
if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a sync. SyncManager drops a request that matches a sync it is
|
||||
* running, so a run of that account and authority is also flagged to
|
||||
* ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]).
|
||||
*/
|
||||
fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) {
|
||||
requireAuthority(authority)
|
||||
DeviceSyncRuns.flagSyncAgain(name, authority)
|
||||
ContentResolver.requestSync(account(context, name), authority, flags.toExtras())
|
||||
}
|
||||
|
||||
fun requireAuthority(authority: String) {
|
||||
require(authority in AUTHORITIES) { "Unknown authority $authority" }
|
||||
}
|
||||
}
|
||||
|
||||
/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */
|
||||
class AccountConflictException(message: String) : Exception(message)
|
||||
|
||||
/** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */
|
||||
data class SyncRequestFlags(
|
||||
val manual: Boolean = false,
|
||||
val expedited: Boolean = false,
|
||||
val upload: Boolean = false,
|
||||
val overrideTooManyDeletions: Boolean = false,
|
||||
val discardLocalDeletions: Boolean = false,
|
||||
) {
|
||||
fun toExtras(): Bundle = Bundle().apply {
|
||||
if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true)
|
||||
if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true)
|
||||
if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.anonymous.bulwarkmobile.HeadlessJs
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The sync adapter behind both authorities (#34). It does no syncing itself:
|
||||
* it starts the BulwarkDeviceSync headless JS task, where the engine lives
|
||||
* (src/device-sync), waits for its report and hands that to SyncManager. See
|
||||
* docs/device-sync.md.
|
||||
*
|
||||
* onPerformSync runs on its own SyncAdapterThread and may block; React Native
|
||||
* is started from the main thread (see HeadlessJs), which stays free.
|
||||
* SyncManager holds a wake lock for as long as onPerformSync runs.
|
||||
*/
|
||||
class DeviceSyncAdapter(context: Context) :
|
||||
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Anything thrown out of here is rethrown on the sync thread
|
||||
* (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI
|
||||
* included. So every failure becomes a result: a soft error SyncManager
|
||||
* retries with back-off, or a database error for a missing permission,
|
||||
* as the framework would report it.
|
||||
*/
|
||||
override fun onPerformSync(
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
) {
|
||||
try {
|
||||
performSync(account, extras, authority, syncResult)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Sync of $authority refused by a permission check", e)
|
||||
syncResult.databaseError = true
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Sync of $authority failed", t)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID)
|
||||
if (registryId.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Sync of $authority skipped: the account has no app account")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
// A sync scheduled as an expedited job (Android 12+) gets 3 minutes
|
||||
// from JobScheduler instead of 10; stay inside either.
|
||||
val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false)
|
||||
val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS
|
||||
val run = DeviceSyncRuns.open(account.name, authority)
|
||||
main.post { startRun(run, account, registryId, authority, extras, deadline) }
|
||||
try {
|
||||
while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) {
|
||||
val boot = run.boot
|
||||
if (boot != null && boot.isFaulted()) {
|
||||
run.end("React Native did not start: ${boot.getError()?.message}")
|
||||
} else if (SystemClock.elapsedRealtime() >= deadline) {
|
||||
run.end("no report before the deadline")
|
||||
}
|
||||
}
|
||||
val outcome = SyncReports.outcome(run.report, run.endReason)
|
||||
SyncReports.apply(outcome, syncResult)
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
|
||||
"${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome")
|
||||
} catch (e: InterruptedException) {
|
||||
// onSyncCanceled interrupts this thread. JS sees the run as
|
||||
// cancelled at its next checkpoint (isRunCancelled).
|
||||
run.end("cancelled")
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority cancelled")
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
DeviceSyncRuns.close(run)
|
||||
// After startRun, which was posted first, so a listener it added is removed.
|
||||
main.post { run.detach() }
|
||||
}
|
||||
// A change pushed while the run was open: SyncManager dropped its
|
||||
// request as a duplicate of this sync, so ask for the next one here.
|
||||
if (run.syncAgainRequested) {
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again")
|
||||
syncResult.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun startRun(
|
||||
run: DeviceSyncRuns.Run,
|
||||
account: Account,
|
||||
registryId: String,
|
||||
authority: String,
|
||||
extras: Bundle,
|
||||
deadline: Long,
|
||||
) {
|
||||
if (run.cancelled) return
|
||||
try {
|
||||
run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext ->
|
||||
if (run.cancelled) return@withReadyReactContext
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
run.end("the React Native instance is gone")
|
||||
return@withReadyReactContext
|
||||
}
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
run.attach(tasks, object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
|
||||
// JS settled without a report, timed out, or the context was reloaded.
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
if (taskId == run.taskId) run.end("task finished")
|
||||
}
|
||||
})
|
||||
val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS)
|
||||
val data = Arguments.createMap().apply {
|
||||
putString("runId", run.runId)
|
||||
putString("accountName", account.name)
|
||||
putString("registryId", registryId)
|
||||
putString("authority", authority)
|
||||
putMap("extras", runExtras(extras))
|
||||
putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble())
|
||||
}
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true),
|
||||
onStarted = { run.taskId = it },
|
||||
onRefused = { run.end("refused: ${it.message}") },
|
||||
)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
run.end("could not start: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply {
|
||||
fun flag(key: String, name: String) {
|
||||
if (extras.getBoolean(key, false)) putBoolean(name, true)
|
||||
}
|
||||
flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual")
|
||||
flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload")
|
||||
flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited")
|
||||
flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff")
|
||||
flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions")
|
||||
flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val TASK_KEY = "BulwarkDeviceSync"
|
||||
|
||||
/** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */
|
||||
private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job"
|
||||
|
||||
private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10
|
||||
private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3
|
||||
private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline
|
||||
private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout
|
||||
private const val MIN_TASK_MS = 30_000L
|
||||
private const val POLL_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */
|
||||
class ContactsSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */
|
||||
class CalendarSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The device sync runs an adapter is waiting for, keyed by a random run id.
|
||||
*
|
||||
* A run ends exactly once, on the first of: JS calling finishRun, the
|
||||
* headless task ending (JS settled, timed out or was reloaded away), React
|
||||
* refusing or failing to start, the adapter's deadline, or a cancel. A report
|
||||
* that arrives after that is refused whole rather than half-applied. Headless
|
||||
* task ids restart at 1 with every React context, so they never identify a
|
||||
* run; the run id does.
|
||||
*/
|
||||
object DeviceSyncRuns {
|
||||
const val NO_TASK = -1
|
||||
|
||||
enum class State { WAITING, ENDED }
|
||||
|
||||
class Run(val runId: String, val accountName: String, val authority: String) {
|
||||
private val state = AtomicReference(State.WAITING)
|
||||
private val syncAgain = AtomicBoolean(false)
|
||||
val done = CountDownLatch(1)
|
||||
|
||||
/** Written before [done] opens, read after it: the latch orders them. */
|
||||
@Volatile var report: String? = null
|
||||
private set
|
||||
@Volatile var endReason: String? = null
|
||||
private set
|
||||
@Volatile var cancelled = false
|
||||
@Volatile var boot: TaskInterface<Void>? = null
|
||||
/** Written and compared on the UI thread only. */
|
||||
@Volatile var taskId = NO_TASK
|
||||
@Volatile private var tasks: HeadlessJsTaskContext? = null
|
||||
@Volatile private var listener: HeadlessJsTaskEventListener? = null
|
||||
|
||||
val isWaiting: Boolean get() = state.get() == State.WAITING
|
||||
|
||||
/** True once a sync was requested for this account and authority while the run was open. */
|
||||
val syncAgainRequested: Boolean get() = syncAgain.get()
|
||||
|
||||
/** Releases the waiting adapter. Only the first end counts; returns whether this was it. */
|
||||
fun end(reason: String): Boolean = end(reason, null)
|
||||
|
||||
internal fun end(reason: String, reportJson: String?): Boolean {
|
||||
if (!state.compareAndSet(State.WAITING, State.ENDED)) return false
|
||||
report = reportJson
|
||||
endReason = reason
|
||||
done.countDown()
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun requestSyncAgain() {
|
||||
syncAgain.set(true)
|
||||
}
|
||||
|
||||
fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) {
|
||||
this.tasks = tasks
|
||||
this.listener = listener
|
||||
tasks.addTaskEventListener(listener)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
val tasks = tasks ?: return
|
||||
listener?.let { tasks.removeTaskEventListener(it) }
|
||||
this.tasks = null
|
||||
this.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
private val runs = ConcurrentHashMap<String, Run>()
|
||||
|
||||
fun open(accountName: String, authority: String): Run =
|
||||
Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it }
|
||||
|
||||
/**
|
||||
* Forgets the run: a late finishRun is then refused and isCancelled
|
||||
* answers true. Read [Run.syncAgainRequested] after this, so a request
|
||||
* can no longer slip in between reading the flag and closing.
|
||||
*/
|
||||
fun close(run: Run) {
|
||||
run.end("closed")
|
||||
run.cancelled = true
|
||||
runs.remove(run.runId)
|
||||
}
|
||||
|
||||
/** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */
|
||||
fun finish(runId: String, reportJson: String): Boolean =
|
||||
runs[runId]?.end("reported", reportJson) ?: false
|
||||
|
||||
/** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */
|
||||
fun isCancelled(runId: String): Boolean {
|
||||
val run = runs[runId] ?: return true
|
||||
return run.cancelled || !run.isWaiting
|
||||
}
|
||||
|
||||
fun cancel(runId: String) {
|
||||
runs[runId]?.cancelled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the open runs of [accountName] and [authority] to request another
|
||||
* sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a
|
||||
* `requestSync` that matches a sync it is running, so a change pushed
|
||||
* mid-run would otherwise wait for the next trigger. Returns whether a
|
||||
* run was flagged.
|
||||
*/
|
||||
fun flagSyncAgain(accountName: String, authority: String): Boolean {
|
||||
var flagged = false
|
||||
for (run in runs.values) {
|
||||
if (run.accountName == accountName && run.authority == authority) {
|
||||
run.requestSyncAgain()
|
||||
flagged = true
|
||||
}
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.TransactionTooLargeException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.SyncStateContract
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Runs device sync's provider calls for one Android account of our type and
|
||||
* one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of
|
||||
* `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this
|
||||
* class only builds the calls and reads the answers.
|
||||
*
|
||||
* Every URI carries `caller_is_syncadapter=true` and the account
|
||||
* (CalendarProvider accepts no other query parameter), so writes neither set
|
||||
* DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by
|
||||
* the account. The scope lookups are the exception: they read ids and owners
|
||||
* of rows of any account.
|
||||
*
|
||||
* The client is unstable: a provider process that dies fails the call instead
|
||||
* of taking this process (and the UI) with it. Call from a background thread;
|
||||
* close when done.
|
||||
*/
|
||||
class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val account: AccountRef
|
||||
private val client: ContentProviderClient
|
||||
|
||||
init {
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority")
|
||||
}
|
||||
account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext))
|
||||
client = appContext.contentResolver.acquireUnstableContentProviderClient(authority)
|
||||
?: throw IllegalStateException("No content provider for $authority")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ── the bridge calls ────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */
|
||||
fun query(queryJson: String): String {
|
||||
val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson))
|
||||
val writer = ProviderRowsWriter(plan.columns)
|
||||
val projection = plan.columns.toTypedArray()
|
||||
for (selection in plan.selections) {
|
||||
query(uri(plan.table), projection, selection, plan.orderBy).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
writer.beginRow()
|
||||
for (i in projection.indices) {
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i))
|
||||
Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i))
|
||||
Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i))
|
||||
// Blobs (photo thumbnails) never cross the bridge; readPhoto reads them.
|
||||
else -> writer.nullCell()
|
||||
}
|
||||
}
|
||||
writer.endRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.finish()
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */
|
||||
fun applyBatch(opsJson: String): String = try {
|
||||
val ops = ProviderOpParser.parseBatch(authority, opsJson)
|
||||
// Rows written for an account that is gone would outlive it: the
|
||||
// providers purge an account's rows only when accounts change.
|
||||
if (!DeviceSyncAccounts.exists(appContext, account.name)) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists")
|
||||
}
|
||||
if (ops.isEmpty()) {
|
||||
ProviderResults.ok(emptyList())
|
||||
} else {
|
||||
val planned = ScopePlanner(account, this).planBatch(ops)
|
||||
val results = client.applyBatch(ArrayList(planned.map(::build)))
|
||||
ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) })
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** The account's SyncState blob as text, or null when there is none. */
|
||||
fun readSyncState(): String? =
|
||||
SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type))
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* The raw contact's display photo, or its thumbnail when it has none,
|
||||
* scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null
|
||||
* when the raw contact has no photo.
|
||||
*/
|
||||
fun readPhoto(rawContactId: Long, maxPx: Int): String? {
|
||||
require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" }
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account")
|
||||
}
|
||||
var fileId: Long? = null
|
||||
var thumbnail: ByteArray? = null
|
||||
val photoRow = Selection(
|
||||
"raw_contact_id = ? AND mimetype = ?",
|
||||
listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE),
|
||||
)
|
||||
query(
|
||||
uri(ProviderTable.DATA),
|
||||
arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO),
|
||||
photoRow,
|
||||
"${ContactsContract.Data.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
if (!cursor.isNull(0)) fileId = cursor.getLong(0)
|
||||
if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1)
|
||||
}
|
||||
}
|
||||
val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null
|
||||
val jpeg = scaledJpeg(bytes, maxPx) ?: return null
|
||||
return JSONObject()
|
||||
.put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP))
|
||||
.put("fileId", fileId ?: JSONObject.NULL)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun readDisplayPhoto(rawContactId: Long): ByteArray? {
|
||||
val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon()
|
||||
.appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY)
|
||||
.scoped()
|
||||
.build()
|
||||
return try {
|
||||
client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } }
|
||||
} catch (e: FileNotFoundException) {
|
||||
// No display photo stored (small photos only have the thumbnail).
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||
val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx)
|
||||
val scaled = if (width == decoded.width && height == decoded.height) {
|
||||
decoded
|
||||
} else {
|
||||
Bitmap.createScaledBitmap(decoded, width, height, true)
|
||||
}
|
||||
return ByteArrayOutputStream().use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScopeLookup ─────────────────────────────────────────────
|
||||
|
||||
override fun ownCalendarIds(): Set<Long> {
|
||||
val ids = HashSet<Long>()
|
||||
val selection = Selection(
|
||||
"${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?",
|
||||
listOf(account.name, account.type),
|
||||
)
|
||||
query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c ->
|
||||
while (c.moveToNext()) ids += c.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> {
|
||||
val owners = HashMap<Long, AccountRef>()
|
||||
forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c ->
|
||||
owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty())
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.DATA, "raw_contact_id", ids)
|
||||
|
||||
override fun dataMimetypes(ids: Set<Long>): Map<Long, String> {
|
||||
val mimetypes = HashMap<Long, String>()
|
||||
forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c ->
|
||||
if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1)
|
||||
}
|
||||
return mimetypes
|
||||
}
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.EVENTS, "calendar_id", ids)
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(table, "event_id", ids)
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> {
|
||||
val events = HashSet<Long>()
|
||||
query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c ->
|
||||
while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Row id → [column] for the rows of [ids] that exist, in any account. */
|
||||
private fun idPairs(table: ProviderTable, column: String, ids: Set<Long>): Map<Long, Long> {
|
||||
val pairs = HashMap<Long, Long>()
|
||||
forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c ->
|
||||
if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
private fun forEachRow(
|
||||
table: ProviderTable,
|
||||
projection: Array<String>,
|
||||
idColumn: String,
|
||||
ids: Set<Long>,
|
||||
read: (Cursor) -> Unit,
|
||||
) {
|
||||
for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) {
|
||||
val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList())
|
||||
query(uri(table, scoped = false), projection, selection, null).use { c ->
|
||||
while (c.moveToNext()) read(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun query(uri: Uri, projection: Array<String>, selection: Selection, orderBy: String?): Cursor =
|
||||
client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy)
|
||||
?: throw IllegalStateException("$authority returned no cursor for $uri")
|
||||
|
||||
// ── operations ──────────────────────────────────────────────
|
||||
|
||||
private fun build(op: PlannedOp): ContentProviderOperation = when (op) {
|
||||
is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) }
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Update -> ContentProviderOperation
|
||||
.newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.apply {
|
||||
op.values?.let { values ->
|
||||
withValues(ContentValues().apply { for ((column, text) in values) put(column, text) })
|
||||
}
|
||||
}
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
// An insert that replaces the account's row; never a yield point.
|
||||
is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation(
|
||||
syncStateUri(),
|
||||
Account(account.name, account.type),
|
||||
op.value.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply {
|
||||
// An item URI takes no selection at all (CalendarProvider refuses one).
|
||||
if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply {
|
||||
if (count != null) withExpectedCount(count)
|
||||
}
|
||||
|
||||
private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) {
|
||||
// Settings inserts answer with the account's URI, which has no id.
|
||||
is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull())
|
||||
is PlannedOp.SetSyncState -> OpOutcome()
|
||||
else -> OpOutcome(count = count)
|
||||
}
|
||||
|
||||
private fun contentValues(values: Map<String, Cell>) = ContentValues(values.size).apply {
|
||||
for ((column, cell) in values) {
|
||||
when (cell) {
|
||||
is Cell.Text -> put(column, cell.value)
|
||||
is Cell.Integer -> put(column, cell.value)
|
||||
is Cell.Real -> put(column, cell.value)
|
||||
is Cell.Blob -> put(column, decodeBlob(column, cell.base64))
|
||||
Cell.Null -> putNull(column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBlob(column: String, base64: String): ByteArray = try {
|
||||
Base64.decode(base64, Base64.DEFAULT)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64")
|
||||
}
|
||||
|
||||
// ── URIs ────────────────────────────────────────────────────
|
||||
|
||||
private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri {
|
||||
val base = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI
|
||||
ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI
|
||||
ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI
|
||||
ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI
|
||||
ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI
|
||||
ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI
|
||||
ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI
|
||||
ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI
|
||||
ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI
|
||||
ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI
|
||||
}
|
||||
val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon()
|
||||
return if (scoped) builder.scoped().build() else builder.syncAdapter().build()
|
||||
}
|
||||
|
||||
private fun syncStateUri(): Uri = when (authority) {
|
||||
DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI
|
||||
else -> CalendarContract.SyncState.CONTENT_URI
|
||||
}.buildUpon().scoped().build()
|
||||
|
||||
private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true")
|
||||
|
||||
private fun Uri.Builder.scoped() = syncAdapter()
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type)
|
||||
|
||||
companion object {
|
||||
private const val ID = "_id"
|
||||
private const val JPEG_QUALITY = 85
|
||||
|
||||
/** Same parameter name in ContactsContract and CalendarContract. */
|
||||
private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER
|
||||
|
||||
/** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */
|
||||
fun failureOf(e: Throwable): BatchFailure {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
when (t) {
|
||||
is ProviderRefusal -> return t.reason
|
||||
is TransactionTooLargeException -> return BatchFailure.TOO_LARGE
|
||||
is SecurityException -> return BatchFailure.PERMISSION
|
||||
is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message)
|
||||
}
|
||||
t = t.cause?.takeIf { it !== t }
|
||||
}
|
||||
return BatchFailure.PROVIDER
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The provider operations of device sync (#34) as the engine sends them: the
|
||||
* `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed
|
||||
* into a validated model. [ScopePlanner] turns the model into provider calls
|
||||
* scoped to one account and [ProviderIo] runs them. Nothing here touches
|
||||
* Android types, so JVM tests cover all of it.
|
||||
*/
|
||||
|
||||
/** `BatchFailure` of types.ts: why a batch was refused or failed. */
|
||||
enum class BatchFailure(val wire: String) {
|
||||
ASSERT("assert"),
|
||||
TOO_LARGE("tooLarge"),
|
||||
SCOPE("scope"),
|
||||
PERMISSION("permission"),
|
||||
PROVIDER("provider"),
|
||||
}
|
||||
|
||||
/** A query or batch refused before it reached the provider. */
|
||||
class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message)
|
||||
|
||||
/** How the rows of a table are tied to an account; see [ScopePlanner]. */
|
||||
enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS }
|
||||
|
||||
/** The tables the engine may touch, per authority. Any other table is refused. */
|
||||
enum class ProviderTable(
|
||||
val wire: String,
|
||||
val authority: String,
|
||||
val scope: TableScope,
|
||||
/** The table whose row each row here belongs to, named by [parentColumn]. */
|
||||
val parent: ProviderTable? = null,
|
||||
val parentColumn: String? = null,
|
||||
/**
|
||||
* How a selection names the row id; null when rows have none (Settings
|
||||
* rows are keyed by their account). CalendarProvider queries attendees
|
||||
* joined with events and calendars, where a bare `_id` is ambiguous.
|
||||
*/
|
||||
val idColumn: String? = "_id",
|
||||
) {
|
||||
RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"),
|
||||
GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null),
|
||||
CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"),
|
||||
ATTENDEES(
|
||||
"attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id",
|
||||
),
|
||||
REMINDERS(
|
||||
"reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id",
|
||||
),
|
||||
EXTENDED_PROPERTIES(
|
||||
"extended_properties",
|
||||
DeviceSyncAccounts.CALENDAR_AUTHORITY,
|
||||
TableScope.EVENT_IDS,
|
||||
EVENTS,
|
||||
"event_id",
|
||||
"ExtendedProperties._id",
|
||||
),
|
||||
COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
;
|
||||
|
||||
/** An insert returns the new row id, which later ops can back-reference. */
|
||||
val insertReturnsId: Boolean get() = idColumn != null
|
||||
|
||||
companion object {
|
||||
fun of(authority: String, wire: String): ProviderTable =
|
||||
entries.firstOrNull { it.wire == wire && it.authority == authority }
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell as it crosses the bridge: text, a number, null, or (written to
|
||||
* `data.data15` only) photo bytes as base64. Numbers that are integers become
|
||||
* [Integer], so an assert compares `5` with the provider's `5`, not `5.0`.
|
||||
*/
|
||||
sealed interface Cell {
|
||||
data class Text(val value: String) : Cell
|
||||
data class Integer(val value: Long) : Cell
|
||||
data class Real(val value: Double) : Cell
|
||||
data class Blob(val base64: String) : Cell
|
||||
data object Null : Cell
|
||||
|
||||
/** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */
|
||||
fun text(): String? = when (this) {
|
||||
is Text -> value
|
||||
is Integer -> value.toString()
|
||||
is Real -> value.toString()
|
||||
is Blob -> throw IllegalStateException("Blobs have no text form")
|
||||
Null -> null
|
||||
}
|
||||
|
||||
/** The row id this cell names, if it names one (`12` or `"12"`). */
|
||||
fun rowId(): Long? = when (this) {
|
||||
is Integer -> value
|
||||
is Text -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */
|
||||
data class RowTarget(val id: Long?, val where: String?, val args: List<String>)
|
||||
|
||||
data class ProviderQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val where: String?,
|
||||
val args: List<String>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One `ProviderOp` of types.ts, validated. */
|
||||
sealed interface ProviderOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
/** Column → index of an earlier insert of the batch whose new row id fills it. */
|
||||
val refs: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>?,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp
|
||||
}
|
||||
|
||||
const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership"
|
||||
|
||||
/**
|
||||
* Checks a selection or sort order before the account's scope is ANDed onto
|
||||
* it as `(text) AND (scope)`: its parentheses must balance outside quoted
|
||||
* literals, or `1) OR (1` would close the scope's own parenthesis. It may
|
||||
* not end or comment out the statement, nor query other tables. The engine's
|
||||
* selections are constants with `?` arguments; this guards against bugs.
|
||||
*/
|
||||
object SqlFragments {
|
||||
private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun check(text: String, what: String) {
|
||||
var depth = 0
|
||||
var quote: Char? = null
|
||||
val outside = StringBuilder(text.length)
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
if (quote != null) {
|
||||
if (c == quote) {
|
||||
// A doubled quote is an escaped one inside the literal.
|
||||
if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null
|
||||
}
|
||||
outside.append(' ')
|
||||
} else {
|
||||
when (c) {
|
||||
'\'', '"' -> {
|
||||
quote = c
|
||||
outside.append(' ')
|
||||
}
|
||||
'(' -> {
|
||||
depth++
|
||||
outside.append(c)
|
||||
}
|
||||
')' -> {
|
||||
if (--depth < 0) refuse(what, "closes a parenthesis it did not open")
|
||||
outside.append(c)
|
||||
}
|
||||
';' -> refuse(what, "ends the statement")
|
||||
'\u0000' -> refuse(what, "contains a NUL character")
|
||||
else -> outside.append(c)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (quote != null) refuse(what, "leaves a quote open")
|
||||
if (depth != 0) refuse(what, "leaves a parenthesis open")
|
||||
val bare = outside.toString()
|
||||
if ("--" in bare || "/*" in bare) refuse(what, "contains a comment")
|
||||
FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") }
|
||||
}
|
||||
|
||||
private fun refuse(what: String, why: String): Nothing =
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$what $why")
|
||||
}
|
||||
|
||||
/**
|
||||
* The table a written column points into when the row it names must belong to
|
||||
* the account as well: the parent of a row, the master of an exception event,
|
||||
* the group of a membership row.
|
||||
*/
|
||||
internal fun referencedTable(table: ProviderTable, column: String, values: Map<String, Cell>): ProviderTable? = when {
|
||||
column == table.parentColumn -> table.parent
|
||||
table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS
|
||||
table == ProviderTable.DATA && column == "data1" &&
|
||||
values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS
|
||||
else -> null
|
||||
}
|
||||
|
||||
object ProviderOpParser {
|
||||
/**
|
||||
* ContactsProvider throws when its op counter reaches this. It counts an
|
||||
* op before resetting at a yield point, so at most 499 ops may follow a
|
||||
* yield point up to and including the next one.
|
||||
*/
|
||||
const val CONTACTS_OPS_PER_YIELD_POINT = 500
|
||||
|
||||
/** JS numbers beyond this are not exact integers any more. */
|
||||
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0
|
||||
|
||||
fun parseQuery(authority: String, json: String): ProviderQuery {
|
||||
val o = parseObject(json, "query")
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array")
|
||||
val columns = (0 until rawColumns.length()).map { i ->
|
||||
rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string")
|
||||
}
|
||||
if (columns.isEmpty()) throw invalid("query needs at least one column")
|
||||
val (where, args) = o.whereArgs("query")
|
||||
val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") }
|
||||
return ProviderQuery(table, columns, where, args, orderBy)
|
||||
}
|
||||
|
||||
fun parseBatch(authority: String, json: String): List<ProviderOp> {
|
||||
val array = try {
|
||||
JSONArray(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("ops are not a JSON array: ${e.message}")
|
||||
}
|
||||
val ops = (0 until array.length()).map { i ->
|
||||
val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object")
|
||||
parseOp(authority, i, o)
|
||||
}
|
||||
checkSyncState(ops)
|
||||
checkBackReferences(ops)
|
||||
if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops)
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp {
|
||||
val where = "op $index"
|
||||
val kind = o.opt("op") as? String ?: throw invalid("$where has no op")
|
||||
val yieldAllowed = o.optBoolean("yieldAllowed", false)
|
||||
if (kind == "syncState") {
|
||||
val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value")
|
||||
return ProviderOp.SetSyncState(value, yieldAllowed)
|
||||
}
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
return when (kind) {
|
||||
"insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed)
|
||||
"update" -> {
|
||||
val values = o.writeValues(table, where)
|
||||
if (values.isEmpty()) throw invalid("$where: an update needs values")
|
||||
ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed)
|
||||
}
|
||||
"delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed)
|
||||
"assert" -> {
|
||||
val target = o.target(table, where)
|
||||
val values = o.assertValues(where)
|
||||
val expectCount = o.expectCount(where)
|
||||
if (values.isNullOrEmpty() && expectCount == null && target.id == null) {
|
||||
throw invalid("$where: an assert needs values, an expected count or an id")
|
||||
}
|
||||
ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed)
|
||||
}
|
||||
else -> throw invalid("$where: unknown op $kind")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state describes the rows of its batch, so it must commit with them:
|
||||
* as the last op, and not at a yield point, where the provider may commit
|
||||
* everything before it on its own.
|
||||
*/
|
||||
private fun checkSyncState(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.SetSyncState) return@forEachIndexed
|
||||
if (i != ops.lastIndex) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch")
|
||||
}
|
||||
if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBackReferences(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.Insert) return@forEachIndexed
|
||||
for ((column, ref) in op.refs) {
|
||||
val target = ops.getOrNull(ref)
|
||||
if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) {
|
||||
throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id")
|
||||
}
|
||||
val expected = referencedTable(op.table, column, op.values)
|
||||
if (expected != null && target.table != expected) {
|
||||
throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}",
|
||||
)
|
||||
}
|
||||
}
|
||||
val parentColumn = op.table.parentColumn ?: return@forEachIndexed
|
||||
val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null
|
||||
if (!named) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkYieldPoints(ops: List<ProviderOp>) {
|
||||
var count = 0
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (++count >= CONTACTS_OPS_PER_YIELD_POINT) {
|
||||
throw invalid(
|
||||
"op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " +
|
||||
"(ContactsProvider refuses the batch)",
|
||||
)
|
||||
}
|
||||
if (i > 0 && op.yieldAllowed) count = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────
|
||||
|
||||
private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message)
|
||||
|
||||
private fun parseObject(json: String, what: String): JSONObject = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("$what is not a JSON object: ${e.message}")
|
||||
}
|
||||
|
||||
private fun JSONObject.requireString(key: String): String =
|
||||
opt(key) as? String ?: throw invalid("$key must be a string")
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) {
|
||||
null, JSONObject.NULL -> null
|
||||
is String -> v.takeIf { it.isNotBlank() }
|
||||
else -> throw invalid("$key must be a string")
|
||||
}
|
||||
|
||||
private fun JSONObject.target(table: ProviderTable, where: String): RowTarget {
|
||||
val id = when (val raw = opt("id")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 }
|
||||
?: throw invalid("$where: id must be a row id")
|
||||
}
|
||||
if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id")
|
||||
val (sql, args) = whereArgs(where)
|
||||
return RowTarget(id, sql, args)
|
||||
}
|
||||
|
||||
private fun JSONObject.whereArgs(where: String): Pair<String?, List<String>> {
|
||||
val sql = optionalString("where")?.also { SqlFragments.check(it, "$where: where") }
|
||||
val args = when (val raw = opt("args")) {
|
||||
null, JSONObject.NULL -> emptyList()
|
||||
is JSONArray -> (0 until raw.length()).map { i ->
|
||||
scalar(raw.opt(i), "$where: args[$i]").text()
|
||||
?: throw invalid("$where: args[$i] must be a string or a number")
|
||||
}
|
||||
else -> throw invalid("$where: args must be an array")
|
||||
}
|
||||
if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where")
|
||||
return sql to args
|
||||
}
|
||||
|
||||
private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||
?: throw invalid("$where: expectCount must be a count")
|
||||
}
|
||||
|
||||
private fun JSONObject.refs(where: String): Map<String, Int> {
|
||||
val raw = opt("refs") ?: return emptyMap()
|
||||
if (raw == JSONObject.NULL) return emptyMap()
|
||||
if (raw !is JSONObject) throw invalid("$where: refs must be an object")
|
||||
val out = LinkedHashMap<String, Int>()
|
||||
for (column in raw.keys()) {
|
||||
val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }
|
||||
?: throw invalid("$where: refs.$column must be an op index")
|
||||
out[column] = index.toInt()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.writeValues(table: ProviderTable, where: String): Map<String, Cell> {
|
||||
val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) {
|
||||
val v = raw.opt(column)
|
||||
out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.assertValues(where: String): Map<String, Cell>? {
|
||||
val raw = opt("values") ?: return null
|
||||
if (raw == JSONObject.NULL) return null
|
||||
if (raw !is JSONObject) throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column")
|
||||
return out
|
||||
}
|
||||
|
||||
/** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */
|
||||
private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob {
|
||||
if (table != ProviderTable.DATA || column != "data15") {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column")
|
||||
}
|
||||
val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }")
|
||||
return Cell.Blob(base64)
|
||||
}
|
||||
|
||||
private fun scalar(v: Any?, what: String): Cell = when (v) {
|
||||
null, JSONObject.NULL -> Cell.Null
|
||||
is String -> Cell.Text(v)
|
||||
is Boolean -> Cell.Integer(if (v) 1 else 0)
|
||||
is Number -> numberCell(v)
|
||||
else -> throw invalid("$what must be text, a number or null")
|
||||
}
|
||||
|
||||
/** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */
|
||||
internal fun numberCell(n: Number): Cell = when (n) {
|
||||
is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong())
|
||||
is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble())
|
||||
is BigDecimal -> try {
|
||||
Cell.Integer(n.longValueExact())
|
||||
} catch (e: ArithmeticException) {
|
||||
Cell.Real(n.toDouble())
|
||||
}
|
||||
else -> {
|
||||
val d = n.toDouble()
|
||||
if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Plans provider calls for one account: every selection gets the account
|
||||
* condition ANDed in, and every row an operation names by id, or points to
|
||||
* from a written column, is checked to belong to the account first
|
||||
* (docs/device-sync.md, "Native module API", Scoping).
|
||||
*
|
||||
* How a table is tied to the account ([TableScope]):
|
||||
* - raw contacts, groups, settings, calendars and colors carry it in their
|
||||
* `account_name`/`account_type` columns;
|
||||
* - contacts data rows are filtered by ContactsProvider itself through the
|
||||
* `account_name`/`account_type` URI parameters. Their account columns are
|
||||
* never selected on: Android 17 drops them from the Data view for apps
|
||||
* targeting more than API 36;
|
||||
* - events get `calendar_id IN (…)` of the account's calendars, not the
|
||||
* account columns: CalendarProvider updates events on the Events table,
|
||||
* which has none;
|
||||
* - attendees, reminders and extended properties get `event_id IN (…)` of the
|
||||
* account's events among those the operation touches. CalendarProvider
|
||||
* applies no account filter to them.
|
||||
*
|
||||
* Selections never use subqueries: the providers may compile them strictly.
|
||||
*/
|
||||
class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) {
|
||||
private val calendarIds: Set<Long> by lazy { lookup.ownCalendarIds() }
|
||||
|
||||
fun planQuery(query: ProviderQuery): PlannedQuery {
|
||||
val target = RowTarget(null, query.where, query.args)
|
||||
val selections = if (query.table.scope == TableScope.EVENT_IDS) {
|
||||
// Mostly a handful of events (the engine reads children per chunk
|
||||
// of events); a query over all of them is split so no selection
|
||||
// outgrows a Binder transaction. Rows are then ordered per chunk.
|
||||
ownEventsMatching(query.table, target)
|
||||
.sorted()
|
||||
.chunked(ID_LIST_CHUNK)
|
||||
.map { chunk -> selection(query.table, target, chunk) }
|
||||
} else {
|
||||
listOf(selection(query.table, target, null))
|
||||
}
|
||||
return PlannedQuery(query.table, query.columns, selections, query.orderBy)
|
||||
}
|
||||
|
||||
fun planBatch(ops: List<ProviderOp>): List<PlannedOp> {
|
||||
val memberships = membershipUpdates(ops)
|
||||
val owners = Owners(ownershipRequests(ops, memberships))
|
||||
return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership rows among the data rows that updates by id write `data1`
|
||||
* of without restating the mimetype: their `data1` names a group, which
|
||||
* must be the account's like on insert.
|
||||
*/
|
||||
private fun membershipUpdates(ops: List<ProviderOp>): Set<Long> {
|
||||
val ids = ops.asSequence()
|
||||
.filterIsInstance<ProviderOp.Update>()
|
||||
.filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) }
|
||||
.mapNotNull { it.target.id }
|
||||
.toSet()
|
||||
if (ids.isEmpty()) return emptySet()
|
||||
return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys
|
||||
}
|
||||
|
||||
private fun writesUntypedData1(values: Map<String, Cell>) =
|
||||
MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null
|
||||
|
||||
private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set<Long>): PlannedOp = when (op) {
|
||||
is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
|
||||
is ProviderOp.Insert -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values - op.refs.keys, owners)
|
||||
val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values
|
||||
PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Update -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values, owners)
|
||||
if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) {
|
||||
checkMembershipUpdate(i, op, owners, memberships)
|
||||
}
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
if (op.table == ProviderTable.EXTENDED_PROPERTIES) {
|
||||
// CalendarProvider updates extended properties only through
|
||||
// their item URI, which takes no selection: the id check above
|
||||
// is the scope.
|
||||
val id = op.target.id
|
||||
if (id == null || op.target.where != null) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id")
|
||||
}
|
||||
PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed)
|
||||
} else {
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
is ProviderOp.Delete -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Assert -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
val values = op.values?.mapValues { (_, cell) -> cell.text() }
|
||||
PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */
|
||||
private fun expectedCount(target: RowTarget, expectCount: Int?): Int? =
|
||||
expectCount ?: if (target.id != null) 1 else null
|
||||
|
||||
private fun accountCells() = mapOf(
|
||||
ACCOUNT_NAME to Cell.Text(account.name),
|
||||
ACCOUNT_TYPE to Cell.Text(account.type),
|
||||
)
|
||||
|
||||
/**
|
||||
* Account columns may only restate the account, on the tables that have
|
||||
* them; a data row, event or attendee follows its parent's account.
|
||||
*/
|
||||
private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) {
|
||||
val cell = values[column] ?: continue
|
||||
val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) {
|
||||
ACCOUNT_NAME -> cell == Cell.Text(account.name)
|
||||
ACCOUNT_TYPE -> cell == Cell.Text(account.type)
|
||||
else -> cell == Cell.Null
|
||||
}
|
||||
if (!restatesAccount) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents, masters and groups a written column names must be rows of the
|
||||
* account that still exist. (Back-references were checked by the parser:
|
||||
* they name inserts of the same batch.)
|
||||
*/
|
||||
private fun checkReferences(i: Int, table: ProviderTable, values: Map<String, Cell>, owners: Owners) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
if (cell == Cell.Null) {
|
||||
if (column == table.parentColumn) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val id = cell.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id")
|
||||
when (owners.of(referenced, id)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id of another account",
|
||||
)
|
||||
// Deleted meanwhile, as far as the engine can tell: re-read and re-plan.
|
||||
Owner.MISSING -> throw ProviderRefusal(
|
||||
BatchFailure.ASSERT,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `data1` written by an update that doesn't restate the mimetype: on a
|
||||
* membership row it names a group. Rows chosen by a selection could be
|
||||
* memberships the lookup didn't see, so only updates by id may do it.
|
||||
*/
|
||||
private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set<Long>) {
|
||||
val id = op.target.id
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id")
|
||||
if (id !in memberships) return
|
||||
val group = op.values[DATA1]?.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id")
|
||||
when (owners.of(ProviderTable.GROUPS, group)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account")
|
||||
Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo row's file id (`data14`) is the provider's: it points into the
|
||||
* photo store, where another account's file id would let `readPhoto`
|
||||
* read that account's photo. Photos are written as bytes (`data15`).
|
||||
*/
|
||||
private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only")
|
||||
}
|
||||
}
|
||||
|
||||
/** A row named by id must not belong to another account; a missing one fails on the expected count instead. */
|
||||
private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) {
|
||||
val id = target.id ?: return
|
||||
if (owners.of(table, id) == Owner.FOREIGN) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account")
|
||||
}
|
||||
}
|
||||
|
||||
/** For attendees, reminders and extended properties: the account's events the op may touch. */
|
||||
private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection<Long>? {
|
||||
if (table.scope != TableScope.EVENT_IDS) return null
|
||||
val id = target.id ?: return ownEventsMatching(table, target)
|
||||
return listOfNotNull(owners.eventOf(table, id))
|
||||
}
|
||||
|
||||
private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set<Long> {
|
||||
val candidates = lookup.childEventIdsMatching(table, target.where, target.args)
|
||||
if (candidates.isEmpty()) return emptySet()
|
||||
return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys
|
||||
}
|
||||
|
||||
private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection<Long>?): Selection {
|
||||
val parts = ArrayList<String>(3)
|
||||
val args = ArrayList<String>()
|
||||
target.id?.let {
|
||||
parts += "${table.idColumn} = ?"
|
||||
args += it.toString()
|
||||
}
|
||||
target.where?.let {
|
||||
parts += it
|
||||
args += target.args
|
||||
}
|
||||
when (table.scope) {
|
||||
TableScope.ACCOUNT_COLUMNS -> {
|
||||
parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?"
|
||||
args += account.name
|
||||
args += account.type
|
||||
}
|
||||
TableScope.URI_PARAMETERS -> Unit
|
||||
TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds)
|
||||
TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty())
|
||||
}
|
||||
return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args)
|
||||
}
|
||||
|
||||
/** Every row id the batch names, by table: op targets and referenced rows. */
|
||||
private fun ownershipRequests(ops: List<ProviderOp>, memberships: Set<Long>): Map<ProviderTable, Set<Long>> {
|
||||
val requests = HashMap<ProviderTable, MutableSet<Long>>()
|
||||
fun request(table: ProviderTable, id: Long) {
|
||||
requests.getOrPut(table) { HashSet() } += id
|
||||
}
|
||||
fun requestReferences(table: ProviderTable, values: Map<String, Cell>) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
cell.rowId()?.let { request(referenced, it) }
|
||||
}
|
||||
}
|
||||
for (op in ops) {
|
||||
when (op) {
|
||||
is ProviderOp.Insert -> requestReferences(op.table, op.values)
|
||||
is ProviderOp.Update -> {
|
||||
requestReferences(op.table, op.values)
|
||||
op.target.id?.let { request(op.table, it) }
|
||||
if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, it) }
|
||||
}
|
||||
is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.SetSyncState -> Unit
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
private enum class Owner { OURS, FOREIGN, MISSING }
|
||||
|
||||
/** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */
|
||||
private inner class Owners(requests: Map<ProviderTable, Set<Long>>) {
|
||||
private val owners = HashMap<ProviderTable, Map<Long, Owner>>()
|
||||
private val childEvents = HashMap<ProviderTable, Map<Long, Long>>()
|
||||
|
||||
init {
|
||||
// Children lead to events, data rows to raw contacts; both are
|
||||
// added to their parent's lookup.
|
||||
val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty())
|
||||
for (table in CHILD_TABLES) {
|
||||
val ids = requests[table] ?: continue
|
||||
val events = lookup.childEvents(table, ids)
|
||||
childEvents[table] = events
|
||||
eventIds += events.values
|
||||
}
|
||||
val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty())
|
||||
val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty()
|
||||
rawContactIds += dataParents.values
|
||||
|
||||
if (eventIds.isNotEmpty()) {
|
||||
owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds)
|
||||
.mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
for ((table, events) in childEvents) {
|
||||
// A child whose event is gone is an orphan, not ours.
|
||||
owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) }
|
||||
}
|
||||
val roots = HashMap<ProviderTable, Set<Long>>()
|
||||
for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it }
|
||||
if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds
|
||||
for ((table, ids) in roots) {
|
||||
owners[table] = lookup.rootAccounts(table, ids)
|
||||
.mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
if (dataParents.isNotEmpty()) {
|
||||
owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) ->
|
||||
ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownerOrForeign(table: ProviderTable, id: Long): Owner =
|
||||
owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN
|
||||
|
||||
fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING
|
||||
|
||||
/** The event of a child row, when both exist and are the account's. */
|
||||
fun eventOf(table: ProviderTable, id: Long): Long? =
|
||||
childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_NAME = "account_name"
|
||||
const val ACCOUNT_TYPE = "account_type"
|
||||
const val DATA_SET = "data_set"
|
||||
private const val MIMETYPE = "mimetype"
|
||||
private const val DATA1 = "data1"
|
||||
private const val DATA14 = "data14"
|
||||
|
||||
/** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */
|
||||
const val ID_LIST_CHUNK = 500
|
||||
|
||||
private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS }
|
||||
private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS }
|
||||
|
||||
/** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */
|
||||
fun idList(column: String, ids: Collection<Long>): String =
|
||||
if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")")
|
||||
}
|
||||
}
|
||||
|
||||
/** An Android account of our type: the scope of every provider call. */
|
||||
data class AccountRef(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* What [ScopePlanner] needs to know about rows beyond the operations
|
||||
* themselves. [ProviderIo] answers with provider queries: except for
|
||||
* [ownCalendarIds], these read rows of any account, but only their ids and
|
||||
* owners, to tell rows of another account from rows that are gone.
|
||||
*/
|
||||
interface ScopeLookup {
|
||||
/** `_id`s of the account's calendars. */
|
||||
fun ownCalendarIds(): Set<Long>
|
||||
|
||||
/** The account of each row of a root table that exists. */
|
||||
fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef>
|
||||
|
||||
/** The raw contact of each data row that exists. */
|
||||
fun dataRawContacts(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The mimetype of each data row that exists. */
|
||||
fun dataMimetypes(ids: Set<Long>): Map<Long, String>
|
||||
|
||||
/** The calendar of each event that exists. */
|
||||
fun eventCalendars(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The event of each attendee, reminder or extended property that exists. */
|
||||
fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The events of the rows of a child table that match [where]. */
|
||||
fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long>
|
||||
}
|
||||
|
||||
/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */
|
||||
data class Selection(val sql: String?, val args: List<String>) {
|
||||
companion object {
|
||||
val NONE = Selection(null, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */
|
||||
data class PlannedQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val selections: List<Selection>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */
|
||||
sealed interface PlannedOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
val backReferences: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [itemId] set: an update through the row's own URI, without a selection. */
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val itemId: Long?,
|
||||
val values: Map<String, Cell>,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [values] compared as text, as ContentProviderOperation does. */
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val values: Map<String, String?>?,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class SetSyncState(val value: String) : PlannedOp {
|
||||
override val yieldAllowed: Boolean get() = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The result of one op, as `OpResult` of types.ts. */
|
||||
data class OpOutcome(val id: Long? = null, val count: Int? = null)
|
||||
|
||||
object ProviderResults {
|
||||
fun ok(results: List<OpOutcome>): String {
|
||||
val array = JSONArray()
|
||||
for (result in results) {
|
||||
array.put(JSONObject().apply {
|
||||
result.id?.let { put("id", it) }
|
||||
result.count?.let { put("count", it) }
|
||||
})
|
||||
}
|
||||
return JSONObject().put("ok", true).put("results", array).toString()
|
||||
}
|
||||
|
||||
fun failed(reason: BatchFailure, message: String?): String = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("reason", reason.wire)
|
||||
.put("message", message ?: reason.wire)
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* An OperationApplicationException is an assert that did not hold or an
|
||||
* expected count that did not match (`assert`), except when an insert got
|
||||
* no row back or ContactsProvider refused the batch's yield points.
|
||||
*/
|
||||
fun failureOfOperationApplication(message: String?): BatchFailure = when {
|
||||
message == null -> BatchFailure.ASSERT
|
||||
message.startsWith("Insert into ") -> BatchFailure.PROVIDER
|
||||
message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER
|
||||
else -> BatchFailure.ASSERT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results as `ProviderRows` JSON, built by hand: a chunk of contacts
|
||||
* can carry megabytes of shadows, and this is the hot path of every run.
|
||||
*/
|
||||
class ProviderRowsWriter(columns: List<String>) {
|
||||
private val out = StringBuilder(4096)
|
||||
private var rows = 0
|
||||
private var cells = 0
|
||||
|
||||
init {
|
||||
out.append("{\"columns\":[")
|
||||
columns.forEachIndexed { i, column ->
|
||||
if (i > 0) out.append(',')
|
||||
appendString(column)
|
||||
}
|
||||
out.append("],\"rows\":[")
|
||||
}
|
||||
|
||||
fun beginRow() {
|
||||
if (rows++ > 0) out.append(',')
|
||||
out.append('[')
|
||||
cells = 0
|
||||
}
|
||||
|
||||
fun endRow() {
|
||||
out.append(']')
|
||||
}
|
||||
|
||||
fun nullCell() = cell { out.append("null") }
|
||||
|
||||
fun longCell(value: Long) = cell { out.append(value) }
|
||||
|
||||
fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") }
|
||||
|
||||
fun textCell(value: String) = cell { appendString(value) }
|
||||
|
||||
fun finish(): String = out.append("]}").toString()
|
||||
|
||||
private inline fun cell(write: () -> Unit) {
|
||||
if (cells++ > 0) out.append(',')
|
||||
write()
|
||||
}
|
||||
|
||||
private fun appendString(s: String) {
|
||||
out.append('"')
|
||||
for (c in s) {
|
||||
when {
|
||||
c == '"' -> out.append("\\\"")
|
||||
c == '\\' -> out.append("\\\\")
|
||||
c == '\n' -> out.append("\\n")
|
||||
c == '\r' -> out.append("\\r")
|
||||
c == '\t' -> out.append("\\t")
|
||||
c < ' ' || c.code == 0x2028 || c.code == 0x2029 ->
|
||||
out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0'))
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
out.append('"')
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */
|
||||
object PhotoScaling {
|
||||
/** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */
|
||||
fun sampleSize(width: Int, height: Int, maxPx: Int): Int {
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
var sample = 1
|
||||
val longer = maxOf(width, height)
|
||||
while (longer / (sample * 2) >= maxPx) sample *= 2
|
||||
return sample
|
||||
}
|
||||
|
||||
fun targetSize(width: Int, height: Int, maxPx: Int): Pair<Int, Int> {
|
||||
val longer = maxOf(width, height)
|
||||
if (longer <= maxPx) return width to height
|
||||
val scale = maxPx.toDouble() / longer
|
||||
return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a push asks for (#34): syncs of the Android accounts that contacts and
|
||||
* calendar changes feed, and whether the mail push task should run at all.
|
||||
*
|
||||
* The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data
|
||||
* key `changed`: JMAP account id → type → state; only its first account is
|
||||
* repeated as `accountId`), so every account key is looked at. The mail task
|
||||
* runs only for mail: when some account's map has a mail type, for anything
|
||||
* but a StateChange (an EmailPush), when `changed` is missing, unreadable or
|
||||
* names no type (older relays), or when an account's entry is unreadable, so
|
||||
* a contact change never ends in a mail notification. The push task's guard
|
||||
* (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules.
|
||||
*/
|
||||
object PushRouting {
|
||||
/** An Android account and the authorities it syncs, fed by one JMAP account. */
|
||||
data class Route(val accountName: String, val authorities: Set<String>)
|
||||
|
||||
data class Decision(
|
||||
/** (Android account name, authority) pairs to request a sync for. */
|
||||
val syncs: Set<Pair<String, String>>,
|
||||
val startMailTask: Boolean,
|
||||
)
|
||||
|
||||
private const val STATE_CHANGE_KIND = "jmap-state-change"
|
||||
|
||||
/**
|
||||
* Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`,
|
||||
* which new mail changes too. A subscription made by an older build
|
||||
* listens to them until the launch-time resync replaces its types, and a
|
||||
* server without `EmailDelivery` has only them. Keep in sync with
|
||||
* MAIL_TYPES in src/lib/push-background-task.ts.
|
||||
*/
|
||||
private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox")
|
||||
private val CONTACT_TYPES = setOf("ContactCard", "AddressBook")
|
||||
private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar")
|
||||
|
||||
fun decide(data: Map<String, String>, routes: Map<String, List<Route>>): Decision {
|
||||
val changed = data["changed"]?.let(::parseChanged)
|
||||
// Only a StateChange can say it carries no mail.
|
||||
var mail = data["kind"] != STATE_CHANGE_KIND || changed == null
|
||||
val syncs = LinkedHashSet<Pair<String, String>>()
|
||||
for ((jmapAccountId, types) in changed.orEmpty()) {
|
||||
if (types == null) {
|
||||
// One account's entry is unreadable: it may be mail.
|
||||
mail = true
|
||||
continue
|
||||
}
|
||||
if (types.any { it in MAIL_TYPES }) mail = true
|
||||
val contacts = types.any { it in CONTACT_TYPES }
|
||||
val calendar = types.any { it in CALENDAR_TYPES }
|
||||
for (route in routes[jmapAccountId].orEmpty()) {
|
||||
if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
}
|
||||
if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
}
|
||||
}
|
||||
}
|
||||
return Decision(syncs, mail)
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP account id → its changed types (null for an entry that is not an
|
||||
* object). Null for a map that is unreadable or names no type at all,
|
||||
* which the relay sends as `{}` when it had no map.
|
||||
*/
|
||||
private fun parseChanged(json: String): Map<String, Set<String>?>? {
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
return null
|
||||
}
|
||||
val out = LinkedHashMap<String, Set<String>?>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val types = o.opt(jmapAccountId) as? JSONObject
|
||||
out[jmapAccountId] = types?.keys()?.asSequence()?.toSet()
|
||||
}
|
||||
return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it.
|
||||
* [strict] refuses anything malformed (IllegalArgumentException); otherwise
|
||||
* malformed entries are skipped, so a bad route never stops mail.
|
||||
*/
|
||||
fun parseRoutes(json: String?, strict: Boolean = false): Map<String, List<Route>> {
|
||||
if (json.isNullOrEmpty()) return emptyMap()
|
||||
fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message")
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
if (strict) bad("not a JSON object") else return emptyMap()
|
||||
}
|
||||
val out = LinkedHashMap<String, List<Route>>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val entries = o.opt(jmapAccountId) as? JSONArray
|
||||
?: if (strict) bad("$jmapAccountId must map to an array") else continue
|
||||
val routes = ArrayList<Route>()
|
||||
for (i in 0 until entries.length()) {
|
||||
val entry = entries.opt(i) as? JSONObject
|
||||
val name = entry?.opt("accountName") as? String
|
||||
val authorities = entry?.opt("authorities") as? JSONArray
|
||||
if (name.isNullOrEmpty() || authorities == null) {
|
||||
if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue
|
||||
}
|
||||
val known = (0 until authorities.length()).mapNotNull { j ->
|
||||
val authority = authorities.opt(j) as? String
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
}
|
||||
routes += Route(name, known.toSet())
|
||||
}
|
||||
out[jmapAccountId] = routes
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [PushRouting] to incoming pushes (BulwarkMessagingService,
|
||||
* BulwarkUnifiedPushService) with the routes JS stored through
|
||||
* `setPushRoutes`.
|
||||
*/
|
||||
object DeviceSyncPushRouter {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
private const val PREFS = "bulwark_device_sync"
|
||||
private const val KEY_ROUTES = "pushRoutes"
|
||||
|
||||
/** Validates and stores the routes; they must survive the process, pushes arrive without JS. */
|
||||
fun saveRoutes(context: Context, json: String) {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ROUTES, json)
|
||||
.commit()
|
||||
check(saved) { "Push routes could not be saved" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the syncs the push asks for, with constant (empty) extras so
|
||||
* repeated pushes coalesce into one pending sync. Returns whether the mail
|
||||
* push task should run; any failure here says yes, so mail never depends
|
||||
* on device sync.
|
||||
*/
|
||||
fun route(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val routes = PushRouting.parseRoutes(
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null),
|
||||
)
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
for ((accountName, authority) in decision.syncs) {
|
||||
try {
|
||||
DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags())
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Push: no sync requested for $authority", e)
|
||||
}
|
||||
}
|
||||
decision.startMailTask
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push not routed", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The one notification per Android account that device sync needs the user
|
||||
* for (#34), e.g. "Sign in again" after the server rejected the credentials.
|
||||
* The engine posts it through `showSyncProblem`; the app clears it once the
|
||||
* problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the
|
||||
* app.
|
||||
*/
|
||||
object SyncProblemNotifier {
|
||||
const val CHANNEL_ID = "bulwark_sync"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Sync problems"
|
||||
private const val TAG_PREFIX = "bulwark-sync:"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val DEEP_LINK_SCHEME = "bulwarkmobile"
|
||||
|
||||
/**
|
||||
* Creates the channel, or renames it: JS passes the name in the app's
|
||||
* language. Nothing urgent, so default importance and no vibration.
|
||||
*/
|
||||
fun ensureChannel(context: Context, name: String?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { enableVibration(false) }
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) {
|
||||
val link = Uri.parse(uri)
|
||||
require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" }
|
||||
ensureChannel(context, channelName)
|
||||
val tag = TAG_PREFIX + accountName
|
||||
// Explicit, so no other app can answer the tap.
|
||||
val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
tag.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
// Runs repeat the same problem until it is solved; say it once.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun clear(context: Context, accountName: String) {
|
||||
context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.SyncResult
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a run's report (src/device-sync/types.ts `RunReport`) asks of the
|
||||
* framework's SyncResult. Kept free of Android classes so JVM tests cover it;
|
||||
* [SyncReports.apply] copies it onto the real SyncResult.
|
||||
*
|
||||
* SyncManager treats io errors as soft (retry with back-off), auth errors,
|
||||
* database errors and too many deletions as hard (no retry until something
|
||||
* changes), and `fullSyncRequested` as "schedule another sync now".
|
||||
*/
|
||||
data class SyncOutcome(
|
||||
val ioErrors: Long = 0,
|
||||
val authErrors: Long = 0,
|
||||
val databaseError: Boolean = false,
|
||||
val tooManyDeletions: Boolean = false,
|
||||
val inserts: Long = 0,
|
||||
val updates: Long = 0,
|
||||
val deletes: Long = 0,
|
||||
val entries: Long = 0,
|
||||
val skipped: Long = 0,
|
||||
/** Epoch seconds; 0 when unset. */
|
||||
val delayUntil: Long = 0,
|
||||
val fullSyncRequested: Boolean = false,
|
||||
)
|
||||
|
||||
object SyncReports {
|
||||
/**
|
||||
* Translates a report. [reportJson] is null when the run ended without
|
||||
* one ([endReason] says why: JS never answered, React failed to start, …),
|
||||
* which is a soft error so the framework retries.
|
||||
*/
|
||||
fun outcome(reportJson: String?, endReason: String?): SyncOutcome {
|
||||
if (reportJson == null) return SyncOutcome(ioErrors = 1)
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return SyncOutcome(ioErrors = 1)
|
||||
}
|
||||
val stats = report.optJSONObject("stats")
|
||||
val down = stats?.optJSONObject("downloaded")
|
||||
val up = stats?.optJSONObject("uploaded")
|
||||
fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0
|
||||
val base = SyncOutcome(
|
||||
inserts = count(down, "created") + count(up, "created"),
|
||||
updates = count(down, "updated") + count(up, "updated"),
|
||||
deletes = count(down, "deleted") + count(up, "deleted"),
|
||||
entries = stats?.optLong("entries", 0) ?: 0,
|
||||
skipped = stats?.optLong("skipped", 0) ?: 0,
|
||||
delayUntil = report.optLong("delayUntil", 0),
|
||||
fullSyncRequested = report.optBoolean("moreRecordsToGet", false),
|
||||
)
|
||||
// SyncManager retries a failed sync at once, without back-off, when it
|
||||
// made progress (inserts, updates, or deletes without
|
||||
// tooManyDeletions). A hard error must therefore report none, or an
|
||||
// auth failure after some work would loop.
|
||||
val hard = base.copy(inserts = 0, updates = 0, deletes = 0)
|
||||
return when (report.optString("outcome")) {
|
||||
"ok", "cancelled", "disabled" -> base
|
||||
"auth" -> hard.copy(authErrors = 1)
|
||||
"permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true)
|
||||
"tooManyDeletions" -> hard.copy(
|
||||
tooManyDeletions = true,
|
||||
// Shown in the system's "too many deletions" notification.
|
||||
deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0,
|
||||
)
|
||||
// "io", "internal" and anything a newer JS might send: retry, at
|
||||
// once if some work got done, else with back-off.
|
||||
else -> base.copy(ioErrors = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for logcat: the report's outcome, its message and how many
|
||||
* items failed. The reason a run failed is otherwise only in the report,
|
||||
* and JS warnings from a headless run don't reach logcat.
|
||||
*/
|
||||
fun summary(reportJson: String?): String {
|
||||
if (reportJson == null) return "no report"
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return "unreadable report"
|
||||
}
|
||||
val parts = mutableListOf("outcome=" + report.optString("outcome", "?"))
|
||||
val items = report.optJSONArray("itemErrors")?.length() ?: 0
|
||||
if (items > 0) parts += "itemErrors=$items"
|
||||
val message = report.optString("message", "")
|
||||
if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE)
|
||||
return parts.joinToString(" ")
|
||||
}
|
||||
|
||||
private const val MAX_LOGGED_MESSAGE = 300
|
||||
|
||||
fun apply(outcome: SyncOutcome, result: SyncResult) {
|
||||
result.stats.numIoExceptions += outcome.ioErrors
|
||||
result.stats.numAuthExceptions += outcome.authErrors
|
||||
result.stats.numInserts += outcome.inserts
|
||||
result.stats.numUpdates += outcome.updates
|
||||
result.stats.numDeletes += outcome.deletes
|
||||
result.stats.numEntries += outcome.entries
|
||||
result.stats.numSkippedEntries += outcome.skipped
|
||||
if (outcome.databaseError) result.databaseError = true
|
||||
if (outcome.tooManyDeletions) result.tooManyDeletions = true
|
||||
if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil
|
||||
if (outcome.fullSyncRequested) result.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,6 @@
|
||||
<string name="app_name">Bulwark Mobile</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<!-- Account type label of device sync (#34), shown in Settings → Accounts. -->
|
||||
<string name="device_sync_account_label" translatable="false">Bulwark</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The "Bulwark" account type of device sync (#34); see docs/device-sync.md. -->
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:smallIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/device_sync_account_label" />
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
How Contacts apps show and edit contacts of the Bulwark account (#34). The
|
||||
EditSchema lists exactly the data kinds device sync maps to JSContact
|
||||
(docs/device-sync.md, "Contacts mapping"); apps offer nothing else.
|
||||
|
||||
AOSP Contacts drops the whole account type when this schema breaks its rules
|
||||
(BaseAccountType): every kind with a type column needs <Type> children and
|
||||
the others must have none, name needs maxOccurs="1" and every supports*
|
||||
attribute true (supportsDisplayName for older releases), and photo,
|
||||
nickname, organization, note and group_membership need maxOccurs="1".
|
||||
A Type's maxOccurs limits that type only; birthday gets none unless given.
|
||||
-->
|
||||
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<EditSchema>
|
||||
<DataKind kind="name" maxOccurs="1"
|
||||
supportsDisplayName="true"
|
||||
supportsPrefix="true"
|
||||
supportsMiddleName="true"
|
||||
supportsSuffix="true"
|
||||
supportsPhoneticFamilyName="true"
|
||||
supportsPhoneticMiddleName="true"
|
||||
supportsPhoneticGivenName="true" />
|
||||
<DataKind kind="photo" maxOccurs="1" />
|
||||
<DataKind kind="phone">
|
||||
<Type type="mobile" />
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="main" />
|
||||
<Type type="fax_work" />
|
||||
<Type type="fax_home" />
|
||||
<Type type="other_fax" />
|
||||
<Type type="pager" />
|
||||
<Type type="work_mobile" />
|
||||
<Type type="work_pager" />
|
||||
<Type type="tty_tdd" />
|
||||
<Type type="other" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="email">
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="other" />
|
||||
<Type type="mobile" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="nickname" maxOccurs="1" />
|
||||
<DataKind kind="postal" needsStructured="true">
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="other" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="organization" maxOccurs="1" />
|
||||
<!-- No <Type> allowed: websites are always OTHER in the editor. -->
|
||||
<DataKind kind="website" />
|
||||
<DataKind kind="event" dateWithTime="false">
|
||||
<Type type="birthday" maxOccurs="1" yearOptional="true" />
|
||||
<Type type="anniversary" yearOptional="true" />
|
||||
<Type type="other" yearOptional="true" />
|
||||
<Type type="custom" yearOptional="true" />
|
||||
</DataKind>
|
||||
<DataKind kind="relationship">
|
||||
<Type type="assistant" />
|
||||
<Type type="brother" />
|
||||
<Type type="child" />
|
||||
<Type type="domestic_partner" />
|
||||
<Type type="father" />
|
||||
<Type type="friend" />
|
||||
<Type type="manager" />
|
||||
<Type type="mother" />
|
||||
<Type type="parent" />
|
||||
<Type type="partner" />
|
||||
<Type type="referred_by" />
|
||||
<Type type="relative" />
|
||||
<Type type="sister" />
|
||||
<Type type="spouse" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="note" maxOccurs="1" />
|
||||
<DataKind kind="group_membership" maxOccurs="1" />
|
||||
</EditSchema>
|
||||
</ContactsAccountType>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Calendar sync adapter of device sync (#34); see docs/device-sync.md. -->
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:contentAuthority="com.android.calendar"
|
||||
android:supportsUploading="true"
|
||||
android:userVisible="true"
|
||||
android:allowParallelSyncs="false"
|
||||
android:isAlwaysSyncable="false" />
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Contacts sync adapter of device sync (#34); see docs/device-sync.md. -->
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:contentAuthority="com.android.contacts"
|
||||
android:supportsUploading="true"
|
||||
android:userVisible="true"
|
||||
android:allowParallelSyncs="false"
|
||||
android:isAlwaysSyncable="false" />
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncAccounts.EnsureAction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncAccountsTest {
|
||||
@Test
|
||||
fun `ensureAccount creates, keeps, adopts or refuses`() {
|
||||
assertEquals(EnsureAction.CREATE, DeviceSyncAccounts.ensureAction(false, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.KEEP, DeviceSyncAccounts.ensureAction(true, "alice@example.org@mail", "alice@example.org@mail"))
|
||||
// An account whose setup never wrote a registry id.
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, "", "alice@example.org@mail"))
|
||||
// Its rows and SyncState belong to another app account: never rewritten.
|
||||
assertEquals(EnsureAction.CONFLICT, DeviceSyncAccounts.ensureAction(true, "alice@example.org@old", "alice@example.org@mail"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncRunsTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
@Test
|
||||
fun `a report is accepted once, while the adapter waits`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"io\"}"))
|
||||
assertFalse(run.end("no report before the deadline"))
|
||||
assertEquals(0L, run.done.count)
|
||||
assertEquals("{\"outcome\":\"ok\"}", run.report)
|
||||
assertEquals("reported", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report after the run ended is refused whole`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(run.end("no report before the deadline"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertNull(run.report)
|
||||
assertEquals("no report before the deadline", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runs nobody waits for, or that ended, count as cancelled`() {
|
||||
assertTrue(DeviceSyncRuns.isCancelled("unknown"))
|
||||
val run = DeviceSyncRuns.open("alice", calendar)
|
||||
assertFalse(DeviceSyncRuns.isCancelled(run.runId))
|
||||
run.end("task finished")
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
DeviceSyncRuns.close(run)
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sync requested during a run flags that run only, and the flag outlives the close`() {
|
||||
val mine = DeviceSyncRuns.open("alice", contacts)
|
||||
val otherAuthority = DeviceSyncRuns.open("alice", calendar)
|
||||
val otherAccount = DeviceSyncRuns.open("bob", contacts)
|
||||
assertTrue(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(mine)
|
||||
assertTrue(mine.syncAgainRequested)
|
||||
assertFalse(otherAuthority.syncAgainRequested)
|
||||
assertFalse(otherAccount.syncAgainRequested)
|
||||
// Closed runs are not flagged any more.
|
||||
assertFalse(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(otherAuthority)
|
||||
DeviceSyncRuns.close(otherAccount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
private const val CONTACTS = "com.android.contacts"
|
||||
private const val CALENDAR = "com.android.calendar"
|
||||
private val ME = AccountRef("alice@example.org", "com.anonymous.bulwarkmobile.account")
|
||||
private val OTHER = AccountRef("bob@gmail.com", "com.google")
|
||||
|
||||
/** The provider as far as the planner asks: who owns which row; [me] is the account the lookup is bound to. */
|
||||
private class FakeLookup(private val me: AccountRef = ME) : ScopeLookup {
|
||||
val rawContacts = HashMap<Long, AccountRef>()
|
||||
val groups = HashMap<Long, AccountRef>()
|
||||
val calendars = HashMap<Long, AccountRef>()
|
||||
val colors = HashMap<Long, AccountRef>()
|
||||
/** data id → raw contact */
|
||||
val data = HashMap<Long, Long>()
|
||||
/** data id → mimetype */
|
||||
val mimetypes = HashMap<Long, String>()
|
||||
/** event id → calendar */
|
||||
val events = HashMap<Long, Long>()
|
||||
/** child table → child id → event */
|
||||
val children = HashMap<ProviderTable, HashMap<Long, Long>>()
|
||||
/** (child table, where) → events of the matching rows; a null where matches every row. */
|
||||
val matching = HashMap<Pair<ProviderTable, String?>, Set<Long>>()
|
||||
|
||||
override fun ownCalendarIds() = calendars.filterValues { it == me }.keys
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> rawContacts
|
||||
ProviderTable.GROUPS -> groups
|
||||
ProviderTable.CALENDARS -> calendars
|
||||
ProviderTable.COLORS -> colors
|
||||
else -> throw AssertionError("no root lookup for $table")
|
||||
}.filterKeys { it in ids }
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>) = data.filterKeys { it in ids }
|
||||
|
||||
override fun dataMimetypes(ids: Set<Long>) = mimetypes.filterKeys { it in ids }
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>) = events.filterKeys { it in ids }
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
children[table].orEmpty().filterKeys { it in ids }
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> =
|
||||
matching[table to where] ?: if (where == null) children[table].orEmpty().values.toSet() else emptySet()
|
||||
|
||||
fun child(table: ProviderTable, id: Long, event: Long) {
|
||||
children.getOrPut(table) { HashMap() }[id] = event
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderOpsTest {
|
||||
private val lookup = FakeLookup().apply {
|
||||
rawContacts[1] = ME
|
||||
rawContacts[2] = OTHER
|
||||
groups[10] = ME
|
||||
groups[11] = OTHER
|
||||
data[100] = 1
|
||||
data[101] = 2
|
||||
calendars[20] = ME
|
||||
calendars[21] = ME
|
||||
calendars[22] = OTHER
|
||||
colors[40] = ME
|
||||
events[30] = 20
|
||||
events[31] = 21
|
||||
events[32] = 22
|
||||
child(ProviderTable.ATTENDEES, 50, 30)
|
||||
child(ProviderTable.ATTENDEES, 51, 32)
|
||||
child(ProviderTable.REMINDERS, 60, 31)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 70, 30)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 71, 32)
|
||||
}
|
||||
private val planner = ScopePlanner(ME, lookup)
|
||||
|
||||
private fun ops(vararg op: String) = "[${op.joinToString(",")}]"
|
||||
|
||||
private fun plan(authority: String, vararg op: String) =
|
||||
planner.planBatch(ProviderOpParser.parseBatch(authority, ops(*op)))
|
||||
|
||||
private fun refused(reason: BatchFailure, block: () -> Unit): String {
|
||||
try {
|
||||
block()
|
||||
} catch (e: ProviderRefusal) {
|
||||
assertEquals(e.message, reason, e.reason)
|
||||
return e.message.orEmpty()
|
||||
}
|
||||
fail("expected a $reason refusal")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
// ── tables and parsing ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `tables are enumerated per authority and anything else is out of scope`() {
|
||||
assertEquals(ProviderTable.EXTENDED_PROPERTIES, ProviderTable.of(CALENDAR, "extended_properties"))
|
||||
assertEquals(ProviderTable.SETTINGS, ProviderTable.of(CONTACTS, "settings"))
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "events") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CALENDAR, "raw_contacts") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "contacts") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"instances","where":"1"}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers keep integers exact whatever org_json parsed them into`() {
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5.0))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(BigDecimal("5.0")))
|
||||
assertEquals(Cell.Integer(1L shl 60), ProviderOpParser.numberCell(BigInteger.ONE.shiftLeft(60)))
|
||||
assertEquals(Cell.Integer(1_700_000_000_000), ProviderOpParser.numberCell(1_700_000_000_000L))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(BigDecimal("1.5")))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(1.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `values keep text, numbers, booleans as 0 and 1, and nulls`() {
|
||||
val insert = ProviderOpParser.parseBatch(
|
||||
CONTACTS,
|
||||
ops("""{"op":"insert","table":"raw_contacts","values":{"sync1":"a","dirty":0,"starred":true,"sync2":null}}"""),
|
||||
).single() as ProviderOp.Insert
|
||||
assertEquals(
|
||||
mapOf("sync1" to Cell.Text("a"), "dirty" to Cell.Integer(0), "starred" to Cell.Integer(1), "sync2" to Cell.Null),
|
||||
insert.values,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed ops are provider failures`() {
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, "{}") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"upsert","table":"groups","values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"assert","table":"groups","where":"dirty = 1"}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"settings","id":1}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","args":["x"]}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","id":1.5}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","where":"a = ?","args":[null]}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bytes are only accepted for data_data15`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo","data15":{"b64":"AAEC"}}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Blob("AAEC"), planned.values["data15"])
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"raw_contacts","id":1,"values":{"sync2":{"b64":"AAEC"}}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data1":{"b64":"AA"}}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data15":{"bytes":"AA"}}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── batch rules ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a syncState op must be the last op and not a yield point`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"groups","id":10,"values":{"sync2":"x"},"yieldAllowed":true}""",
|
||||
"""{"op":"syncState","value":"{\"v\":1}"}""",
|
||||
)
|
||||
assertEquals(PlannedOp.SetSyncState("{\"v\":1}"), planned.last())
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"syncState","value":"{}"}""", """{"op":"delete","table":"groups","id":10}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"syncState","value":"{}","yieldAllowed":true}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletes(count: Int, yieldAt: Set<Int> = emptySet()) = (0 until count).joinToString(",", "[", "]") { i ->
|
||||
"""{"op":"delete","table":"groups","where":"sync1 = ?","args":["$i"]${if (i in yieldAt) ""","yieldAllowed":true""" else ""}}"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contacts batches stop at 499 ops between yield points, counted as ContactsProvider does`() {
|
||||
assertEquals(499, ProviderOpParser.parseBatch(CONTACTS, deletes(499)).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500)) }
|
||||
// The yielding op still counts toward the ops before it.
|
||||
assertEquals(998, ProviderOpParser.parseBatch(CONTACTS, deletes(998, setOf(498))).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(999, setOf(498))) }
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(600, setOf(499))) }
|
||||
// A yield on the first op is ignored, as the provider ignores it.
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500, setOf(0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar batches have no yield point limit`() {
|
||||
val many = (0 until 600).joinToString(",", "[", "]") { """{"op":"delete","table":"colors","where":"color_index = '$it'"}""" }
|
||||
assertEquals(600, ProviderOpParser.parseBatch(CALENDAR, many).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back-references name earlier inserts, and parents name their parent table`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"vnd.android.cursor.item/name","data1":"A"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
assertEquals(mapOf("raw_contact_id" to 0), (planned[1] as PlannedOp.Insert).backReferences)
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
// Settings rows have no id to refer to.
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"settings","values":{"ungrouped_visible":1}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"groups","values":{"title":"g"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":null,"attendeeEmail":"a@b"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── scope: inserts ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root inserts are written into the account, whatever the op says`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1","account_name":"alice@example.org"}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Text(ME.name), planned.values["account_name"])
|
||||
assertEquals(Cell.Text(ME.type), planned.values["account_type"])
|
||||
val calendar = plan(CALENDAR, """{"op":"insert","table":"calendars","values":{"name":"c"}}""").single()
|
||||
assertEquals(Cell.Text(ME.type), (calendar as PlannedOp.Insert).values["account_type"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account columns never move a row out of the account`() {
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"raw_contacts","values":{"account_name":"bob@gmail.com"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{"account_type":"com.google"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"groups","values":{"title":"g","data_set":"plugin"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inserts must name a parent of the account that still exists`() {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x"}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"1","mimetype":"x"}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":2,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":9,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"me","mimetype":"x"}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":21,"dtstart":0}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":22,"dtstart":0}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":20,"original_id":32}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":31,"minutes":10}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":32,"minutes":10}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":99}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership row may only name a group of the account`() {
|
||||
val membership = """"mimetype":"vnd.android.cursor.item/group_membership""""
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":10}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"group_sourceid":"c/g"}}""")
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":11}}""")
|
||||
}
|
||||
// data1 of other kinds is plain data.
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x","data1":11}}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership row's group is checked on updates by id too`() {
|
||||
lookup.mimetypes[100] = GROUP_MEMBERSHIP_MIMETYPE
|
||||
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":10}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":12}}""") }
|
||||
// data1 by a selection could reach memberships the lookup never saw.
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"data","where":"raw_contact_id = ?","args":[1],"values":{"data1":11}}""")
|
||||
}
|
||||
// data1 of other kinds is plain data.
|
||||
lookup.mimetypes[100] = "vnd.android.cursor.item/phone_v2"
|
||||
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photo file ids are never written`() {
|
||||
val photo = """"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo""""
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":null,"data15":{"b64":"AAAA"}}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":5}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data14":5}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection can't close the scope's parentheses or query elsewhere`() {
|
||||
for (where in listOf("1) OR (1", "dirty = 1) OR (1 = 1", "(1", "1; DELETE FROM events", "1 -- x", "1 /* x */",
|
||||
"_id IN (SELECT _id FROM events)", "1 UNION ALL x", "title = 'open")) {
|
||||
val quoted = JSONObject.quote(where)
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","where":$quoted,"values":{"dirty":1}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","where":$quoted}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":$quoted}""")
|
||||
}
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id); DROP TABLE x; --"}""")
|
||||
}
|
||||
// Parentheses and keywords inside quoted literals are data.
|
||||
plan(CALENDAR, """{"op":"update","table":"events","where":"title = '(x' OR title = 'select'","values":{"dirty":1}}""")
|
||||
plan(CALENDAR, """{"op":"assert","table":"events","where":"(dirty = 1 OR deleted = 1) AND _sync_id LIKE '~pending/%'","expectCount":0}""")
|
||||
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id DESC"}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a row to another parent is checked like an insert`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":22}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"raw_contact_id":null}}""") }
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":21}}""")
|
||||
}
|
||||
|
||||
// ── scope: selections ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root tables are selected by their account columns`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"raw_contacts","where":"dirty = ? AND deleted = 0","args":[1],"values":{"dirty":0}}""",
|
||||
).single() as PlannedOp.Update
|
||||
assertEquals(
|
||||
Selection("(dirty = ? AND deleted = 0) AND (account_name = ? AND account_type = ?)", listOf("1", ME.name, ME.type)),
|
||||
op.selection,
|
||||
)
|
||||
assertNull(op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an op by id selects that row of the account and expects exactly one row`() {
|
||||
val op = plan(CONTACTS, """{"op":"delete","table":"groups","id":10}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(_id = ?) AND (account_name = ? AND account_type = ?)", listOf("10", ME.name, ME.type)), op.selection)
|
||||
assertEquals(1, op.expectedCount)
|
||||
val counted = plan(CONTACTS, """{"op":"assert","table":"raw_contacts","id":1,"expectCount":0}""").single()
|
||||
assertEquals(0, (counted as PlannedOp.Assert).expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an id of another account is refused, a vanished one is left to the expected count`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":2}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":101,"values":{"data1":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"assert","table":"events","id":32,"values":{"dirty":0}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","id":51}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"calendars","id":22,"values":{"name":"x"}}""") }
|
||||
val gone = plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":9}""").single() as PlannedOp.Delete
|
||||
assertEquals(1, gone.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data rows rely on the URI parameters and never select on the Data view's account columns`() {
|
||||
val op = plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":"x"}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?)", listOf("100")), op.selection)
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"],"where":"raw_contact_id IN (1)"}"""))
|
||||
assertEquals(listOf(Selection("(raw_contact_id IN (1))", emptyList())), query.selections)
|
||||
val all = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection.NONE), all.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events are selected by the account's calendars`() {
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id","title"],"where":"dirty = 1","orderBy":"_id"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(dirty = 1) AND (calendar_id IN (20,21))", emptyList())), query.selections)
|
||||
assertEquals("_id", query.orderBy)
|
||||
val update = plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"dirty":0}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?) AND (calendar_id IN (20,21))", listOf("30")), update.selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account without calendars selects no events`() {
|
||||
val carol = AccountRef("carol", ME.type)
|
||||
val empty = ScopePlanner(carol, FakeLookup(carol).apply { calendars.putAll(lookup.calendars) })
|
||||
val query = empty.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection("(0)", emptyList())), query.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `children of events are selected by the account's events among those they touch`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id IN (30,32)"] = setOf(30, 32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["attendeeEmail"],"where":"event_id IN (30,32)"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(event_id IN (30,32)) AND (event_id IN (30))", emptyList())), query.selections)
|
||||
|
||||
val byId = plan(CALENDAR, """{"op":"delete","table":"attendees","id":50}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Attendees._id = ?) AND (event_id IN (30))", listOf("50")), byId.selection)
|
||||
assertEquals(1, byId.expectedCount)
|
||||
|
||||
val gone = plan(CALENDAR, """{"op":"delete","table":"reminders","id":69}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Reminders._id = ?) AND (0)", listOf("69")), gone.selection)
|
||||
|
||||
lookup.matching[ProviderTable.REMINDERS to "event_id = ?"] = setOf(31)
|
||||
val byWhere = plan(CALENDAR, """{"op":"delete","table":"reminders","where":"event_id = ?","args":[31]}""").single()
|
||||
assertEquals(Selection("(event_id = ?) AND (event_id IN (31))", listOf("31")), (byWhere as PlannedOp.Delete).selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a child query that touches none of the account's events reads nothing`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id = 32"] = setOf(32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":"event_id = 32"}"""),
|
||||
)
|
||||
assertTrue(query.selections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long event lists are split into chunks`() {
|
||||
val many = (1000L until 1000L + 1200).toSet()
|
||||
for (id in many) lookup.events[id] = 20
|
||||
lookup.matching[ProviderTable.REMINDERS to null] = many
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"reminders","columns":["minutes"]}"""))
|
||||
assertEquals(3, query.selections.size)
|
||||
assertTrue(query.selections.all { it.sql!!.startsWith("(event_id IN (") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extended properties are updated through their item URI, so only by id`() {
|
||||
val op = plan(CALENDAR, """{"op":"update","table":"extended_properties","id":70,"values":{"value":"v"}}""").single()
|
||||
assertEquals(PlannedOp.Update(ProviderTable.EXTENDED_PROPERTIES, Selection.NONE, 70, mapOf("value" to Cell.Text("v")), 1, false), op)
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","id":71,"values":{"value":"v"}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","where":"name = 'x'","values":{"value":"v"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asserts compare text and keep nulls`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"assert","table":"raw_contacts","id":1,"values":{"version":3,"dirty":0,"sync3":null,"sourceid":"c/1"}}""",
|
||||
).single() as PlannedOp.Assert
|
||||
assertEquals(mapOf("version" to "3", "dirty" to "0", "sync3" to null, "sourceid" to "c/1"), op.values)
|
||||
assertEquals(1, op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings are written by account`() {
|
||||
val insert = plan(CONTACTS, """{"op":"insert","table":"settings","values":{"ungrouped_visible":1,"should_sync":1}}""").single()
|
||||
assertEquals(Cell.Text(ME.name), (insert as PlannedOp.Insert).values["account_name"])
|
||||
val update = plan(CONTACTS, """{"op":"update","table":"settings","values":{"ungrouped_visible":1}}""").single()
|
||||
assertEquals(
|
||||
Selection("(account_name = ? AND account_type = ?)", listOf(ME.name, ME.type)),
|
||||
(update as PlannedOp.Update).selection,
|
||||
)
|
||||
}
|
||||
|
||||
// ── results ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `batch results are BatchResult JSON`() {
|
||||
val ok = JSONObject(ProviderResults.ok(listOf(OpOutcome(id = 7), OpOutcome(count = 1), OpOutcome())))
|
||||
assertEquals(true, ok.getBoolean("ok"))
|
||||
val results = ok.getJSONArray("results")
|
||||
assertEquals(7L, results.getJSONObject(0).getLong("id"))
|
||||
assertEquals(1, results.getJSONObject(1).getInt("count"))
|
||||
assertEquals(0, results.getJSONObject(2).length())
|
||||
val failed = JSONObject(ProviderResults.failed(BatchFailure.TOO_LARGE, "TransactionTooLargeException"))
|
||||
assertEquals(false, failed.getBoolean("ok"))
|
||||
assertEquals("tooLarge", failed.getString("reason"))
|
||||
assertEquals("TransactionTooLargeException", failed.getString("message"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an OperationApplicationException is an assert unless an insert got no row`() {
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Found value 4 when expected 3 for column version"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Expected 1 rows but actual 0"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("wrong number of rows: 0"))
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Insert into content://com.android.contacts/settings returned no result"),
|
||||
)
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Too many content provider operations between yield points."),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query rows are ProviderRows JSON with escaped text`() {
|
||||
val writer = ProviderRowsWriter(listOf("_id", "title", "x"))
|
||||
writer.beginRow()
|
||||
writer.longCell(9_007_199_254_740_991)
|
||||
writer.textCell("a \"quoted\"\\ line\nbreak\u0001 and \u2028")
|
||||
writer.nullCell()
|
||||
writer.endRow()
|
||||
writer.beginRow()
|
||||
writer.longCell(2)
|
||||
writer.doubleCell(1.5)
|
||||
writer.doubleCell(Double.NaN)
|
||||
writer.endRow()
|
||||
val parsed = JSONObject(writer.finish())
|
||||
assertEquals(JSONArray(listOf("_id", "title", "x")).toString(), parsed.getJSONArray("columns").toString())
|
||||
val rows = parsed.getJSONArray("rows")
|
||||
assertEquals(9_007_199_254_740_991L, rows.getJSONArray(0).getLong(0))
|
||||
assertEquals("a \"quoted\"\\ line\nbreak\u0001 and \u2028", rows.getJSONArray(0).getString(1))
|
||||
assertTrue(rows.getJSONArray(0).isNull(2))
|
||||
assertEquals(1.5, rows.getJSONArray(1).getDouble(1), 0.0)
|
||||
assertTrue(rows.getJSONArray(1).isNull(2))
|
||||
assertEquals("{\"columns\":[],\"rows\":[]}", ProviderRowsWriter(emptyList()).finish())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos decode close to the target size and scale their longer side down to it`() {
|
||||
assertEquals(1, PhotoScaling.sampleSize(720, 720, 512))
|
||||
assertEquals(4, PhotoScaling.sampleSize(4000, 3000, 512))
|
||||
assertEquals(512 to 384, PhotoScaling.targetSize(1000, 750, 512))
|
||||
assertEquals(96 to 96, PhotoScaling.targetSize(96, 96, 512))
|
||||
assertEquals(1 to 512, PhotoScaling.targetSize(3, 4000, 512))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PushRoutingTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
private val routes = PushRouting.parseRoutes(
|
||||
"""
|
||||
{
|
||||
"c": [{ "accountName": "alice@example.org", "authorities": ["$contacts", "$calendar"] }],
|
||||
"team": [
|
||||
{ "accountName": "alice@example.org", "authorities": ["$contacts"] },
|
||||
{ "accountName": "alice-work", "authorities": ["$calendar"] }
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
strict = true,
|
||||
)
|
||||
|
||||
private fun stateChange(changed: String) = mapOf(
|
||||
"kind" to "jmap-state-change",
|
||||
"accountLabel" to "alice",
|
||||
"accountId" to "c",
|
||||
"emailIds" to "[]",
|
||||
"changed" to changed,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a contact change requests a contacts sync and no mail task`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":{"ContactCard":"s1"}}"""), routes)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar types request calendar syncs of the routed accounts only`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"team":{"Calendar":"s1","CalendarEvent":"s2"}}"""), routes)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every account key is routed, not only the one repeated as accountId`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"c":{"AddressBook":"s1"},"team":{"ContactCard":"s2","CalendarEvent":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
// alice@example.org's contacts sync is requested once.
|
||||
assertEquals(setOf("alice@example.org" to contacts, "alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mail anywhere in the map starts the mail task, next to the syncs`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"team":{"CalendarEvent":"s1"},"c":{"EmailDelivery":"s2","Email":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an Email or Mailbox change is possible mail, as a subscription from an older build sends it`() {
|
||||
for (changed in listOf(
|
||||
"""{"c":{"Email":"s1","Mailbox":"s2"}}""",
|
||||
"""{"c":{"Email":"s1"}}""",
|
||||
"""{"c":{"Mailbox":"s1"}}""",
|
||||
)) {
|
||||
val decision = PushRouting.decide(stateChange(changed), routes)
|
||||
assertTrue("changed=$changed", decision.startMailTask)
|
||||
assertTrue("changed=$changed", decision.syncs.isEmpty())
|
||||
}
|
||||
val mixed = PushRouting.decide(stateChange("""{"team":{"CalendarEvent":"e1"},"c":{"Email":"s1"}}"""), routes)
|
||||
assertTrue(mixed.startMailTask)
|
||||
assertEquals(setOf("alice-work" to calendar), mixed.syncs)
|
||||
// Types that are neither mail nor device sync say nothing about mail.
|
||||
val other = PushRouting.decide(stateChange("""{"c":{"Thread":"t1","ContactCard":"s1"}}"""), routes)
|
||||
assertFalse(other.startMailTask)
|
||||
assertEquals(setOf("alice@example.org" to contacts), other.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email push always starts the mail task`() {
|
||||
val data = mapOf("kind" to "jmap-email-push", "accountId" to "c", "changed" to """{"c":{"EmailDelivery":"s"}}""")
|
||||
assertTrue(PushRouting.decide(data, routes).startMailTask)
|
||||
assertTrue(PushRouting.decide(mapOf("kind" to "jmap-email-push"), routes).startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payloads without a readable map are legacy mail pushes`() {
|
||||
for (changed in listOf(null, "", "{}", "not json", "[1]", """{"c":{}}""")) {
|
||||
val data = stateChange(changed ?: "").let { if (changed == null) it - "changed" else it }
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
assertTrue("changed=$changed", decision.startMailTask)
|
||||
assertTrue("changed=$changed", decision.syncs.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable account entry may be mail`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":"garbled","team":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads a push the same way as the push task`() {
|
||||
// kind (null: none), changed (null: none) → whether the mail task starts. The
|
||||
// same table as MAIL_CASES in src/lib/__tests__/push-background-task.test.ts,
|
||||
// whose carriesNoMail must agree.
|
||||
val state = "jmap-state-change"
|
||||
val emailPush = "jmap-email-push"
|
||||
val cases: List<Triple<String?, String?, Boolean>> = listOf(
|
||||
// No readable map: as before device sync.
|
||||
Triple(state, null, true),
|
||||
Triple(state, "", true),
|
||||
Triple(state, "not json", true),
|
||||
Triple(state, "[1]", true),
|
||||
Triple(state, "{}", true),
|
||||
// A map that names no type is read like a missing one.
|
||||
Triple(state, """{"c":{}}""", true),
|
||||
Triple(state, """{"c":{},"team":{}}""", true),
|
||||
// An account entry that can't be read may be mail.
|
||||
Triple(state, """{"c":null}""", true),
|
||||
Triple(state, """{"c":"garbled","team":{"ContactCard":"s"}}""", true),
|
||||
Triple(state, """{"c":[],"team":{"ContactCard":"s"}}""", true),
|
||||
Triple(state, """{"c":5}""", true),
|
||||
// Mail types anywhere in the map.
|
||||
Triple(state, """{"c":{"EmailDelivery":"s"}}""", true),
|
||||
Triple(state, """{"c":{"Email":"s"}}""", true),
|
||||
Triple(state, """{"c":{"Mailbox":"s"}}""", true),
|
||||
Triple(state, """{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}""", true),
|
||||
// Only a StateChange can say it carries no mail.
|
||||
Triple(null, """{"c":{"ContactCard":"s"}}""", true),
|
||||
Triple(emailPush, """{"c":{"EmailDelivery":"s"}}""", true),
|
||||
Triple(emailPush, null, true),
|
||||
// Contact and calendar changes, and types that say nothing about mail.
|
||||
Triple(state, """{"c":{"ContactCard":"s1","AddressBook":"s2"}}""", false),
|
||||
Triple(state, """{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}""", false),
|
||||
Triple(state, """{"c":{"ContactCard":"s"},"team":{}}""", false),
|
||||
Triple(state, """{"elsewhere":{"ContactCard":"s"}}""", false),
|
||||
Triple(state, """{"c":{"Thread":"t1","ContactCard":"s1"}}""", false),
|
||||
)
|
||||
for ((kind, changed, mail) in cases) {
|
||||
val data = buildMap<String, String> {
|
||||
if (kind != null) put("kind", kind)
|
||||
put("accountLabel", "alice")
|
||||
put("accountId", "c")
|
||||
put("emailIds", "[]")
|
||||
if (changed != null) put("changed", changed)
|
||||
}
|
||||
assertEquals("kind=$kind changed=$changed", mail, PushRouting.decide(data, routes).startMailTask)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts without routes are ignored`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"elsewhere":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.syncs.isEmpty())
|
||||
assertFalse(decision.startMailTask)
|
||||
assertTrue(PushRouting.decide(stateChange("""{"c":{"ContactCard":"s"}}"""), emptyMap()).syncs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored routes are validated strictly and read leniently`() {
|
||||
val bad = listOf(
|
||||
"[]",
|
||||
"""{"c":{}}""",
|
||||
"""{"c":[{"authorities":["$contacts"]}]}""",
|
||||
"""{"c":[{"accountName":"a","authorities":["com.example.tasks"]}]}""",
|
||||
)
|
||||
for (json in bad) {
|
||||
try {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
throw AssertionError("accepted $json")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes("[]"))
|
||||
assertEquals(
|
||||
mapOf("c" to listOf(PushRouting.Route("a", setOf(contacts)))),
|
||||
PushRouting.parseRoutes("""{"c":[{"accountName":"a","authorities":["$contacts","com.example.tasks"]},{"x":1}]}"""),
|
||||
)
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SyncReportsTest {
|
||||
private fun report(outcome: String, extra: String = "") = """
|
||||
{"v":1,"runId":"r","authority":"com.android.contacts","outcome":"$outcome",
|
||||
"startedAt":0,"durationMs":5,"conflicts":0,"itemErrors":[],
|
||||
"stats":{"downloaded":{"created":3,"updated":2,"deleted":1},
|
||||
"uploaded":{"created":1,"updated":4,"deleted":2},"entries":13,"skipped":1}$extra}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `a run without a report is a soft error`() {
|
||||
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome(null, "task finished"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable report is a soft error`() {
|
||||
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome("{not json", "reported"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ok sums both directions into the framework's stats`() {
|
||||
assertEquals(
|
||||
SyncOutcome(inserts = 4, updates = 6, deletes = 3, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("ok"), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `io keeps the progress so the framework retries at once`() {
|
||||
val outcome = SyncReports.outcome(report("io"), "reported")
|
||||
assertEquals(1L, outcome.ioErrors)
|
||||
assertEquals(4L, outcome.inserts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth is hard and reports no progress, or the framework would retry it at once`() {
|
||||
assertEquals(
|
||||
SyncOutcome(authErrors = 1, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("auth"), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permission, unsupported and safety aborts are database errors without progress`() {
|
||||
for (outcome in listOf("permission", "unsupported", "safetyAbort")) {
|
||||
assertEquals(
|
||||
outcome,
|
||||
SyncOutcome(databaseError = true, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report(outcome), "reported"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too many deletions reports the pending deletions for the system notification`() {
|
||||
assertEquals(
|
||||
SyncOutcome(tooManyDeletions = true, deletes = 42, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("tooManyDeletions", ""","tooManyDeletions":{"count":42,"threshold":20}"""), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delayUntil and moreRecordsToGet reach the framework`() {
|
||||
val outcome = SyncReports.outcome(report("ok", ""","delayUntil":1790000000,"moreRecordsToGet":true"""), "reported")
|
||||
assertEquals(1790000000L, outcome.delayUntil)
|
||||
assertEquals(true, outcome.fullSyncRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled and disabled runs are not errors`() {
|
||||
for (outcome in listOf("cancelled", "disabled")) {
|
||||
assertEquals(0L, SyncReports.outcome(report(outcome), "reported").ioErrors)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an outcome this build does not know is retried`() {
|
||||
assertEquals(1L, SyncReports.outcome(report("somethingNew"), "reported").ioErrors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the log summary names the outcome, failed items and the message`() {
|
||||
assertEquals("outcome=ok", SyncReports.summary(report("ok")))
|
||||
assertEquals(
|
||||
"outcome=internal message=Invalid column x",
|
||||
SyncReports.summary(report("internal", ",\"message\":\"Invalid column x\"")),
|
||||
)
|
||||
val withItems = report("ok").replace("\"itemErrors\":[]", "\"itemErrors\":[{\"ref\":\"a\"},{\"ref\":\"b\"}]")
|
||||
assertEquals("outcome=ok itemErrors=2", SyncReports.summary(withItems))
|
||||
assertEquals("no report", SyncReports.summary(null))
|
||||
assertEquals("unreadable report", SyncReports.summary("{"))
|
||||
val long = SyncReports.summary(report("io", ",\"message\":\"" + "m".repeat(400) + "\""))
|
||||
assertEquals("outcome=io message=".length + 300, long.length)
|
||||
}
|
||||
}
|
||||
@@ -1020,7 +1020,8 @@ ones moved to the N/A lists, and the wrong text corrected.
|
||||
| #46 email preview shows CSS | Not reproduced: HTML-only mail with `<style>` shows clean previews, and the viewer routes by type (`06742ef`). Ask for a sample, then close. |
|
||||
| #44, #48 push without FCM / UnifiedPush | Uncommitted work in this tree; not in any release. Committed in `ee04e95`; not released yet. |
|
||||
| #3 TLS client auth | Partial: Android has a certificate picker (Settings → Security); iOS doesn't. |
|
||||
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #34 sync adapters, #1 settings sync | Feature requests; see open questions. |
|
||||
| #34 sync adapters | Done on Android: contacts and calendars sync both ways with the device's apps ([design](device-sync.md)). |
|
||||
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #1 settings sync | Feature requests; see open questions. |
|
||||
|
||||
No open webmail issue is about the native app. The six mobile-titled ones (#813, #838, #915,
|
||||
#969, #999, #1020) concern the web and PWA.
|
||||
@@ -1042,8 +1043,7 @@ No open webmail issue is about the native app. The six mobile-titled ones (#813,
|
||||
for the page to be visible for some time, or apply only to the message that was actually
|
||||
opened? Decided in `5f3f584`: only the page on screen auto-sends, once per message per session.
|
||||
- **Settings sync (#1):** needs a server-side store, which neither client has today.
|
||||
- **Scope of the feature requests:** encryption (#58), widgets (#52), IMAP/SMTP (#51) and native
|
||||
sync adapters (#34).
|
||||
- **Scope of the feature requests:** encryption (#58), widgets (#52) and IMAP/SMTP (#51).
|
||||
|
||||
## Reproducing
|
||||
|
||||
|
||||
+1969
File diff suppressed because it is too large
Load Diff
@@ -315,4 +315,4 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
|
||||
- Print contact (`components/contacts/contact-print.ts`).
|
||||
- Plugin contact APIs and the `contact-cryptokeys` plugin slot.
|
||||
- `mailto:`/`tel:` handoff differences (RN uses `Linking`).
|
||||
- Native issue #34 (device contacts / calendar sync adapters) is a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.
|
||||
- Native issue #34 (device contacts / calendar sync adapters) is not a parity item; it is done on Android (Settings → Contacts → "Sync to this device", [design](../device-sync.md)) and builds on the UID (#644) and PartialDate (#224) fixes above.
|
||||
|
||||
@@ -15,6 +15,14 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
|
||||
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Device sync (Android, #34): the contacts and calendar sync adapters run the
|
||||
// sync engine through this task, headless or next to the UI. The engine is
|
||||
// required on first use so an app start does not load it.
|
||||
AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import('./src/device-sync/types').RunPayload) => {
|
||||
const { runDeviceSyncTask } = require('./src/device-sync/task') as typeof import('./src/device-sync/task');
|
||||
await runDeviceSyncTask(data);
|
||||
});
|
||||
|
||||
// Home-screen widgets (Android): every widget event runs this headless task.
|
||||
registerWidgetTaskHandler(widgetTaskHandler);
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "حتى {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "اعرض تقويماتك في تطبيق التقويم والتطبيقات الأخرى على هذا الجهاز. تتم مزامنة التغييرات في الاتجاهين.",
|
||||
"device_sync_title": "المزامنة مع هذا الجهاز",
|
||||
"show_week_numbers_mobile_desc": "عرض أرقام الأسابيع في عرض الشهر.",
|
||||
"time_zone": "المنطقة الزمنية",
|
||||
"time_zone_auto": "المنطقة الزمنية للجهاز",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "وسم",
|
||||
"remove_color": "إزالة الوسم"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "لم يعد الخادم يقبل تسجيل الدخول إلى {account}، لذلك توقفت مزامنة جهات الاتصال والتقويمات الخاصة به على هذا الجهاز. سجّل الدخول مرة أخرى لاستئناف المزامنة. يتم الاحتفاظ بالتغييرات التي أُجريت على هذا الجهاز.",
|
||||
"auth_problem_title": "سجّل الدخول مرة أخرى لمواصلة المزامنة",
|
||||
"problem_channel": "مشكلات المزامنة",
|
||||
"sign_out_anyway": "تسجيل الخروج",
|
||||
"sign_out_pending_message": "{count, plural, zero {# تغيير أُجري على هذا الجهاز لم يصل إلى الخادم. سيُفقد إذا سجّلت الخروج الآن.} one {تغيير واحد أُجري على هذا الجهاز لم يصل إلى الخادم. سيُفقد إذا سجّلت الخروج الآن.} two {تغييران أُجريا على هذا الجهاز لم يصلا إلى الخادم. سيُفقدان إذا سجّلت الخروج الآن.} few {# تغييرات أُجريت على هذا الجهاز لم تصل إلى الخادم. ستُفقد إذا سجّلت الخروج الآن.} many {# تغييرًا أُجريت على هذا الجهاز لم تصل إلى الخادم. ستُفقد إذا سجّلت الخروج الآن.} other {# تغيير أُجريت على هذا الجهاز لم تصل إلى الخادم. ستُفقد إذا سجّلت الخروج الآن.}}",
|
||||
"sign_out_pending_title": "تسجيل الخروج على أي حال؟",
|
||||
"sign_out_unknown_message": "ربما لم تصل التغييرات التي أُجريت على هذا الجهاز إلى الخادم. ستُفقد إذا سجّلت الخروج الآن.",
|
||||
"signed_out_text": "تم تسجيل الخروج من {account}، لذلك توقفت مزامنة جهات الاتصال والتقويمات الخاصة به على هذا الجهاز. سجّل الدخول مرة أخرى لاستئناف المزامنة. يتم الاحتفاظ بالتغييرات التي أُجريت على هذا الجهاز.",
|
||||
"signed_out_title": "سجّل الدخول لمواصلة المزامنة",
|
||||
"signing_out": "جارٍ إزالة جهات الاتصال والتقويمات المتزامنة من هذا الجهاز…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "الكاميرا",
|
||||
"attachment_reminder_body": "تذكر رسالتك \"{keyword}\" لكن لم يتم إرفاق أي ملف. هل تريد الإرسال على أي حال؟",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, zero {# جهة اتصال} one {جهة اتصال واحدة} two {جهتا اتصال} few {# جهات اتصال} many {# جهة اتصال} other {# جهة اتصال}}",
|
||||
"default_book": "دفتر العناوين الافتراضي",
|
||||
"description_mobile": "تفضيلات العرض وأدوات دفاتر العناوين.",
|
||||
"device_sync_desc": "اعرض دفاتر عناوينك في تطبيق جهات الاتصال والتطبيقات الأخرى على هذا الجهاز. تتم مزامنة التغييرات في الاتجاهين.",
|
||||
"device_sync_title": "المزامنة مع هذا الجهاز",
|
||||
"export_description_count": "{count, plural, zero {تصدير # جهة اتصال في ملف vCard واحد.} one {تصدير جهة اتصال واحدة في ملف vCard واحد.} two {تصدير جهتي اتصال في ملف vCard واحد.} few {تصدير # جهات اتصال في ملف vCard واحد.} many {تصدير # جهة اتصال في ملف vCard واحد.} other {تصدير # جهة اتصال في ملف vCard واحد.}}",
|
||||
"export_empty": "لا توجد جهات اتصال للتصدير",
|
||||
"import_description_mobile": "استيراد جهات الاتصال من ملف vCard (.vcf) مع اكتشاف التكرارات.",
|
||||
"manage_description_mobile": "أنشئ دفاتر العناوين التي تنظّم جهات اتصالك، وأعد تسميتها، واحذفها."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "تتم مزامنته أيضًا عبر {accounts}، لذلك يظهر مرتين على هذا الجهاز.",
|
||||
"android_settings": "إعدادات حساب Android",
|
||||
"blocked_calendar": "لم يعد Android يطلب الوصول إلى التقويمات. اسمح به من إعدادات التطبيق، ثم فعّل المزامنة مرة أخرى.",
|
||||
"blocked_contacts": "لم يعد Android يطلب الوصول إلى جهات الاتصال. اسمح به من إعدادات التطبيق، ثم فعّل المزامنة مرة أخرى.",
|
||||
"blocked_title": "الوصول متوقف",
|
||||
"books_default": "تتم مزامنة دفاتر عناوينك، أما المشتركة فلا تتم مزامنتها إلا إذا اخترتها.",
|
||||
"books_hint": "تتم مزامنة دفاتر عناوينك ما لم توقفها، أما المشتركة فلا تتم مزامنتها إلا إذا فعّلتها.",
|
||||
"books_label": "دفاتر العناوين",
|
||||
"calendars_default": "تتم مزامنة تقويماتك، أما المشتركة فلا تتم مزامنتها إلا إذا اخترتها.",
|
||||
"calendars_hint": "تتم مزامنة تقويماتك ما لم توقفها، أما المشتركة فلا تتم مزامنتها إلا إذا فعّلتها. تبقى قوائم المهام في Bulwark.",
|
||||
"calendars_label": "التقويمات",
|
||||
"choose": "اختيار",
|
||||
"choose_books_title": "دفاتر العناوين المراد مزامنتها",
|
||||
"choose_calendars_title": "التقويمات المراد مزامنتها",
|
||||
"collections_count": "تتم مزامنة {selected} من {total}",
|
||||
"continue": "متابعة",
|
||||
"deletions_delete": "الحذف من الخادم",
|
||||
"deletions_message": "حُذفت عناصر كثيرة دفعة واحدة على هذا الجهاز، لذلك لم تُحذف من الخادم. احذفها من هناك أيضًا، أو استعدها على هذا الجهاز.",
|
||||
"deletions_restore": "استعادتها",
|
||||
"deletions_title": "هل تريد حذفها من الخادم أيضًا؟",
|
||||
"denied_calendar": "لا يستطيع Bulwark وضع تقويماتك على هذا الجهاز بدون الوصول إلى التقويمات.",
|
||||
"denied_contacts": "لا يستطيع Bulwark وضع دفاتر عناوينك على هذا الجهاز بدون الوصول إلى جهات الاتصال.",
|
||||
"denied_title": "تبقى المزامنة متوقفة",
|
||||
"failed_title": "تعذّر تشغيل المزامنة",
|
||||
"grant_access": "منح الوصول",
|
||||
"interval_15m": "كل 15 دقيقة",
|
||||
"interval_1h": "كل ساعة",
|
||||
"interval_30m": "كل 30 دقيقة",
|
||||
"interval_6h": "كل 6 ساعات",
|
||||
"interval_desc": "عدد مرات المزامنة التي يجريها Android من تلقاء نفسه. تتم مزامنة التعديلات في Bulwark والتغييرات الواردة عبر الإشعارات الفورية بشكل أسرع.",
|
||||
"interval_label": "فترة المزامنة",
|
||||
"interval_manual": "بدون مزامنة منتظمة",
|
||||
"keep_syncing": "مواصلة المزامنة",
|
||||
"list_error_auth": "لم يقبل الخادم تسجيل الدخول.",
|
||||
"list_error_network": "تعذّر الوصول إلى الخادم.",
|
||||
"list_error_server": "تعذّر على الخادم عرض القائمة.",
|
||||
"list_error_signed_out": "لم يتم تسجيل الدخول إلى هذا الحساب على هذا الجهاز.",
|
||||
"master_off": "المزامنة التلقائية متوقفة على هذا الجهاز، لذلك لا تتم المزامنة إلا عند النقر على \"مزامنة الآن\".",
|
||||
"new_contacts_default": "دفتر العناوين الافتراضي",
|
||||
"new_contacts_desc": "دفتر العناوين لجهات الاتصال التي تُنشأ في تطبيقات أخرى على هذا الجهاز.",
|
||||
"new_contacts_label": "حفظ جهات الاتصال الجديدة في",
|
||||
"no_books": "لا توجد دفاتر عناوين في هذا الحساب.",
|
||||
"no_calendars": "لا توجد تقويمات للمزامنة في هذا الحساب.",
|
||||
"ok": "حسنًا",
|
||||
"open_app_settings": "فتح إعدادات التطبيق",
|
||||
"own_collections": "الخاصة بك",
|
||||
"rationale_calendar": "لعرض تقويماتك في تطبيق التقويم، يحتاج Bulwark إلى الوصول إلى التقويمات على هذا الجهاز. ولا يغيّر إلا تقويمات حسابه.",
|
||||
"rationale_contacts": "لعرض دفاتر عناوينك في تطبيق جهات الاتصال، يحتاج Bulwark إلى الوصول إلى جهات الاتصال على هذا الجهاز. ولا يغيّر إلا جهات الاتصال في حسابه.",
|
||||
"rationale_title_calendar": "السماح بالوصول إلى التقويمات",
|
||||
"rationale_title_contacts": "السماح بالوصول إلى جهات الاتصال",
|
||||
"read_only": "للقراءة فقط",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "تطبيق التقويم",
|
||||
"reminder_question_message": "يمكن أن تصلك تذكيرات الأحداث المتزامنة من تطبيق التقويم على هذا الجهاز أو من Bulwark. اختر أحدهما حتى لا تصلك التذكيرات مرتين. يمكنك تغيير ذلك لاحقًا.",
|
||||
"reminder_question_title": "من يجب أن يذكّرك؟",
|
||||
"reminders_desc": "الجهة التي تذكّرك بالأحداث المتزامنة. يبقى الآخر صامتًا، فلا تصلك التذكيرات مرتين.",
|
||||
"reminders_label": "التذكيرات",
|
||||
"reminders_nobody": "إشعارات الأحداث في Bulwark متوقفة، لذلك لن يذكّرك شيء بالأحداث المتزامنة.",
|
||||
"remove": "إزالة",
|
||||
"remove_from_device": "إزالة من هذا الجهاز",
|
||||
"remove_message": "ستُزال جهات الاتصال والتقويمات الخاصة بالحساب من هذا الجهاز. وستُفقد التغييرات التي أُجريت هنا ولم تصل إلى الخادم.",
|
||||
"remove_title": "إزالة من هذا الجهاز؟",
|
||||
"review_deletions": "مراجعة المحذوفات",
|
||||
"shared_by": "تمت المشاركة بواسطة {name}",
|
||||
"sign_in_again": "تسجيل الدخول مرة أخرى",
|
||||
"status_auth": "لم يعد الخادم يقبل تسجيل الدخول. سجّل الدخول مرة أخرى لمواصلة المزامنة.",
|
||||
"status_cancelled": "توقفت آخر مزامنة قبل اكتمالها. وستُستكمل في المرة القادمة.",
|
||||
"status_failed": "فشلت آخر مزامنة: {message}",
|
||||
"status_failed_plain": "فشلت آخر مزامنة.",
|
||||
"status_io": "تعذّر الوصول إلى الخادم {when}. سيحاول Android مرة أخرى.",
|
||||
"status_item_errors": "{count, plural, zero {تعذّرت مزامنة # عنصر.} one {تعذّرت مزامنة عنصر واحد.} two {تعذّرت مزامنة عنصرين.} few {تعذّرت مزامنة # عناصر.} many {تعذّرت مزامنة # عنصرًا.} other {تعذّرت مزامنة # عنصر.}}",
|
||||
"status_off": "متوقفة",
|
||||
"status_paused": "متوقفة مؤقتًا في إعدادات Android",
|
||||
"status_permission_calendar": "تم إيقاف الوصول إلى التقويمات، لذلك لا تتم مزامنة أي شيء.",
|
||||
"status_permission_contacts": "تم إيقاف الوصول إلى جهات الاتصال، لذلك لا تتم مزامنة أي شيء.",
|
||||
"status_removed": "تمت الإزالة في إعدادات Android. فعّل المزامنة لإضافة الحساب مرة أخرى.",
|
||||
"status_safety_abort": "تم الإيقاف: لم يُرجع الخادم أي شيء رغم أن هذا الجهاز لا يزال يحتوي على عناصر. تحقّق من الخادم، ثم أعد المزامنة.",
|
||||
"status_synced": "آخر مزامنة: {when}",
|
||||
"status_syncing": "جارٍ المزامنة…",
|
||||
"status_too_many_deletions": "حُذفت عناصر كثيرة على هذا الجهاز. لن تُحذف من الخادم حتى تؤكد ذلك.",
|
||||
"status_unsupported_calendar": "لا يوفّر الخادم أي تقويمات لهذا الحساب.",
|
||||
"status_unsupported_contacts": "لا يوفّر الخادم أي دفاتر عناوين لهذا الحساب.",
|
||||
"status_waiting": "بانتظار المزامنة الأولى",
|
||||
"status_waiting_next": "بانتظار المزامنة التالية",
|
||||
"suspended_desc": "تم تسجيل الخروج. تبقى جهات الاتصال والتقويمات الخاصة به على هذا الجهاز مع التغييرات التي أُجريت هنا، وستتم مزامنتها مجددًا بعد تسجيل الدخول.",
|
||||
"switch_label": "مزامنة {account} مع هذا الجهاز",
|
||||
"sync_now": "مزامنة الآن",
|
||||
"turn_off_anyway": "إيقاف",
|
||||
"turn_off_failed": "تعذّرت إزالة البيانات المتزامنة من هذا الجهاز. المزامنة متوقفة مؤقتًا؛ حاول مرة أخرى لاحقًا.",
|
||||
"turn_off_failed_title": "تعذّر إيقاف المزامنة",
|
||||
"turn_off_pending": "{count, plural, zero {# تغيير أُجري على هذا الجهاز لم يصل إلى الخادم بعد. سيُفقد إذا أوقفت المزامنة الآن.} one {تغيير واحد أُجري على هذا الجهاز لم يصل إلى الخادم بعد. سيُفقد إذا أوقفت المزامنة الآن.} two {تغييران أُجريا على هذا الجهاز لم يصلا إلى الخادم بعد. سيُفقدان إذا أوقفت المزامنة الآن.} few {# تغييرات أُجريت على هذا الجهاز لم تصل إلى الخادم بعد. ستُفقد إذا أوقفت المزامنة الآن.} many {# تغييرًا أُجريت على هذا الجهاز لم تصل إلى الخادم بعد. ستُفقد إذا أوقفت المزامنة الآن.} other {# تغيير أُجريت على هذا الجهاز لم تصل إلى الخادم بعد. ستُفقد إذا أوقفت المزامنة الآن.}}",
|
||||
"turn_off_title": "الإيقاف على أي حال؟",
|
||||
"turn_off_unknown": "تعذّر على Bulwark التحقق مما إذا كانت التغييرات التي أُجريت على هذا الجهاز قد وصلت إلى الخادم. ستُفقد إذا أوقفت المزامنة الآن.",
|
||||
"turning_off": "جارٍ تحميل التغييرات ثم إزالة البيانات من هذا الجهاز…",
|
||||
"turning_on": "جارٍ الإعداد…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "القالب المستخدم عند حفظ مرفق أو مشاركته."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Fins al {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Mostra els vostres calendaris a l'aplicació Calendari i a altres aplicacions d'aquest dispositiu. Els canvis se sincronitzen en tots dos sentits.",
|
||||
"device_sync_title": "Sincronitza amb aquest dispositiu",
|
||||
"show_week_numbers_mobile_desc": "Mostra els números de setmana a la vista de mes.",
|
||||
"time_zone": "Zona horària",
|
||||
"time_zone_auto": "Zona horària del dispositiu",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Elimina l'etiqueta"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "El servidor ja no accepta l'inici de sessió de {account}, de manera que els seus contactes i calendaris d'aquest dispositiu han deixat de sincronitzar-se. Torneu a iniciar la sessió per reprendre la sincronització. Els canvis fets en aquest dispositiu es conserven.",
|
||||
"auth_problem_title": "Torneu a iniciar la sessió per continuar sincronitzant",
|
||||
"problem_channel": "Problemes de sincronització",
|
||||
"sign_out_anyway": "Tanca la sessió",
|
||||
"sign_out_pending_message": "{count, plural, one {# canvi fet en aquest dispositiu no ha arribat al servidor. Es perdrà si tanqueu la sessió ara.} other {# canvis fets en aquest dispositiu no han arribat al servidor. Es perdran si tanqueu la sessió ara.}}",
|
||||
"sign_out_pending_title": "Voleu tancar la sessió igualment?",
|
||||
"sign_out_unknown_message": "És possible que els canvis fets en aquest dispositiu no hagin arribat al servidor. Es perdran si tanqueu la sessió ara.",
|
||||
"signed_out_text": "S'ha tancat la sessió de {account}, de manera que els seus contactes i calendaris d'aquest dispositiu ja no se sincronitzen. Torneu a iniciar la sessió per reprendre la sincronització. Els canvis fets en aquest dispositiu es conserven.",
|
||||
"signed_out_title": "Inicieu la sessió per continuar sincronitzant",
|
||||
"signing_out": "S'estan eliminant d'aquest dispositiu els contactes i calendaris sincronitzats…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Càmera",
|
||||
"attachment_reminder_body": "El vostre missatge esmenta «{keyword}», però no hi ha cap fitxer adjunt. Voleu enviar-lo igualment?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contacte} other {# contactes}}",
|
||||
"default_book": "Llibreta d'adreces predeterminada",
|
||||
"description_mobile": "Preferències de visualització i eines de la llibreta d'adreces.",
|
||||
"device_sync_desc": "Mostra les vostres llibretes d'adreces a l'aplicació Contactes i a altres aplicacions d'aquest dispositiu. Els canvis se sincronitzen en tots dos sentits.",
|
||||
"device_sync_title": "Sincronitza amb aquest dispositiu",
|
||||
"export_description_count": "{count, plural, one {Exporta # contacte com un únic fitxer vCard.} other {Exporta # contactes com un únic fitxer vCard.}}",
|
||||
"export_empty": "No hi ha contactes per exportar",
|
||||
"import_description_mobile": "Importa contactes des d'un fitxer vCard (.vcf) amb detecció de duplicats.",
|
||||
"manage_description_mobile": "Creeu, canvieu el nom i suprimiu les llibretes d'adreces que organitzen els contactes."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "També se sincronitza per a {accounts}, per això apareix dues vegades en aquest dispositiu.",
|
||||
"android_settings": "Configuració del compte d'Android",
|
||||
"blocked_calendar": "Android ja no demana accés als calendaris. Permeteu-lo a la configuració de l'aplicació i torneu a activar la sincronització.",
|
||||
"blocked_contacts": "Android ja no demana accés als contactes. Permeteu-lo a la configuració de l'aplicació i torneu a activar la sincronització.",
|
||||
"blocked_title": "L'accés està desactivat",
|
||||
"books_default": "Les vostres llibretes d'adreces se sincronitzen; les compartides, només si les trieu.",
|
||||
"books_hint": "Les vostres llibretes d'adreces se sincronitzen tret que les desactiveu; les compartides, només si les activeu.",
|
||||
"books_label": "Llibretes d'adreces",
|
||||
"calendars_default": "Els vostres calendaris se sincronitzen; els compartits, només si els trieu.",
|
||||
"calendars_hint": "Els vostres calendaris se sincronitzen tret que els desactiveu; els compartits, només si els activeu. Les llistes de tasques es queden al Bulwark.",
|
||||
"calendars_label": "Calendaris",
|
||||
"choose": "Tria",
|
||||
"choose_books_title": "Llibretes d'adreces per sincronitzar",
|
||||
"choose_calendars_title": "Calendaris per sincronitzar",
|
||||
"collections_count": "Sincronització: {selected} de {total}",
|
||||
"continue": "Continua",
|
||||
"deletions_delete": "Suprimeix al servidor",
|
||||
"deletions_message": "S'han suprimit molts elements alhora en aquest dispositiu, de manera que no s'han suprimit al servidor. Suprimiu-los també allà o recupereu-los en aquest dispositiu.",
|
||||
"deletions_restore": "Recupera'ls",
|
||||
"deletions_title": "Voleu suprimir-los també al servidor?",
|
||||
"denied_calendar": "Sense accés als calendaris, el Bulwark no pot posar els vostres calendaris en aquest dispositiu.",
|
||||
"denied_contacts": "Sense accés als contactes, el Bulwark no pot posar les vostres llibretes d'adreces en aquest dispositiu.",
|
||||
"denied_title": "La sincronització continua desactivada",
|
||||
"failed_title": "No s'ha pogut activar la sincronització",
|
||||
"grant_access": "Concedeix l'accés",
|
||||
"interval_15m": "Cada 15 minuts",
|
||||
"interval_1h": "Cada hora",
|
||||
"interval_30m": "Cada 30 minuts",
|
||||
"interval_6h": "Cada 6 hores",
|
||||
"interval_desc": "Amb quina freqüència sincronitza Android pel seu compte. Els canvis fets al Bulwark i els que arriben per push se sincronitzen abans.",
|
||||
"interval_label": "Interval de sincronització",
|
||||
"interval_manual": "Sense sincronització periòdica",
|
||||
"keep_syncing": "Continua sincronitzant",
|
||||
"list_error_auth": "El servidor no ha acceptat l'inici de sessió.",
|
||||
"list_error_network": "No s'ha pogut connectar amb el servidor.",
|
||||
"list_error_server": "El servidor no n'ha pogut mostrar la llista.",
|
||||
"list_error_signed_out": "Aquest compte no té la sessió iniciada en aquest dispositiu.",
|
||||
"master_off": "La sincronització automàtica està desactivada en aquest dispositiu, de manera que només se sincronitza quan toqueu «Sincronitza ara».",
|
||||
"new_contacts_default": "Llibreta d'adreces predeterminada",
|
||||
"new_contacts_desc": "La llibreta d'adreces per als contactes creats en altres aplicacions d'aquest dispositiu.",
|
||||
"new_contacts_label": "Desa els contactes nous a",
|
||||
"no_books": "Aquest compte no té cap llibreta d'adreces.",
|
||||
"no_calendars": "Aquest compte no té cap calendari per sincronitzar.",
|
||||
"ok": "D'acord",
|
||||
"open_app_settings": "Obre la configuració",
|
||||
"own_collections": "Personals",
|
||||
"rationale_calendar": "Per mostrar els vostres calendaris a l'aplicació Calendari, el Bulwark necessita accés als calendaris d'aquest dispositiu. Només modifica els calendaris del seu propi compte.",
|
||||
"rationale_contacts": "Per mostrar les vostres llibretes d'adreces a l'aplicació Contactes, el Bulwark necessita accés als contactes d'aquest dispositiu. Només modifica els contactes del seu propi compte.",
|
||||
"rationale_title_calendar": "Permet l'accés als calendaris",
|
||||
"rationale_title_contacts": "Permet l'accés als contactes",
|
||||
"read_only": "Només lectura",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Aplicació Calendari",
|
||||
"reminder_question_message": "Els recordatoris dels esdeveniments sincronitzats poden venir de l'aplicació de calendari d'aquest dispositiu o del Bulwark. Trieu-ne un perquè no rebeu els recordatoris dues vegades. Ho podeu canviar més endavant.",
|
||||
"reminder_question_title": "Qui us ha d'enviar els recordatoris?",
|
||||
"reminders_desc": "Qui us recorda els esdeveniments sincronitzats. L'altre no avisa, perquè no rebeu els recordatoris dues vegades.",
|
||||
"reminders_label": "Recordatoris",
|
||||
"reminders_nobody": "Les notificacions d'esdeveniments del Bulwark estan desactivades, de manera que res no us recorda els esdeveniments sincronitzats.",
|
||||
"remove": "Elimina",
|
||||
"remove_from_device": "Elimina d'aquest dispositiu",
|
||||
"remove_message": "Els contactes i calendaris del compte s'eliminen d'aquest dispositiu. Els canvis fets aquí que no hagin arribat al servidor es perden.",
|
||||
"remove_title": "Voleu eliminar-ho d'aquest dispositiu?",
|
||||
"review_deletions": "Revisa les supressions",
|
||||
"shared_by": "Compartit per {name}",
|
||||
"sign_in_again": "Torna a iniciar la sessió",
|
||||
"status_auth": "El servidor ja no accepta l'inici de sessió. Torneu a iniciar la sessió per continuar sincronitzant.",
|
||||
"status_cancelled": "L'última sincronització s'ha aturat abans d'hora. Continuarà la propera vegada.",
|
||||
"status_failed": "Ha fallat l'última sincronització: {message}",
|
||||
"status_failed_plain": "Ha fallat l'última sincronització.",
|
||||
"status_io": "No s'ha pogut connectar amb el servidor {when}. Android ho tornarà a provar.",
|
||||
"status_item_errors": "{count, plural, one {No s'ha pogut sincronitzar # element.} other {No s'han pogut sincronitzar # elements.}}",
|
||||
"status_off": "Desactivada",
|
||||
"status_paused": "En pausa a la configuració d'Android",
|
||||
"status_permission_calendar": "S'ha desactivat l'accés als calendaris, de manera que no se sincronitza res.",
|
||||
"status_permission_contacts": "S'ha desactivat l'accés als contactes, de manera que no se sincronitza res.",
|
||||
"status_removed": "S'ha eliminat a la configuració d'Android. Activeu la sincronització per tornar a afegir el compte.",
|
||||
"status_safety_abort": "S'ha aturat: el servidor no ha retornat res, però aquest dispositiu encara conté elements. Comproveu el servidor i torneu a sincronitzar.",
|
||||
"status_synced": "Sincronitzat {when}",
|
||||
"status_syncing": "S'està sincronitzant…",
|
||||
"status_too_many_deletions": "S'han suprimit molts elements en aquest dispositiu. No se suprimiran al servidor fins que no ho confirmeu.",
|
||||
"status_unsupported_calendar": "El servidor no ofereix calendaris per a aquest compte.",
|
||||
"status_unsupported_contacts": "El servidor no ofereix llibretes d'adreces per a aquest compte.",
|
||||
"status_waiting": "S'està esperant la primera sincronització",
|
||||
"status_waiting_next": "S'està esperant la propera sincronització",
|
||||
"suspended_desc": "Sessió tancada. Els seus contactes i calendaris es queden en aquest dispositiu, amb els canvis fets aquí, i es tornaran a sincronitzar quan inicieu la sessió.",
|
||||
"switch_label": "Sincronitza {account} amb aquest dispositiu",
|
||||
"sync_now": "Sincronitza ara",
|
||||
"turn_off_anyway": "Desactiva",
|
||||
"turn_off_failed": "No s'han pogut eliminar les dades sincronitzades d'aquest dispositiu. La sincronització està en pausa; torneu-ho a provar més tard.",
|
||||
"turn_off_failed_title": "No s'ha pogut desactivar la sincronització",
|
||||
"turn_off_pending": "{count, plural, one {# canvi fet en aquest dispositiu encara no ha arribat al servidor. Es perdrà si desactiveu la sincronització ara.} other {# canvis fets en aquest dispositiu encara no han arribat al servidor. Es perdran si desactiveu la sincronització ara.}}",
|
||||
"turn_off_title": "Voleu desactivar-la igualment?",
|
||||
"turn_off_unknown": "El Bulwark no ha pogut comprovar si els canvis fets en aquest dispositiu han arribat al servidor. Es perdran si desactiveu la sincronització ara.",
|
||||
"turning_off": "S'estan pujant els canvis i després s'eliminaran les dades d'aquest dispositiu…",
|
||||
"turning_on": "S'està configurant…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Plantilla utilitzada en desar o compartir un fitxer adjunt."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Do {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Zobrazí vaše kalendáře v aplikaci Kalendář a dalších aplikacích v tomto zařízení. Změny se synchronizují oběma směry.",
|
||||
"device_sync_title": "Synchronizovat s tímto zařízením",
|
||||
"show_week_numbers_mobile_desc": "Zobrazovat čísla týdnů v měsíčním zobrazení.",
|
||||
"time_zone": "Časové pásmo",
|
||||
"time_zone_auto": "Časové pásmo zařízení",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Štítek",
|
||||
"remove_color": "Odebrat štítek"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Server už nepřijímá přihlášení účtu {account}, proto se jeho kontakty a kalendáře v tomto zařízení přestaly synchronizovat. Chcete-li pokračovat, přihlaste se znovu. Změny provedené v tomto zařízení zůstanou zachovány.",
|
||||
"auth_problem_title": "Pro další synchronizaci se znovu přihlaste",
|
||||
"problem_channel": "Problémy se synchronizací",
|
||||
"sign_out_anyway": "Odhlásit se",
|
||||
"sign_out_pending_message": "{count, plural, one {# změna provedená v tomto zařízení se nedostala na server. Pokud se teď odhlásíte, bude ztracena.} few {# změny provedené v tomto zařízení se nedostaly na server. Pokud se teď odhlásíte, budou ztraceny.} many {# změny provedené v tomto zařízení se nedostalo na server. Pokud se teď odhlásíte, budou ztraceny.} other {# změn provedených v tomto zařízení se nedostalo na server. Pokud se teď odhlásíte, budou ztraceny.}}",
|
||||
"sign_out_pending_title": "Přesto se odhlásit?",
|
||||
"sign_out_unknown_message": "Změny provedené v tomto zařízení se možná nedostaly na server. Pokud se teď odhlásíte, budou ztraceny.",
|
||||
"signed_out_text": "Účet {account} byl odhlášen, proto se jeho kontakty a kalendáře v tomto zařízení už nesynchronizují. Chcete-li pokračovat, přihlaste se znovu. Změny provedené v tomto zařízení zůstanou zachovány.",
|
||||
"signed_out_title": "Pro další synchronizaci se přihlaste",
|
||||
"signing_out": "Odebírání synchronizovaných kontaktů a kalendářů z tohoto zařízení…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Fotoaparát",
|
||||
"attachment_reminder_body": "Vaše zpráva obsahuje slovo „{keyword}“, ale není k ní připojen žádný soubor. Přesto odeslat?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktu} other {# kontaktů}}",
|
||||
"default_book": "Výchozí adresář",
|
||||
"description_mobile": "Předvolby zobrazení a nástroje pro adresáře.",
|
||||
"device_sync_desc": "Zobrazí vaše adresáře v aplikaci Kontakty a dalších aplikacích v tomto zařízení. Změny se synchronizují oběma směry.",
|
||||
"device_sync_title": "Synchronizovat s tímto zařízením",
|
||||
"export_description_count": "{count, plural, one {Exportovat # kontakt do jednoho souboru vCard.} few {Exportovat # kontakty do jednoho souboru vCard.} many {Exportovat # kontaktu do jednoho souboru vCard.} other {Exportovat # kontaktů do jednoho souboru vCard.}}",
|
||||
"export_empty": "Žádné kontakty k exportu",
|
||||
"import_description_mobile": "Importovat kontakty ze souboru vCard (.vcf) s rozpoznáním duplicit.",
|
||||
"manage_description_mobile": "Vytvářejte, přejmenovávejte a odstraňujte adresáře, ve kterých máte uspořádané kontakty."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Synchronizuje se také pro {accounts}, proto se v tomto zařízení zobrazuje dvakrát.",
|
||||
"android_settings": "Nastavení účtu v Androidu",
|
||||
"blocked_calendar": "Android už o přístup ke kalendářům nežádá. Povolte ho v nastavení aplikace a pak synchronizaci znovu zapněte.",
|
||||
"blocked_contacts": "Android už o přístup ke kontaktům nežádá. Povolte ho v nastavení aplikace a pak synchronizaci znovu zapněte.",
|
||||
"blocked_title": "Přístup je vypnutý",
|
||||
"books_default": "Vaše vlastní adresáře se synchronizují, sdílené jen tehdy, když je vyberete.",
|
||||
"books_hint": "Vaše vlastní adresáře se synchronizují, pokud je nevypnete; sdílené jen tehdy, když je zapnete.",
|
||||
"books_label": "Adresáře",
|
||||
"calendars_default": "Vaše vlastní kalendáře se synchronizují, sdílené jen tehdy, když je vyberete.",
|
||||
"calendars_hint": "Vaše vlastní kalendáře se synchronizují, pokud je nevypnete; sdílené jen tehdy, když je zapnete. Seznamy úkolů zůstávají v Bulwarku.",
|
||||
"calendars_label": "Kalendáře",
|
||||
"choose": "Vybrat",
|
||||
"choose_books_title": "Adresáře k synchronizaci",
|
||||
"choose_calendars_title": "Kalendáře k synchronizaci",
|
||||
"collections_count": "Synchronizováno: {selected} z {total}",
|
||||
"continue": "Pokračovat",
|
||||
"deletions_delete": "Smazat na serveru",
|
||||
"deletions_message": "V tomto zařízení bylo najednou smazáno mnoho položek, proto nebyly smazány na serveru. Smažte je i tam, nebo je v tomto zařízení obnovte.",
|
||||
"deletions_restore": "Obnovit",
|
||||
"deletions_title": "Smazat je i na serveru?",
|
||||
"denied_calendar": "Bez přístupu ke kalendářům nemůže Bulwark přenést vaše kalendáře do tohoto zařízení.",
|
||||
"denied_contacts": "Bez přístupu ke kontaktům nemůže Bulwark přenést vaše adresáře do tohoto zařízení.",
|
||||
"denied_title": "Synchronizace zůstává vypnutá",
|
||||
"failed_title": "Synchronizaci se nepodařilo zapnout",
|
||||
"grant_access": "Udělit přístup",
|
||||
"interval_15m": "Každých 15 minut",
|
||||
"interval_1h": "Každou hodinu",
|
||||
"interval_30m": "Každých 30 minut",
|
||||
"interval_6h": "Každých 6 hodin",
|
||||
"interval_desc": "Jak často Android synchronizuje sám. Úpravy v Bulwarku a změny oznámené přes push se synchronizují dříve.",
|
||||
"interval_label": "Interval synchronizace",
|
||||
"interval_manual": "Bez pravidelné synchronizace",
|
||||
"keep_syncing": "Dál synchronizovat",
|
||||
"list_error_auth": "Server nepřijal přihlášení.",
|
||||
"list_error_network": "Nepodařilo se spojit se serverem.",
|
||||
"list_error_server": "Server nedokázal poskytnout seznam.",
|
||||
"list_error_signed_out": "Tento účet není v tomto zařízení přihlášen.",
|
||||
"master_off": "Automatická synchronizace je v tomto zařízení vypnutá, proto se synchronizuje jen po klepnutí na „Synchronizovat nyní“.",
|
||||
"new_contacts_default": "Výchozí adresář",
|
||||
"new_contacts_desc": "Adresář pro kontakty vytvořené v jiných aplikacích v tomto zařízení.",
|
||||
"new_contacts_label": "Nové kontakty ukládat do",
|
||||
"no_books": "Tento účet nemá žádné adresáře.",
|
||||
"no_calendars": "Tento účet nemá žádné kalendáře k synchronizaci.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Otevřít nastavení",
|
||||
"own_collections": "Vlastní",
|
||||
"rationale_calendar": "Aby mohl Bulwark zobrazit vaše kalendáře v aplikaci Kalendář, potřebuje přístup ke kalendářům v tomto zařízení. Mění jen kalendáře svého vlastního účtu.",
|
||||
"rationale_contacts": "Aby mohl Bulwark zobrazit vaše adresáře v aplikaci Kontakty, potřebuje přístup ke kontaktům v tomto zařízení. Mění jen kontakty svého vlastního účtu.",
|
||||
"rationale_title_calendar": "Povolit přístup ke kalendářům",
|
||||
"rationale_title_contacts": "Povolit přístup ke kontaktům",
|
||||
"read_only": "Jen pro čtení",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Aplikace Kalendář",
|
||||
"reminder_question_message": "Připomenutí synchronizovaných událostí může zobrazovat aplikace kalendáře v tomto zařízení, nebo Bulwark. Vyberte jednu možnost, abyste nedostávali připomenutí dvakrát. Později to můžete změnit.",
|
||||
"reminder_question_title": "Kdo vám má připomínat události?",
|
||||
"reminders_desc": "Kdo vám připomíná synchronizované události. Druhá možnost mlčí, abyste nedostávali připomenutí dvakrát.",
|
||||
"reminders_label": "Připomenutí",
|
||||
"reminders_nobody": "Oznámení událostí v Bulwarku jsou vypnutá, takže vám synchronizované události nic nepřipomene.",
|
||||
"remove": "Odebrat",
|
||||
"remove_from_device": "Odebrat z tohoto zařízení",
|
||||
"remove_message": "Kontakty a kalendáře účtu budou z tohoto zařízení odebrány. Změny provedené zde, které se nedostaly na server, budou ztraceny.",
|
||||
"remove_title": "Odebrat z tohoto zařízení?",
|
||||
"review_deletions": "Zkontrolovat smazání",
|
||||
"shared_by": "Sdílí {name}",
|
||||
"sign_in_again": "Znovu se přihlásit",
|
||||
"status_auth": "Server už nepřijímá přihlášení. Pro další synchronizaci se znovu přihlaste.",
|
||||
"status_cancelled": "Poslední synchronizace skončila předčasně. Příště bude pokračovat.",
|
||||
"status_failed": "Poslední synchronizace selhala: {message}",
|
||||
"status_failed_plain": "Poslední synchronizace selhala.",
|
||||
"status_io": "Server byl {when} nedostupný. Android to zkusí znovu.",
|
||||
"status_item_errors": "{count, plural, one {# položku se nepodařilo synchronizovat.} few {# položky se nepodařilo synchronizovat.} many {# položky se nepodařilo synchronizovat.} other {# položek se nepodařilo synchronizovat.}}",
|
||||
"status_off": "Vypnuto",
|
||||
"status_paused": "Pozastaveno v nastavení Androidu",
|
||||
"status_permission_calendar": "Přístup ke kalendářům byl vypnut, proto se nic nesynchronizuje.",
|
||||
"status_permission_contacts": "Přístup ke kontaktům byl vypnut, proto se nic nesynchronizuje.",
|
||||
"status_removed": "Odebráno v nastavení Androidu. Chcete-li účet znovu přidat, zapněte synchronizaci.",
|
||||
"status_safety_abort": "Zastaveno: server nic nevrátil, přestože toto zařízení stále obsahuje položky. Zkontrolujte server a pak synchronizujte znovu.",
|
||||
"status_synced": "Synchronizováno {when}",
|
||||
"status_syncing": "Synchronizace…",
|
||||
"status_too_many_deletions": "V tomto zařízení bylo smazáno mnoho položek. Na serveru budou smazány až po vašem potvrzení.",
|
||||
"status_unsupported_calendar": "Server pro tento účet nenabízí žádné kalendáře.",
|
||||
"status_unsupported_contacts": "Server pro tento účet nenabízí žádné adresáře.",
|
||||
"status_waiting": "Čeká se na první synchronizaci",
|
||||
"status_waiting_next": "Čeká se na další synchronizaci",
|
||||
"suspended_desc": "Odhlášeno. Kontakty a kalendáře účtu zůstanou v tomto zařízení i se zde provedenými změnami a po přihlášení se znovu synchronizují.",
|
||||
"switch_label": "Synchronizovat {account} s tímto zařízením",
|
||||
"sync_now": "Synchronizovat nyní",
|
||||
"turn_off_anyway": "Vypnout",
|
||||
"turn_off_failed": "Synchronizovaná data se nepodařilo z tohoto zařízení odebrat. Synchronizace je pozastavena; zkuste to později.",
|
||||
"turn_off_failed_title": "Synchronizaci se nepodařilo vypnout",
|
||||
"turn_off_pending": "{count, plural, one {# změna provedená v tomto zařízení se ještě nedostala na server. Pokud teď synchronizaci vypnete, bude ztracena.} few {# změny provedené v tomto zařízení se ještě nedostaly na server. Pokud teď synchronizaci vypnete, budou ztraceny.} many {# změny provedené v tomto zařízení se ještě nedostalo na server. Pokud teď synchronizaci vypnete, budou ztraceny.} other {# změn provedených v tomto zařízení se ještě nedostalo na server. Pokud teď synchronizaci vypnete, budou ztraceny.}}",
|
||||
"turn_off_title": "Přesto vypnout?",
|
||||
"turn_off_unknown": "Bulwark nemohl ověřit, zda se změny provedené v tomto zařízení dostaly na server. Pokud teď synchronizaci vypnete, budou ztraceny.",
|
||||
"turning_off": "Nahrávání změn a poté odebírání dat z tohoto zařízení…",
|
||||
"turning_on": "Nastavování…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Šablona použitá při ukládání nebo sdílení přílohy."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Indtil {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Vis dine kalendere i Kalender-appen og andre apps på denne enhed. Ændringer synkroniseres begge veje.",
|
||||
"device_sync_title": "Synkronisér med denne enhed",
|
||||
"show_week_numbers_mobile_desc": "Vis ugenumre i månedsvisningen.",
|
||||
"time_zone": "Tidszone",
|
||||
"time_zone_auto": "Enhedens tidszone",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Fjern tag"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Serveren accepterer ikke længere login for {account}, så kontoens kontakter og kalendere på denne enhed synkroniseres ikke længere. Log ind igen for at fortsætte. Ændringer foretaget på denne enhed bevares.",
|
||||
"auth_problem_title": "Log ind igen for at fortsætte synkroniseringen",
|
||||
"problem_channel": "Synkroniseringsproblemer",
|
||||
"sign_out_anyway": "Log ud",
|
||||
"sign_out_pending_message": "{count, plural, one {# ændring foretaget på denne enhed er ikke nået frem til serveren. Den går tabt, hvis du logger ud nu.} other {# ændringer foretaget på denne enhed er ikke nået frem til serveren. De går tabt, hvis du logger ud nu.}}",
|
||||
"sign_out_pending_title": "Vil du logge ud alligevel?",
|
||||
"sign_out_unknown_message": "Ændringer foretaget på denne enhed er måske ikke nået frem til serveren. De går tabt, hvis du logger ud nu.",
|
||||
"signed_out_text": "{account} blev logget ud, så kontoens kontakter og kalendere på denne enhed synkroniseres ikke længere. Log ind igen for at fortsætte. Ændringer foretaget på denne enhed bevares.",
|
||||
"signed_out_title": "Log ind for at fortsætte synkroniseringen",
|
||||
"signing_out": "Fjerner synkroniserede kontakter og kalendere fra denne enhed…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "Din besked nævner \"{keyword}\", men ingen fil er vedhæftet. Send alligevel?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kontakt} other {# kontakter}}",
|
||||
"default_book": "Standardadressebog",
|
||||
"description_mobile": "Visningsindstillinger og værktøjer til adressebøger.",
|
||||
"device_sync_desc": "Vis dine adressebøger i Kontakter-appen og andre apps på denne enhed. Ændringer synkroniseres begge veje.",
|
||||
"device_sync_title": "Synkronisér med denne enhed",
|
||||
"export_description_count": "{count, plural, one {Eksportér # kontakt som én vCard-fil.} other {Eksportér # kontakter som én vCard-fil.}}",
|
||||
"export_empty": "Ingen kontakter at eksportere",
|
||||
"import_description_mobile": "Importér kontakter fra en vCard-fil (.vcf) med registrering af dubletter.",
|
||||
"manage_description_mobile": "Opret, omdøb og fjern de adressebøger, der organiserer dine kontakter."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Synkroniseres også for {accounts}, så den vises to gange på denne enhed.",
|
||||
"android_settings": "Android-kontoindstillinger",
|
||||
"blocked_calendar": "Android spørger ikke længere om adgang til kalendere. Tillad det i appens indstillinger, og slå derefter synkronisering til igen.",
|
||||
"blocked_contacts": "Android spørger ikke længere om adgang til kontakter. Tillad det i appens indstillinger, og slå derefter synkronisering til igen.",
|
||||
"blocked_title": "Adgang er slået fra",
|
||||
"books_default": "Dine egne adressebøger synkroniseres; delte kun, når du vælger dem.",
|
||||
"books_hint": "Dine egne adressebøger synkroniseres, medmindre du slår dem fra; delte kun, når du slår dem til.",
|
||||
"books_label": "Adressebøger",
|
||||
"calendars_default": "Dine egne kalendere synkroniseres; delte kun, når du vælger dem.",
|
||||
"calendars_hint": "Dine egne kalendere synkroniseres, medmindre du slår dem fra; delte kun, når du slår dem til. Opgavelister bliver i Bulwark.",
|
||||
"calendars_label": "Kalendere",
|
||||
"choose": "Vælg",
|
||||
"choose_books_title": "Adressebøger, der skal synkroniseres",
|
||||
"choose_calendars_title": "Kalendere, der skal synkroniseres",
|
||||
"collections_count": "{selected} af {total} synkroniseres",
|
||||
"continue": "Fortsæt",
|
||||
"deletions_delete": "Slet på serveren",
|
||||
"deletions_message": "Mange elementer blev slettet på én gang på denne enhed, så de blev ikke slettet på serveren. Slet dem også der, eller hent dem tilbage på denne enhed.",
|
||||
"deletions_restore": "Hent dem tilbage",
|
||||
"deletions_title": "Vil du også slette dem på serveren?",
|
||||
"denied_calendar": "Uden adgang til kalendere kan Bulwark ikke lægge dine kalendere på denne enhed.",
|
||||
"denied_contacts": "Uden adgang til kontakter kan Bulwark ikke lægge dine adressebøger på denne enhed.",
|
||||
"denied_title": "Synkronisering forbliver slået fra",
|
||||
"failed_title": "Synkronisering kunne ikke slås til",
|
||||
"grant_access": "Giv adgang",
|
||||
"interval_15m": "Hvert 15. minut",
|
||||
"interval_1h": "Hver time",
|
||||
"interval_30m": "Hvert 30. minut",
|
||||
"interval_6h": "Hver 6. time",
|
||||
"interval_desc": "Hvor ofte Android synkroniserer af sig selv. Ændringer i Bulwark og ændringer, der meldes via push, synkroniseres hurtigere.",
|
||||
"interval_label": "Synkroniseringsinterval",
|
||||
"interval_manual": "Ingen regelmæssig synkronisering",
|
||||
"keep_syncing": "Fortsæt synkronisering",
|
||||
"list_error_auth": "Serveren accepterede ikke login.",
|
||||
"list_error_network": "Serveren kunne ikke nås.",
|
||||
"list_error_server": "Serveren kunne ikke vise listen.",
|
||||
"list_error_signed_out": "Denne konto er ikke logget ind på denne enhed.",
|
||||
"master_off": "Automatisk synkronisering er slået fra på denne enhed, så der synkroniseres kun, når du trykker på \"Synkronisér nu\".",
|
||||
"new_contacts_default": "Standardadressebog",
|
||||
"new_contacts_desc": "Adressebogen til kontakter, der oprettes i andre apps på denne enhed.",
|
||||
"new_contacts_label": "Gem nye kontakter i",
|
||||
"no_books": "Denne konto har ingen adressebøger.",
|
||||
"no_calendars": "Denne konto har ingen kalendere at synkronisere.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Åbn appindstillinger",
|
||||
"own_collections": "Dine egne",
|
||||
"rationale_calendar": "For at vise dine kalendere i Kalender-appen skal Bulwark have adgang til kalenderne på denne enhed. Bulwark ændrer kun kalenderne for sin egen konto.",
|
||||
"rationale_contacts": "For at vise dine adressebøger i Kontakter-appen skal Bulwark have adgang til kontakterne på denne enhed. Bulwark ændrer kun kontakterne for sin egen konto.",
|
||||
"rationale_title_calendar": "Giv adgang til kalendere",
|
||||
"rationale_title_contacts": "Giv adgang til kontakter",
|
||||
"read_only": "Skrivebeskyttet",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Kalender-app",
|
||||
"reminder_question_message": "Påmindelser om synkroniserede begivenheder kan komme fra kalenderappen på denne enhed eller fra Bulwark. Vælg én af dem, så du ikke får to påmindelser. Du kan ændre det senere.",
|
||||
"reminder_question_title": "Hvem skal minde dig om begivenheder?",
|
||||
"reminders_desc": "Hvem der minder dig om synkroniserede begivenheder. Den anden forholder sig tavs, så du ikke får to påmindelser.",
|
||||
"reminders_label": "Påmindelser",
|
||||
"reminders_nobody": "Bulwarks notifikationer om begivenheder er slået fra, så intet minder dig om synkroniserede begivenheder.",
|
||||
"remove": "Fjern",
|
||||
"remove_from_device": "Fjern fra denne enhed",
|
||||
"remove_message": "Kontoens kontakter og kalendere fjernes fra denne enhed. Ændringer foretaget her, som ikke er nået frem til serveren, går tabt.",
|
||||
"remove_title": "Fjern fra denne enhed?",
|
||||
"review_deletions": "Gennemgå sletninger",
|
||||
"shared_by": "Delt af {name}",
|
||||
"sign_in_again": "Log ind igen",
|
||||
"status_auth": "Serveren accepterer ikke længere login. Log ind igen for at fortsætte synkroniseringen.",
|
||||
"status_cancelled": "Den seneste synkronisering stoppede før tid. Den fortsætter næste gang.",
|
||||
"status_failed": "Den seneste synkronisering mislykkedes: {message}",
|
||||
"status_failed_plain": "Den seneste synkronisering mislykkedes.",
|
||||
"status_io": "Serveren kunne ikke nås {when}. Android prøver igen.",
|
||||
"status_item_errors": "{count, plural, one {# element kunne ikke synkroniseres.} other {# elementer kunne ikke synkroniseres.}}",
|
||||
"status_off": "Fra",
|
||||
"status_paused": "Sat på pause i Android-indstillinger",
|
||||
"status_permission_calendar": "Adgangen til kalendere blev slået fra, så intet synkroniseres.",
|
||||
"status_permission_contacts": "Adgangen til kontakter blev slået fra, så intet synkroniseres.",
|
||||
"status_removed": "Fjernet i Android-indstillinger. Slå synkronisering til for at tilføje kontoen igen.",
|
||||
"status_safety_abort": "Stoppet: Serveren returnerede intet, selvom denne enhed stadig har elementer. Tjek serveren, og synkronisér derefter igen.",
|
||||
"status_synced": "Sidst synkroniseret: {when}",
|
||||
"status_syncing": "Synkroniserer…",
|
||||
"status_too_many_deletions": "Mange elementer blev slettet på denne enhed. De slettes ikke på serveren, før du bekræfter.",
|
||||
"status_unsupported_calendar": "Serveren tilbyder ingen kalendere til denne konto.",
|
||||
"status_unsupported_contacts": "Serveren tilbyder ingen adressebøger til denne konto.",
|
||||
"status_waiting": "Venter på den første synkronisering",
|
||||
"status_waiting_next": "Venter på den næste synkronisering",
|
||||
"suspended_desc": "Logget ud. Kontoens kontakter og kalendere bliver på denne enhed sammen med de ændringer, der er foretaget her, og synkroniseres igen, når du logger ind.",
|
||||
"switch_label": "Synkronisér {account} med denne enhed",
|
||||
"sync_now": "Synkronisér nu",
|
||||
"turn_off_anyway": "Slå fra",
|
||||
"turn_off_failed": "De synkroniserede data kunne ikke fjernes fra denne enhed. Synkroniseringen er sat på pause; prøv igen senere.",
|
||||
"turn_off_failed_title": "Synkronisering kunne ikke slås fra",
|
||||
"turn_off_pending": "{count, plural, one {# ændring foretaget på denne enhed er endnu ikke nået frem til serveren. Den går tabt, hvis du slår synkronisering fra nu.} other {# ændringer foretaget på denne enhed er endnu ikke nået frem til serveren. De går tabt, hvis du slår synkronisering fra nu.}}",
|
||||
"turn_off_title": "Vil du slå fra alligevel?",
|
||||
"turn_off_unknown": "Bulwark kunne ikke kontrollere, om ændringer foretaget på denne enhed er nået frem til serveren. De går tabt, hvis du slår synkronisering fra nu.",
|
||||
"turning_off": "Uploader ændringer og fjerner derefter data fra denne enhed…",
|
||||
"turning_on": "Konfigurerer…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Skabelon, der bruges, når en vedhæftning gemmes eller deles."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Bis {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Zeigt Ihre Kalender in der Kalender-App und in anderen Apps auf diesem Gerät an. Änderungen werden in beide Richtungen synchronisiert.",
|
||||
"device_sync_title": "Mit diesem Gerät synchronisieren",
|
||||
"show_week_numbers_mobile_desc": "Kalenderwochen in der Monatsansicht anzeigen.",
|
||||
"time_zone": "Zeitzone",
|
||||
"time_zone_auto": "Gerätezeitzone",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label entfernen"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Der Server akzeptiert die Anmeldung von {account} nicht mehr, daher werden die Kontakte und Kalender dieses Kontos auf diesem Gerät nicht mehr synchronisiert. Melden Sie sich erneut an, um fortzufahren. Auf diesem Gerät vorgenommene Änderungen bleiben erhalten.",
|
||||
"auth_problem_title": "Erneut anmelden, um weiter zu synchronisieren",
|
||||
"problem_channel": "Synchronisierungsprobleme",
|
||||
"sign_out_anyway": "Abmelden",
|
||||
"sign_out_pending_message": "{count, plural, one {# auf diesem Gerät vorgenommene Änderung hat den Server nicht erreicht. Sie geht verloren, wenn Sie sich jetzt abmelden.} other {# auf diesem Gerät vorgenommene Änderungen haben den Server nicht erreicht. Sie gehen verloren, wenn Sie sich jetzt abmelden.}}",
|
||||
"sign_out_pending_title": "Trotzdem abmelden?",
|
||||
"sign_out_unknown_message": "Auf diesem Gerät vorgenommene Änderungen haben den Server möglicherweise nicht erreicht. Sie gehen verloren, wenn Sie sich jetzt abmelden.",
|
||||
"signed_out_text": "{account} wurde abgemeldet, daher werden die Kontakte und Kalender dieses Kontos auf diesem Gerät nicht mehr synchronisiert. Melden Sie sich erneut an, um fortzufahren. Auf diesem Gerät vorgenommene Änderungen bleiben erhalten.",
|
||||
"signed_out_title": "Anmelden, um weiter zu synchronisieren",
|
||||
"signing_out": "Synchronisierte Kontakte und Kalender werden von diesem Gerät entfernt…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "Ihre Nachricht erwähnt \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
|
||||
"default_book": "Standardadressbuch",
|
||||
"description_mobile": "Anzeigeeinstellungen und Adressbuch-Werkzeuge.",
|
||||
"device_sync_desc": "Zeigt Ihre Adressbücher in der Kontakte-App und in anderen Apps auf diesem Gerät an. Änderungen werden in beide Richtungen synchronisiert.",
|
||||
"device_sync_title": "Mit diesem Gerät synchronisieren",
|
||||
"export_description_count": "{count, plural, one {# Kontakt als eine vCard-Datei exportieren.} other {# Kontakte als eine vCard-Datei exportieren.}}",
|
||||
"export_empty": "Keine Kontakte zum Exportieren",
|
||||
"import_description_mobile": "Kontakte aus einer vCard-Datei (.vcf) importieren, mit Duplikaterkennung.",
|
||||
"manage_description_mobile": "Die Adressbücher erstellen, umbenennen und entfernen, in denen Ihre Kontakte organisiert sind."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Wird auch für {accounts} synchronisiert und erscheint daher doppelt auf diesem Gerät.",
|
||||
"android_settings": "Android-Kontoeinstellungen",
|
||||
"blocked_calendar": "Android fragt nicht mehr nach dem Zugriff auf Kalender. Erlauben Sie ihn in den App-Einstellungen und schalten Sie die Synchronisierung dann wieder ein.",
|
||||
"blocked_contacts": "Android fragt nicht mehr nach dem Zugriff auf Kontakte. Erlauben Sie ihn in den App-Einstellungen und schalten Sie die Synchronisierung dann wieder ein.",
|
||||
"blocked_title": "Zugriff ist ausgeschaltet",
|
||||
"books_default": "Ihre eigenen Adressbücher werden synchronisiert, geteilte nur, wenn Sie sie auswählen.",
|
||||
"books_hint": "Ihre eigenen Adressbücher werden synchronisiert, außer Sie schalten sie aus; geteilte nur, wenn Sie sie einschalten.",
|
||||
"books_label": "Adressbücher",
|
||||
"calendars_default": "Ihre eigenen Kalender werden synchronisiert, geteilte nur, wenn Sie sie auswählen.",
|
||||
"calendars_hint": "Ihre eigenen Kalender werden synchronisiert, außer Sie schalten sie aus; geteilte nur, wenn Sie sie einschalten. Aufgabenlisten bleiben in Bulwark.",
|
||||
"calendars_label": "Kalender",
|
||||
"choose": "Auswählen",
|
||||
"choose_books_title": "Zu synchronisierende Adressbücher",
|
||||
"choose_calendars_title": "Zu synchronisierende Kalender",
|
||||
"collections_count": "{selected} von {total} synchronisiert",
|
||||
"continue": "Weiter",
|
||||
"deletions_delete": "Auf dem Server löschen",
|
||||
"deletions_message": "Auf diesem Gerät wurden viele Elemente auf einmal gelöscht, daher wurden sie auf dem Server nicht gelöscht. Löschen Sie sie auch dort oder stellen Sie sie auf diesem Gerät wieder her.",
|
||||
"deletions_restore": "Wiederherstellen",
|
||||
"deletions_title": "Auch auf dem Server löschen?",
|
||||
"denied_calendar": "Ohne Zugriff auf Kalender kann Bulwark Ihre Kalender nicht auf dieses Gerät bringen.",
|
||||
"denied_contacts": "Ohne Zugriff auf Kontakte kann Bulwark Ihre Adressbücher nicht auf dieses Gerät bringen.",
|
||||
"denied_title": "Synchronisierung bleibt aus",
|
||||
"failed_title": "Synchronisierung konnte nicht eingeschaltet werden",
|
||||
"grant_access": "Zugriff erlauben",
|
||||
"interval_15m": "Alle 15 Minuten",
|
||||
"interval_1h": "Jede Stunde",
|
||||
"interval_30m": "Alle 30 Minuten",
|
||||
"interval_6h": "Alle 6 Stunden",
|
||||
"interval_desc": "Wie oft Android von selbst synchronisiert. Änderungen in Bulwark und per Push gemeldete Änderungen werden früher synchronisiert.",
|
||||
"interval_label": "Synchronisierungsintervall",
|
||||
"interval_manual": "Keine regelmäßige Synchronisierung",
|
||||
"keep_syncing": "Weiter synchronisieren",
|
||||
"list_error_auth": "Der Server hat die Anmeldung nicht akzeptiert.",
|
||||
"list_error_network": "Der Server war nicht erreichbar.",
|
||||
"list_error_server": "Der Server konnte sie nicht auflisten.",
|
||||
"list_error_signed_out": "Dieses Konto ist auf diesem Gerät nicht angemeldet.",
|
||||
"master_off": "Die automatische Synchronisierung ist auf diesem Gerät ausgeschaltet, daher wird nur synchronisiert, wenn Sie auf \"Jetzt synchronisieren\" tippen.",
|
||||
"new_contacts_default": "Standardadressbuch",
|
||||
"new_contacts_desc": "Das Adressbuch für Kontakte, die in anderen Apps auf diesem Gerät erstellt werden.",
|
||||
"new_contacts_label": "Neue Kontakte speichern in",
|
||||
"no_books": "Dieses Konto hat keine Adressbücher.",
|
||||
"no_calendars": "Dieses Konto hat keine Kalender zum Synchronisieren.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "App-Einstellungen öffnen",
|
||||
"own_collections": "Ihre eigenen",
|
||||
"rationale_calendar": "Um Ihre Kalender in der Kalender-App anzuzeigen, benötigt Bulwark Zugriff auf die Kalender auf diesem Gerät. Bulwark ändert nur die Kalender seines eigenen Kontos.",
|
||||
"rationale_contacts": "Um Ihre Adressbücher in der Kontakte-App anzuzeigen, benötigt Bulwark Zugriff auf die Kontakte auf diesem Gerät. Bulwark ändert nur die Kontakte seines eigenen Kontos.",
|
||||
"rationale_title_calendar": "Zugriff auf Kalender erlauben",
|
||||
"rationale_title_contacts": "Zugriff auf Kontakte erlauben",
|
||||
"read_only": "Schreibgeschützt",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Kalender-App",
|
||||
"reminder_question_message": "Synchronisierte Termine können Sie über die Kalender-App auf diesem Gerät oder über Bulwark erinnern. Wählen Sie eines davon, damit Sie nicht doppelt erinnert werden. Sie können dies später ändern.",
|
||||
"reminder_question_title": "Wer soll Sie erinnern?",
|
||||
"reminders_desc": "Wer Sie an synchronisierte Termine erinnert. Die jeweils andere App bleibt still, damit Sie nicht doppelt erinnert werden.",
|
||||
"reminders_label": "Erinnerungen",
|
||||
"reminders_nobody": "Die Terminbenachrichtigungen von Bulwark sind ausgeschaltet, daher erinnert Sie nichts an synchronisierte Termine.",
|
||||
"remove": "Entfernen",
|
||||
"remove_from_device": "Von diesem Gerät entfernen",
|
||||
"remove_message": "Die Kontakte und Kalender des Kontos werden von diesem Gerät entfernt. Hier vorgenommene Änderungen, die den Server noch nicht erreicht haben, gehen verloren.",
|
||||
"remove_title": "Von diesem Gerät entfernen?",
|
||||
"review_deletions": "Löschungen prüfen",
|
||||
"shared_by": "Von {name} geteilt",
|
||||
"sign_in_again": "Erneut anmelden",
|
||||
"status_auth": "Der Server akzeptiert die Anmeldung nicht mehr. Melden Sie sich erneut an, um weiter zu synchronisieren.",
|
||||
"status_cancelled": "Die letzte Synchronisierung wurde vorzeitig beendet. Sie wird beim nächsten Mal fortgesetzt.",
|
||||
"status_failed": "Die letzte Synchronisierung ist fehlgeschlagen: {message}",
|
||||
"status_failed_plain": "Die letzte Synchronisierung ist fehlgeschlagen.",
|
||||
"status_io": "Der Server war {when} nicht erreichbar. Android versucht es erneut.",
|
||||
"status_item_errors": "{count, plural, one {# Element konnte nicht synchronisiert werden.} other {# Elemente konnten nicht synchronisiert werden.}}",
|
||||
"status_off": "Aus",
|
||||
"status_paused": "In den Android-Einstellungen pausiert",
|
||||
"status_permission_calendar": "Der Zugriff auf Kalender wurde ausgeschaltet, daher wird nichts synchronisiert.",
|
||||
"status_permission_contacts": "Der Zugriff auf Kontakte wurde ausgeschaltet, daher wird nichts synchronisiert.",
|
||||
"status_removed": "In den Android-Einstellungen entfernt. Schalten Sie die Synchronisierung ein, um das Konto wieder hinzuzufügen.",
|
||||
"status_safety_abort": "Angehalten: Der Server hat nichts zurückgegeben, obwohl dieses Gerät noch Elemente enthält. Prüfen Sie den Server und synchronisieren Sie dann erneut.",
|
||||
"status_synced": "Zuletzt synchronisiert: {when}",
|
||||
"status_syncing": "Wird synchronisiert…",
|
||||
"status_too_many_deletions": "Auf diesem Gerät wurden viele Elemente gelöscht. Auf dem Server werden sie erst gelöscht, wenn Sie dies bestätigen.",
|
||||
"status_unsupported_calendar": "Der Server bietet für dieses Konto keine Kalender an.",
|
||||
"status_unsupported_contacts": "Der Server bietet für dieses Konto keine Adressbücher an.",
|
||||
"status_waiting": "Warten auf die erste Synchronisierung",
|
||||
"status_waiting_next": "Warten auf die nächste Synchronisierung",
|
||||
"suspended_desc": "Abgemeldet. Die Kontakte und Kalender des Kontos bleiben mit den hier vorgenommenen Änderungen auf diesem Gerät und werden wieder synchronisiert, sobald Sie sich anmelden.",
|
||||
"switch_label": "{account} mit diesem Gerät synchronisieren",
|
||||
"sync_now": "Jetzt synchronisieren",
|
||||
"turn_off_anyway": "Ausschalten",
|
||||
"turn_off_failed": "Die synchronisierten Daten konnten nicht von diesem Gerät entfernt werden. Die Synchronisierung ist pausiert; versuchen Sie es später erneut.",
|
||||
"turn_off_failed_title": "Synchronisierung konnte nicht ausgeschaltet werden",
|
||||
"turn_off_pending": "{count, plural, one {# auf diesem Gerät vorgenommene Änderung hat den Server noch nicht erreicht. Sie geht verloren, wenn Sie die Synchronisierung jetzt ausschalten.} other {# auf diesem Gerät vorgenommene Änderungen haben den Server noch nicht erreicht. Sie gehen verloren, wenn Sie die Synchronisierung jetzt ausschalten.}}",
|
||||
"turn_off_title": "Trotzdem ausschalten?",
|
||||
"turn_off_unknown": "Bulwark konnte nicht prüfen, ob auf diesem Gerät vorgenommene Änderungen den Server erreicht haben. Sie gehen verloren, wenn Sie die Synchronisierung jetzt ausschalten.",
|
||||
"turning_off": "Änderungen werden hochgeladen, dann werden die Daten von diesem Gerät entfernt…",
|
||||
"turning_on": "Wird eingerichtet…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Vorlage für das Speichern oder Teilen eines Anhangs."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Until {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.",
|
||||
"device_sync_title": "Sync to this device",
|
||||
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
|
||||
"time_zone": "Time zone",
|
||||
"time_zone_auto": "Device time zone",
|
||||
@@ -252,6 +254,18 @@
|
||||
"deep_link": {
|
||||
"open_failed": "Couldn't open this"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "The server no longer accepts the sign-in of {account}, so its contacts and calendars on this device stopped syncing. Sign in again to resume. Changes made on this device are kept.",
|
||||
"auth_problem_title": "Sign in again to keep syncing",
|
||||
"problem_channel": "Sync problems",
|
||||
"sign_out_anyway": "Sign out",
|
||||
"sign_out_pending_message": "{count, plural, one {# change made on this device hasn't reached the server. It is lost if you sign out now.} other {# changes made on this device haven't reached the server. They are lost if you sign out now.}}",
|
||||
"sign_out_pending_title": "Sign out anyway?",
|
||||
"sign_out_unknown_message": "Changes made on this device may not have reached the server. They are lost if you sign out now.",
|
||||
"signed_out_text": "{account} was signed out, so its contacts and calendars on this device no longer sync. Sign in again to resume. Changes made on this device are kept.",
|
||||
"signed_out_title": "Sign in to keep syncing",
|
||||
"signing_out": "Removing synced contacts and calendars from this device…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Camera",
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
@@ -639,11 +653,110 @@
|
||||
"book_count": "{count, plural, one {# contact} other {# contacts}}",
|
||||
"default_book": "Default address book",
|
||||
"description_mobile": "Display preferences and address-book tools.",
|
||||
"device_sync_desc": "Show your address books in the Contacts app and other apps on this device. Changes sync both ways.",
|
||||
"device_sync_title": "Sync to this device",
|
||||
"export_description_count": "{count, plural, one {Export # contact as a single vCard file.} other {Export # contacts as a single vCard file.}}",
|
||||
"export_empty": "No contacts to export",
|
||||
"import_description_mobile": "Import contacts from a vCard (.vcf) file with duplicate detection.",
|
||||
"manage_description_mobile": "Create, rename, and remove the address books that organize your contacts."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Also synced for {accounts}, so it shows twice on this device.",
|
||||
"android_settings": "Android account settings",
|
||||
"blocked_calendar": "Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.",
|
||||
"blocked_contacts": "Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.",
|
||||
"blocked_title": "Access is turned off",
|
||||
"books_default": "Your own address books sync; shared ones only when you pick them.",
|
||||
"books_hint": "Your own address books sync unless you turn them off; shared ones only when you turn them on.",
|
||||
"books_label": "Address books",
|
||||
"calendars_default": "Your own calendars sync; shared ones only when you pick them.",
|
||||
"calendars_hint": "Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.",
|
||||
"calendars_label": "Calendars",
|
||||
"choose": "Choose",
|
||||
"choose_books_title": "Address books to sync",
|
||||
"choose_calendars_title": "Calendars to sync",
|
||||
"collections_count": "{selected, plural, one {# of {total} syncs} other {# of {total} sync}}",
|
||||
"continue": "Continue",
|
||||
"deletions_delete": "Delete on server",
|
||||
"deletions_message": "Many items were deleted on this device at once, so they were not deleted on the server. Delete them there as well, or bring them back on this device.",
|
||||
"deletions_restore": "Bring them back",
|
||||
"deletions_title": "Delete them on the server too?",
|
||||
"denied_calendar": "Without access to calendars, Bulwark cannot put your calendars on this device.",
|
||||
"denied_contacts": "Without access to contacts, Bulwark cannot put your address books on this device.",
|
||||
"denied_title": "Sync stays off",
|
||||
"failed_title": "Sync could not be turned on",
|
||||
"grant_access": "Grant access",
|
||||
"interval_15m": "Every 15 minutes",
|
||||
"interval_1h": "Every hour",
|
||||
"interval_30m": "Every 30 minutes",
|
||||
"interval_6h": "Every 6 hours",
|
||||
"interval_desc": "How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.",
|
||||
"interval_label": "Sync interval",
|
||||
"interval_manual": "No regular sync",
|
||||
"keep_syncing": "Keep syncing",
|
||||
"list_error_auth": "The server did not accept the sign-in.",
|
||||
"list_error_network": "Could not reach the server.",
|
||||
"list_error_server": "The server could not list them.",
|
||||
"list_error_signed_out": "This account is not signed in on this device.",
|
||||
"master_off": "Auto-sync is off on this device, so syncs only run when you tap Sync now.",
|
||||
"new_contacts_default": "Default address book",
|
||||
"new_contacts_desc": "The address book for contacts created in other apps on this device.",
|
||||
"new_contacts_label": "New contacts go to",
|
||||
"no_books": "This account has no address books.",
|
||||
"no_calendars": "This account has no calendars to sync.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Open app settings",
|
||||
"own_collections": "Your own",
|
||||
"rationale_calendar": "To show your calendars in the Calendar app, Bulwark needs access to the calendars on this device. It only changes the calendars of its own account.",
|
||||
"rationale_contacts": "To show your address books in the Contacts app, Bulwark needs access to the contacts on this device. It only changes the contacts of its own account.",
|
||||
"rationale_title_calendar": "Allow access to calendars",
|
||||
"rationale_title_contacts": "Allow access to contacts",
|
||||
"read_only": "Read-only",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Calendar app",
|
||||
"reminder_question_message": "Synced events can remind you from the calendar app on this device or from Bulwark. Pick one so you are not reminded twice. You can change it later.",
|
||||
"reminder_question_title": "Who should remind you?",
|
||||
"reminders_desc": "Who reminds you of synced events. The other stays quiet, so you are not reminded twice.",
|
||||
"reminders_label": "Reminders",
|
||||
"reminders_nobody": "Bulwark's event notifications are off, so nothing reminds you of synced events.",
|
||||
"remove": "Remove",
|
||||
"remove_from_device": "Remove from this device",
|
||||
"remove_message": "The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.",
|
||||
"remove_title": "Remove from this device?",
|
||||
"review_deletions": "Review deletions",
|
||||
"shared_by": "Shared by {name}",
|
||||
"sign_in_again": "Sign in again",
|
||||
"status_auth": "The server no longer accepts the sign-in. Sign in again to keep syncing.",
|
||||
"status_cancelled": "The last sync stopped early. It continues next time.",
|
||||
"status_failed": "The last sync failed: {message}",
|
||||
"status_failed_plain": "The last sync failed.",
|
||||
"status_io": "Could not reach the server {when}. Android tries again.",
|
||||
"status_item_errors": "{count, plural, one {# item could not be synced.} other {# items could not be synced.}}",
|
||||
"status_off": "Off",
|
||||
"status_paused": "Paused in Android settings",
|
||||
"status_permission_calendar": "Access to calendars was turned off, so nothing syncs.",
|
||||
"status_permission_contacts": "Access to contacts was turned off, so nothing syncs.",
|
||||
"status_removed": "Removed in Android settings. Turn on to add the account again.",
|
||||
"status_safety_abort": "Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.",
|
||||
"status_synced": "Synced {when}",
|
||||
"status_syncing": "Syncing…",
|
||||
"status_too_many_deletions": "Many items were deleted on this device. They are not deleted on the server until you confirm.",
|
||||
"status_unsupported_calendar": "The server offers no calendars for this account.",
|
||||
"status_unsupported_contacts": "The server offers no address books for this account.",
|
||||
"status_waiting": "Waiting for the first sync",
|
||||
"status_waiting_next": "Waiting for the next sync",
|
||||
"suspended_desc": "Signed out. Its contacts and calendars stay on this device, with the changes made here, and sync again once you sign in.",
|
||||
"switch_label": "Sync {account} to this device",
|
||||
"sync_now": "Sync now",
|
||||
"turn_off_anyway": "Turn off",
|
||||
"turn_off_failed": "The synced data could not be removed from this device. Sync is paused; try again later.",
|
||||
"turn_off_failed_title": "Sync could not be turned off",
|
||||
"turn_off_pending": "{count, plural, one {# change made on this device hasn't reached the server yet. It is lost if you turn sync off now.} other {# changes made on this device haven't reached the server yet. They are lost if you turn sync off now.}}",
|
||||
"turn_off_title": "Turn off anyway?",
|
||||
"turn_off_unknown": "Bulwark could not check whether changes made on this device reached the server. They are lost if you turn sync off now.",
|
||||
"turning_off": "Uploading changes, then removing the data from this device…",
|
||||
"turning_on": "Setting up…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Template used when saving or sharing an attachment."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Hasta el {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Muestra tus calendarios en la app Calendario y en otras apps de este dispositivo. Los cambios se sincronizan en ambos sentidos.",
|
||||
"device_sync_title": "Sincronizar con este dispositivo",
|
||||
"show_week_numbers_mobile_desc": "Mostrar los números de semana en la vista mensual.",
|
||||
"time_zone": "Zona horaria",
|
||||
"time_zone_auto": "Zona horaria del dispositivo",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Eliminar etiqueta"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "El servidor ya no acepta el inicio de sesión de {account}, así que sus contactos y calendarios de este dispositivo han dejado de sincronizarse. Vuelve a iniciar sesión para reanudar la sincronización. Los cambios hechos en este dispositivo se conservan.",
|
||||
"auth_problem_title": "Vuelve a iniciar sesión para seguir sincronizando",
|
||||
"problem_channel": "Problemas de sincronización",
|
||||
"sign_out_anyway": "Cerrar sesión",
|
||||
"sign_out_pending_message": "{count, plural, one {# cambio hecho en este dispositivo no ha llegado al servidor. Se perderá si cierras sesión ahora.} other {# cambios hechos en este dispositivo no han llegado al servidor. Se perderán si cierras sesión ahora.}}",
|
||||
"sign_out_pending_title": "¿Cerrar sesión de todos modos?",
|
||||
"sign_out_unknown_message": "Puede que los cambios hechos en este dispositivo no hayan llegado al servidor. Se perderán si cierras sesión ahora.",
|
||||
"signed_out_text": "Se cerró la sesión de {account}, así que sus contactos y calendarios de este dispositivo ya no se sincronizan. Vuelve a iniciar sesión para reanudar la sincronización. Los cambios hechos en este dispositivo se conservan.",
|
||||
"signed_out_title": "Inicia sesión para seguir sincronizando",
|
||||
"signing_out": "Quitando de este dispositivo los contactos y calendarios sincronizados…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Cámara",
|
||||
"attachment_reminder_body": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contacto} other {# contactos}}",
|
||||
"default_book": "Libreta de direcciones predeterminada",
|
||||
"description_mobile": "Preferencias de visualización y herramientas de la libreta de direcciones.",
|
||||
"device_sync_desc": "Muestra tus libretas de direcciones en la app Contactos y en otras apps de este dispositivo. Los cambios se sincronizan en ambos sentidos.",
|
||||
"device_sync_title": "Sincronizar con este dispositivo",
|
||||
"export_description_count": "{count, plural, one {Exporta # contacto como un único archivo vCard.} other {Exporta # contactos como un único archivo vCard.}}",
|
||||
"export_empty": "No hay contactos para exportar",
|
||||
"import_description_mobile": "Importa contactos desde un archivo vCard (.vcf) con detección de duplicados.",
|
||||
"manage_description_mobile": "Crea, renombra y elimina las libretas de direcciones que organizan tus contactos."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "También se sincroniza para {accounts}, por lo que aparece dos veces en este dispositivo.",
|
||||
"android_settings": "Ajustes de cuenta de Android",
|
||||
"blocked_calendar": "Android ya no pide acceso a los calendarios. Permítelo en los ajustes de la app y vuelve a activar la sincronización.",
|
||||
"blocked_contacts": "Android ya no pide acceso a los contactos. Permítelo en los ajustes de la app y vuelve a activar la sincronización.",
|
||||
"blocked_title": "El acceso está desactivado",
|
||||
"books_default": "Tus libretas de direcciones se sincronizan; las compartidas, solo si las eliges.",
|
||||
"books_hint": "Tus libretas de direcciones se sincronizan salvo que las desactives; las compartidas, solo si las activas.",
|
||||
"books_label": "Libretas de direcciones",
|
||||
"calendars_default": "Tus calendarios se sincronizan; los compartidos, solo si los eliges.",
|
||||
"calendars_hint": "Tus calendarios se sincronizan salvo que los desactives; los compartidos, solo si los activas. Las listas de tareas se quedan en Bulwark.",
|
||||
"calendars_label": "Calendarios",
|
||||
"choose": "Elegir",
|
||||
"choose_books_title": "Libretas de direcciones que sincronizar",
|
||||
"choose_calendars_title": "Calendarios que sincronizar",
|
||||
"collections_count": "Sincronización: {selected} de {total}",
|
||||
"continue": "Continuar",
|
||||
"deletions_delete": "Eliminar en el servidor",
|
||||
"deletions_message": "Se eliminaron muchos elementos a la vez en este dispositivo, así que no se eliminaron en el servidor. Elimínalos también allí o recupéralos en este dispositivo.",
|
||||
"deletions_restore": "Recuperarlos",
|
||||
"deletions_title": "¿Eliminarlos también en el servidor?",
|
||||
"denied_calendar": "Sin acceso a los calendarios, Bulwark no puede poner tus calendarios en este dispositivo.",
|
||||
"denied_contacts": "Sin acceso a los contactos, Bulwark no puede poner tus libretas de direcciones en este dispositivo.",
|
||||
"denied_title": "La sincronización sigue desactivada",
|
||||
"failed_title": "No se pudo activar la sincronización",
|
||||
"grant_access": "Conceder acceso",
|
||||
"interval_15m": "Cada 15 minutos",
|
||||
"interval_1h": "Cada hora",
|
||||
"interval_30m": "Cada 30 minutos",
|
||||
"interval_6h": "Cada 6 horas",
|
||||
"interval_desc": "Con qué frecuencia sincroniza Android por su cuenta. Los cambios hechos en Bulwark y los que llegan por push se sincronizan antes.",
|
||||
"interval_label": "Intervalo de sincronización",
|
||||
"interval_manual": "Sin sincronización periódica",
|
||||
"keep_syncing": "Seguir sincronizando",
|
||||
"list_error_auth": "El servidor no aceptó el inicio de sesión.",
|
||||
"list_error_network": "No se pudo conectar con el servidor.",
|
||||
"list_error_server": "El servidor no pudo mostrar la lista.",
|
||||
"list_error_signed_out": "Esta cuenta no tiene la sesión iniciada en este dispositivo.",
|
||||
"master_off": "La sincronización automática está desactivada en este dispositivo, así que solo se sincroniza cuando tocas \"Sincronizar ahora\".",
|
||||
"new_contacts_default": "Libreta de direcciones predeterminada",
|
||||
"new_contacts_desc": "La libreta de direcciones para los contactos creados en otras apps de este dispositivo.",
|
||||
"new_contacts_label": "Guardar contactos nuevos en",
|
||||
"no_books": "Esta cuenta no tiene libretas de direcciones.",
|
||||
"no_calendars": "Esta cuenta no tiene calendarios que sincronizar.",
|
||||
"ok": "Aceptar",
|
||||
"open_app_settings": "Abrir ajustes de la app",
|
||||
"own_collections": "Personales",
|
||||
"rationale_calendar": "Para mostrar tus calendarios en la app Calendario, Bulwark necesita acceso a los calendarios de este dispositivo. Solo modifica los calendarios de su propia cuenta.",
|
||||
"rationale_contacts": "Para mostrar tus libretas de direcciones en la app Contactos, Bulwark necesita acceso a los contactos de este dispositivo. Solo modifica los contactos de su propia cuenta.",
|
||||
"rationale_title_calendar": "Permitir acceso a los calendarios",
|
||||
"rationale_title_contacts": "Permitir acceso a los contactos",
|
||||
"read_only": "Solo lectura",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "App Calendario",
|
||||
"reminder_question_message": "Los recordatorios de los eventos sincronizados pueden venir de la app de calendario de este dispositivo o de Bulwark. Elige una opción para no recibirlos dos veces. Puedes cambiarlo más tarde.",
|
||||
"reminder_question_title": "¿Quién debe avisarte?",
|
||||
"reminders_desc": "Quién te avisa de los eventos sincronizados. El otro se queda en silencio para que no recibas dos avisos.",
|
||||
"reminders_label": "Recordatorios",
|
||||
"reminders_nobody": "Las notificaciones de eventos de Bulwark están desactivadas, así que nada te avisará de los eventos sincronizados.",
|
||||
"remove": "Quitar",
|
||||
"remove_from_device": "Quitar de este dispositivo",
|
||||
"remove_message": "Los contactos y calendarios de la cuenta se quitarán de este dispositivo. Los cambios hechos aquí que no hayan llegado al servidor se perderán.",
|
||||
"remove_title": "¿Quitar de este dispositivo?",
|
||||
"review_deletions": "Revisar eliminaciones",
|
||||
"shared_by": "Compartido por {name}",
|
||||
"sign_in_again": "Volver a iniciar sesión",
|
||||
"status_auth": "El servidor ya no acepta el inicio de sesión. Vuelve a iniciar sesión para seguir sincronizando.",
|
||||
"status_cancelled": "La última sincronización se detuvo antes de tiempo. Continuará la próxima vez.",
|
||||
"status_failed": "La última sincronización falló: {message}",
|
||||
"status_failed_plain": "La última sincronización falló.",
|
||||
"status_io": "No se pudo conectar con el servidor {when}. Android lo volverá a intentar.",
|
||||
"status_item_errors": "{count, plural, one {No se pudo sincronizar # elemento.} other {No se pudieron sincronizar # elementos.}}",
|
||||
"status_off": "Desactivada",
|
||||
"status_paused": "En pausa en los ajustes de Android",
|
||||
"status_permission_calendar": "Se desactivó el acceso a los calendarios, así que no se sincroniza nada.",
|
||||
"status_permission_contacts": "Se desactivó el acceso a los contactos, así que no se sincroniza nada.",
|
||||
"status_removed": "Se quitó en los ajustes de Android. Activa la sincronización para volver a añadir la cuenta.",
|
||||
"status_safety_abort": "Detenida: el servidor no devolvió nada, aunque este dispositivo aún tiene elementos. Revisa el servidor y vuelve a sincronizar.",
|
||||
"status_synced": "Sincronizado {when}",
|
||||
"status_syncing": "Sincronizando…",
|
||||
"status_too_many_deletions": "Se eliminaron muchos elementos en este dispositivo. No se eliminarán en el servidor hasta que lo confirmes.",
|
||||
"status_unsupported_calendar": "El servidor no ofrece calendarios para esta cuenta.",
|
||||
"status_unsupported_contacts": "El servidor no ofrece libretas de direcciones para esta cuenta.",
|
||||
"status_waiting": "Esperando la primera sincronización",
|
||||
"status_waiting_next": "Esperando la próxima sincronización",
|
||||
"suspended_desc": "Sesión cerrada. Sus contactos y calendarios se quedan en este dispositivo, con los cambios hechos aquí, y se volverán a sincronizar cuando inicies sesión.",
|
||||
"switch_label": "Sincronizar {account} con este dispositivo",
|
||||
"sync_now": "Sincronizar ahora",
|
||||
"turn_off_anyway": "Desactivar",
|
||||
"turn_off_failed": "No se pudieron quitar los datos sincronizados de este dispositivo. La sincronización está en pausa; inténtalo de nuevo más tarde.",
|
||||
"turn_off_failed_title": "No se pudo desactivar la sincronización",
|
||||
"turn_off_pending": "{count, plural, one {# cambio hecho en este dispositivo aún no ha llegado al servidor. Se perderá si desactivas la sincronización ahora.} other {# cambios hechos en este dispositivo aún no han llegado al servidor. Se perderán si desactivas la sincronización ahora.}}",
|
||||
"turn_off_title": "¿Desactivar de todos modos?",
|
||||
"turn_off_unknown": "Bulwark no pudo comprobar si los cambios hechos en este dispositivo llegaron al servidor. Se perderán si desactivas la sincronización ahora.",
|
||||
"turning_off": "Subiendo los cambios y quitando después los datos de este dispositivo…",
|
||||
"turning_on": "Configurando…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Plantilla usada al guardar o compartir un adjunto."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "تا {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "تقویمهای خود را در برنامه تقویم و برنامههای دیگر این دستگاه نشان دهید. تغییرات در هر دو جهت همگامسازی میشوند.",
|
||||
"device_sync_title": "همگامسازی با این دستگاه",
|
||||
"show_week_numbers_mobile_desc": "نمایش شماره هفتهها در نمای ماه.",
|
||||
"time_zone": "منطقه زمانی",
|
||||
"time_zone_auto": "منطقه زمانی دستگاه",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "برچسب",
|
||||
"remove_color": "حذف برچسب"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "سرور دیگر ورود {account} را نمیپذیرد، بنابراین همگامسازی مخاطبین و تقویمهای این حساب در این دستگاه متوقف شد. برای ادامه، دوباره وارد شوید. تغییراتی که در این دستگاه ایجاد شدهاند حفظ میشوند.",
|
||||
"auth_problem_title": "برای ادامه همگامسازی دوباره وارد شوید",
|
||||
"problem_channel": "مشکلات همگامسازی",
|
||||
"sign_out_anyway": "خروج",
|
||||
"sign_out_pending_message": "{count, plural, one {# تغییری که در این دستگاه ایجاد شده به سرور نرسیده است. اگر اکنون خارج شوید، از دست میرود.} other {# تغییری که در این دستگاه ایجاد شده به سرور نرسیده است. اگر اکنون خارج شوید، از دست میروند.}}",
|
||||
"sign_out_pending_title": "به هر حال خارج میشوید؟",
|
||||
"sign_out_unknown_message": "ممکن است تغییراتی که در این دستگاه ایجاد شدهاند به سرور نرسیده باشند. اگر اکنون خارج شوید، از دست میروند.",
|
||||
"signed_out_text": "خروج از {account} انجام شد، بنابراین مخاطبین و تقویمهای این حساب در این دستگاه دیگر همگامسازی نمیشوند. برای ادامه، دوباره وارد شوید. تغییراتی که در این دستگاه ایجاد شدهاند حفظ میشوند.",
|
||||
"signed_out_title": "برای ادامه همگامسازی وارد شوید",
|
||||
"signing_out": "در حال حذف مخاطبین و تقویمهای همگامسازیشده از این دستگاه…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "دوربین",
|
||||
"attachment_reminder_body": "در پیام شما به «{keyword}» اشاره شده اما فایلی پیوست نشده است. با این حال ارسال شود؟",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# مخاطب} other {# مخاطب}}",
|
||||
"default_book": "دفترچه آدرس پیشفرض",
|
||||
"description_mobile": "تنظیمات نمایش و ابزارهای دفترچه آدرس.",
|
||||
"device_sync_desc": "دفترچههای آدرس خود را در برنامه مخاطبین و برنامههای دیگر این دستگاه نشان دهید. تغییرات در هر دو جهت همگامسازی میشوند.",
|
||||
"device_sync_title": "همگامسازی با این دستگاه",
|
||||
"export_description_count": "{count, plural, one {خروجی گرفتن از # مخاطب در قالب یک فایل vCard.} other {خروجی گرفتن از # مخاطب در قالب یک فایل vCard.}}",
|
||||
"export_empty": "مخاطبی برای خروجی گرفتن نیست",
|
||||
"import_description_mobile": "وارد کردن مخاطبین از فایل vCard (.vcf) با تشخیص موارد تکراری.",
|
||||
"manage_description_mobile": "دفترچههای آدرسی را که مخاطبین شما را سازماندهی میکنند ایجاد، تغییر نام و حذف کنید."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "برای {accounts} هم همگامسازی میشود، به همین دلیل در این دستگاه دو بار نمایش داده میشود.",
|
||||
"android_settings": "تنظیمات حساب Android",
|
||||
"blocked_calendar": "Android دیگر برای دسترسی به تقویمها درخواست نمیدهد. آن را در تنظیمات برنامه مجاز کنید، سپس همگامسازی را دوباره روشن کنید.",
|
||||
"blocked_contacts": "Android دیگر برای دسترسی به مخاطبین درخواست نمیدهد. آن را در تنظیمات برنامه مجاز کنید، سپس همگامسازی را دوباره روشن کنید.",
|
||||
"blocked_title": "دسترسی خاموش است",
|
||||
"books_default": "دفترچههای آدرس خودتان همگامسازی میشوند؛ دفترچههای اشتراکی فقط وقتی آنها را انتخاب کنید.",
|
||||
"books_hint": "دفترچههای آدرس خودتان همگامسازی میشوند، مگر اینکه آنها را خاموش کنید؛ دفترچههای اشتراکی فقط وقتی آنها را روشن کنید.",
|
||||
"books_label": "دفترچههای آدرس",
|
||||
"calendars_default": "تقویمهای خودتان همگامسازی میشوند؛ تقویمهای اشتراکی فقط وقتی آنها را انتخاب کنید.",
|
||||
"calendars_hint": "تقویمهای خودتان همگامسازی میشوند، مگر اینکه آنها را خاموش کنید؛ تقویمهای اشتراکی فقط وقتی آنها را روشن کنید. فهرستهای وظایف در Bulwark میمانند.",
|
||||
"calendars_label": "تقویمها",
|
||||
"choose": "انتخاب",
|
||||
"choose_books_title": "دفترچههای آدرس برای همگامسازی",
|
||||
"choose_calendars_title": "تقویمها برای همگامسازی",
|
||||
"collections_count": "{selected} از {total} همگامسازی میشوند",
|
||||
"continue": "ادامه",
|
||||
"deletions_delete": "حذف در سرور",
|
||||
"deletions_message": "موارد زیادی یکجا در این دستگاه حذف شدند، بنابراین در سرور حذف نشدند. آنها را در سرور هم حذف کنید یا در این دستگاه بازگردانید.",
|
||||
"deletions_restore": "بازگرداندن",
|
||||
"deletions_title": "در سرور هم حذف شوند؟",
|
||||
"denied_calendar": "بدون دسترسی به تقویمها، Bulwark نمیتواند تقویمهای شما را در این دستگاه قرار دهد.",
|
||||
"denied_contacts": "بدون دسترسی به مخاطبین، Bulwark نمیتواند دفترچههای آدرس شما را در این دستگاه قرار دهد.",
|
||||
"denied_title": "همگامسازی خاموش میماند",
|
||||
"failed_title": "روشن کردن همگامسازی ممکن نشد",
|
||||
"grant_access": "اعطای دسترسی",
|
||||
"interval_15m": "هر 15 دقیقه",
|
||||
"interval_1h": "هر ساعت",
|
||||
"interval_30m": "هر 30 دقیقه",
|
||||
"interval_6h": "هر 6 ساعت",
|
||||
"interval_desc": "اینکه Android هر چند وقت یک بار خودش همگامسازی کند. ویرایشها در Bulwark و تغییراتی که با push اعلام میشوند زودتر همگامسازی میشوند.",
|
||||
"interval_label": "فاصله همگامسازی",
|
||||
"interval_manual": "بدون همگامسازی منظم",
|
||||
"keep_syncing": "ادامه همگامسازی",
|
||||
"list_error_auth": "سرور ورود را نپذیرفت.",
|
||||
"list_error_network": "اتصال به سرور ممکن نشد.",
|
||||
"list_error_server": "سرور نتوانست فهرست را ارائه کند.",
|
||||
"list_error_signed_out": "در این دستگاه به این حساب وارد نشدهاید.",
|
||||
"master_off": "همگامسازی خودکار در این دستگاه خاموش است، بنابراین همگامسازی فقط وقتی انجام میشود که روی «همگامسازی اکنون» ضربه بزنید.",
|
||||
"new_contacts_default": "دفترچه آدرس پیشفرض",
|
||||
"new_contacts_desc": "دفترچه آدرس برای مخاطبینی که در برنامههای دیگر این دستگاه ایجاد میشوند.",
|
||||
"new_contacts_label": "محل ذخیره مخاطبین جدید",
|
||||
"no_books": "این حساب هیچ دفترچه آدرسی ندارد.",
|
||||
"no_calendars": "این حساب هیچ تقویمی برای همگامسازی ندارد.",
|
||||
"ok": "تأیید",
|
||||
"open_app_settings": "باز کردن تنظیمات برنامه",
|
||||
"own_collections": "متعلق به شما",
|
||||
"rationale_calendar": "Bulwark برای نمایش تقویمهای شما در برنامه تقویم به تقویمهای این دستگاه دسترسی نیاز دارد. فقط تقویمهای حساب خودش را تغییر میدهد.",
|
||||
"rationale_contacts": "Bulwark برای نمایش دفترچههای آدرس شما در برنامه مخاطبین به مخاطبین این دستگاه دسترسی نیاز دارد. فقط مخاطبین حساب خودش را تغییر میدهد.",
|
||||
"rationale_title_calendar": "اجازه دسترسی به تقویمها",
|
||||
"rationale_title_contacts": "اجازه دسترسی به مخاطبین",
|
||||
"read_only": "فقط خواندنی",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "برنامه تقویم",
|
||||
"reminder_question_message": "یادآور رویدادهای همگامسازیشده میتواند از برنامه تقویم این دستگاه یا از Bulwark بیاید. یکی را انتخاب کنید تا دو بار یادآوری دریافت نکنید. بعداً میتوانید آن را تغییر دهید.",
|
||||
"reminder_question_title": "چه کسی به شما یادآوری کند؟",
|
||||
"reminders_desc": "چه کسی رویدادهای همگامسازیشده را به شما یادآوری میکند. دیگری ساکت میماند تا دو بار یادآوری دریافت نکنید.",
|
||||
"reminders_label": "یادآورها",
|
||||
"reminders_nobody": "اعلانهای رویداد Bulwark خاموش است، بنابراین هیچ چیز رویدادهای همگامسازیشده را به شما یادآوری نمیکند.",
|
||||
"remove": "حذف",
|
||||
"remove_from_device": "حذف از این دستگاه",
|
||||
"remove_message": "مخاطبین و تقویمهای این حساب از این دستگاه حذف میشوند. تغییراتی که اینجا ایجاد شده و به سرور نرسیدهاند از دست میروند.",
|
||||
"remove_title": "از این دستگاه حذف شود؟",
|
||||
"review_deletions": "بررسی حذفها",
|
||||
"shared_by": "به اشتراک گذاشته شده توسط {name}",
|
||||
"sign_in_again": "ورود دوباره",
|
||||
"status_auth": "سرور دیگر ورود را نمیپذیرد. برای ادامه همگامسازی دوباره وارد شوید.",
|
||||
"status_cancelled": "آخرین همگامسازی زودتر متوقف شد. دفعه بعد ادامه مییابد.",
|
||||
"status_failed": "آخرین همگامسازی ناموفق بود: {message}",
|
||||
"status_failed_plain": "آخرین همگامسازی ناموفق بود.",
|
||||
"status_io": "اتصال به سرور ممکن نشد ({when}). Android دوباره تلاش میکند.",
|
||||
"status_item_errors": "{count, plural, one {# مورد همگامسازی نشد.} other {# مورد همگامسازی نشد.}}",
|
||||
"status_off": "خاموش",
|
||||
"status_paused": "در تنظیمات Android متوقف شده",
|
||||
"status_permission_calendar": "دسترسی به تقویمها خاموش شد، بنابراین چیزی همگامسازی نمیشود.",
|
||||
"status_permission_contacts": "دسترسی به مخاطبین خاموش شد، بنابراین چیزی همگامسازی نمیشود.",
|
||||
"status_removed": "در تنظیمات Android حذف شد. برای افزودن دوباره حساب، همگامسازی را روشن کنید.",
|
||||
"status_safety_abort": "متوقف شد: سرور چیزی برنگرداند، در حالی که این دستگاه هنوز موارد دارد. سرور را بررسی کنید، سپس دوباره همگامسازی کنید.",
|
||||
"status_synced": "آخرین همگامسازی: {when}",
|
||||
"status_syncing": "در حال همگامسازی…",
|
||||
"status_too_many_deletions": "موارد زیادی در این دستگاه حذف شدند. تا وقتی تأیید نکنید، در سرور حذف نمیشوند.",
|
||||
"status_unsupported_calendar": "سرور هیچ تقویمی برای این حساب ارائه نمیکند.",
|
||||
"status_unsupported_contacts": "سرور هیچ دفترچه آدرسی برای این حساب ارائه نمیکند.",
|
||||
"status_waiting": "در انتظار نخستین همگامسازی",
|
||||
"status_waiting_next": "در انتظار همگامسازی بعدی",
|
||||
"suspended_desc": "خارج شده. مخاطبین و تقویمهای این حساب همراه با تغییراتی که اینجا ایجاد شده در این دستگاه میمانند و پس از ورود دوباره همگامسازی میشوند.",
|
||||
"switch_label": "همگامسازی {account} با این دستگاه",
|
||||
"sync_now": "همگامسازی اکنون",
|
||||
"turn_off_anyway": "خاموش کردن",
|
||||
"turn_off_failed": "حذف دادههای همگامسازیشده از این دستگاه ممکن نشد. همگامسازی متوقف شده است؛ بعداً دوباره تلاش کنید.",
|
||||
"turn_off_failed_title": "خاموش کردن همگامسازی ممکن نشد",
|
||||
"turn_off_pending": "{count, plural, one {# تغییری که در این دستگاه ایجاد شده هنوز به سرور نرسیده است. اگر اکنون همگامسازی را خاموش کنید، از دست میرود.} other {# تغییری که در این دستگاه ایجاد شده هنوز به سرور نرسیده است. اگر اکنون همگامسازی را خاموش کنید، از دست میروند.}}",
|
||||
"turn_off_title": "به هر حال خاموش شود؟",
|
||||
"turn_off_unknown": "Bulwark نتوانست بررسی کند که آیا تغییراتی که در این دستگاه ایجاد شدهاند به سرور رسیدهاند یا نه. اگر اکنون همگامسازی را خاموش کنید، از دست میروند.",
|
||||
"turning_off": "در حال بارگذاری تغییرات و سپس حذف دادهها از این دستگاه…",
|
||||
"turning_on": "در حال راهاندازی…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "الگویی که هنگام ذخیره یا اشتراکگذاری پیوست استفاده میشود."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Jusqu'au {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Affichez vos calendriers dans l'application Agenda et dans les autres applications de cet appareil. Les modifications sont synchronisées dans les deux sens.",
|
||||
"device_sync_title": "Synchroniser avec cet appareil",
|
||||
"show_week_numbers_mobile_desc": "Afficher les numéros de semaine dans la vue mensuelle.",
|
||||
"time_zone": "Fuseau horaire",
|
||||
"time_zone_auto": "Fuseau horaire de l'appareil",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Étiquette",
|
||||
"remove_color": "Retirer l'étiquette"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Le serveur n'accepte plus la connexion de {account} : ses contacts et calendriers ne sont donc plus synchronisés sur cet appareil. Reconnectez-vous pour reprendre la synchronisation. Les modifications effectuées sur cet appareil sont conservées.",
|
||||
"auth_problem_title": "Reconnectez-vous pour continuer la synchronisation",
|
||||
"problem_channel": "Problèmes de synchronisation",
|
||||
"sign_out_anyway": "Se déconnecter",
|
||||
"sign_out_pending_message": "{count, plural, one {# modification effectuée sur cet appareil n'a pas atteint le serveur. Elle sera perdue si vous vous déconnectez maintenant.} other {# modifications effectuées sur cet appareil n'ont pas atteint le serveur. Elles seront perdues si vous vous déconnectez maintenant.}}",
|
||||
"sign_out_pending_title": "Se déconnecter quand même ?",
|
||||
"sign_out_unknown_message": "Les modifications effectuées sur cet appareil n'ont peut-être pas atteint le serveur. Elles seront perdues si vous vous déconnectez maintenant.",
|
||||
"signed_out_text": "{account} a été déconnecté : ses contacts et calendriers ne sont donc plus synchronisés sur cet appareil. Reconnectez-vous pour reprendre la synchronisation. Les modifications effectuées sur cet appareil sont conservées.",
|
||||
"signed_out_title": "Connectez-vous pour continuer la synchronisation",
|
||||
"signing_out": "Suppression des contacts et calendriers synchronisés de cet appareil…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Appareil photo",
|
||||
"attachment_reminder_body": "Votre message mentionne « {keyword} » mais aucun fichier n'est joint. Envoyer quand même ?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contact} other {# contacts}}",
|
||||
"default_book": "Carnet d'adresses par défaut",
|
||||
"description_mobile": "Préférences d'affichage et outils pour les carnets d'adresses.",
|
||||
"device_sync_desc": "Affichez vos carnets d'adresses dans l'application Contacts et dans les autres applications de cet appareil. Les modifications sont synchronisées dans les deux sens.",
|
||||
"device_sync_title": "Synchroniser avec cet appareil",
|
||||
"export_description_count": "{count, plural, one {Exporter # contact dans un seul fichier vCard.} other {Exporter # contacts dans un seul fichier vCard.}}",
|
||||
"export_empty": "Aucun contact à exporter",
|
||||
"import_description_mobile": "Importer des contacts depuis un fichier vCard (.vcf) avec détection des doublons.",
|
||||
"manage_description_mobile": "Créez, renommez et supprimez les carnets d'adresses qui organisent vos contacts."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Également synchronisé pour {accounts} : il apparaît donc deux fois sur cet appareil.",
|
||||
"android_settings": "Paramètres du compte Android",
|
||||
"blocked_calendar": "Android ne demande plus l'accès aux calendriers. Autorisez-le dans les paramètres de l'application, puis réactivez la synchronisation.",
|
||||
"blocked_contacts": "Android ne demande plus l'accès aux contacts. Autorisez-le dans les paramètres de l'application, puis réactivez la synchronisation.",
|
||||
"blocked_title": "L'accès est désactivé",
|
||||
"books_default": "Vos propres carnets d'adresses sont synchronisés ; les carnets partagés, uniquement si vous les choisissez.",
|
||||
"books_hint": "Vos propres carnets d'adresses sont synchronisés, sauf si vous les désactivez ; les carnets partagés, uniquement si vous les activez.",
|
||||
"books_label": "Carnets d'adresses",
|
||||
"calendars_default": "Vos propres calendriers sont synchronisés ; les calendriers partagés, uniquement si vous les choisissez.",
|
||||
"calendars_hint": "Vos propres calendriers sont synchronisés, sauf si vous les désactivez ; les calendriers partagés, uniquement si vous les activez. Les listes de tâches restent dans Bulwark.",
|
||||
"calendars_label": "Calendriers",
|
||||
"choose": "Choisir",
|
||||
"choose_books_title": "Carnets d'adresses à synchroniser",
|
||||
"choose_calendars_title": "Calendriers à synchroniser",
|
||||
"collections_count": "Synchronisation : {selected} sur {total}",
|
||||
"continue": "Continuer",
|
||||
"deletions_delete": "Supprimer sur le serveur",
|
||||
"deletions_message": "De nombreux éléments ont été supprimés d'un coup sur cet appareil ; ils n'ont donc pas été supprimés sur le serveur. Supprimez-les aussi sur le serveur ou restaurez-les sur cet appareil.",
|
||||
"deletions_restore": "Restaurer",
|
||||
"deletions_title": "Les supprimer aussi sur le serveur ?",
|
||||
"denied_calendar": "Sans accès aux calendriers, Bulwark ne peut pas placer vos calendriers sur cet appareil.",
|
||||
"denied_contacts": "Sans accès aux contacts, Bulwark ne peut pas placer vos carnets d'adresses sur cet appareil.",
|
||||
"denied_title": "La synchronisation reste désactivée",
|
||||
"failed_title": "Impossible d'activer la synchronisation",
|
||||
"grant_access": "Autoriser l'accès",
|
||||
"interval_15m": "Toutes les 15 minutes",
|
||||
"interval_1h": "Toutes les heures",
|
||||
"interval_30m": "Toutes les 30 minutes",
|
||||
"interval_6h": "Toutes les 6 heures",
|
||||
"interval_desc": "Fréquence à laquelle Android synchronise de lui-même. Les modifications faites dans Bulwark et celles signalées par push sont synchronisées plus tôt.",
|
||||
"interval_label": "Fréquence de synchronisation",
|
||||
"interval_manual": "Pas de synchronisation régulière",
|
||||
"keep_syncing": "Continuer à synchroniser",
|
||||
"list_error_auth": "Le serveur n'a pas accepté la connexion.",
|
||||
"list_error_network": "Impossible de joindre le serveur.",
|
||||
"list_error_server": "Le serveur n'a pas pu en fournir la liste.",
|
||||
"list_error_signed_out": "Ce compte n'est pas connecté sur cet appareil.",
|
||||
"master_off": "La synchronisation automatique est désactivée sur cet appareil : la synchronisation n'a lieu que lorsque vous appuyez sur « Synchroniser maintenant ».",
|
||||
"new_contacts_default": "Carnet d'adresses par défaut",
|
||||
"new_contacts_desc": "Le carnet d'adresses des contacts créés dans d'autres applications de cet appareil.",
|
||||
"new_contacts_label": "Enregistrer les nouveaux contacts dans",
|
||||
"no_books": "Ce compte n'a aucun carnet d'adresses.",
|
||||
"no_calendars": "Ce compte n'a aucun calendrier à synchroniser.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Ouvrir les paramètres",
|
||||
"own_collections": "Les vôtres",
|
||||
"rationale_calendar": "Pour afficher vos calendriers dans l'application Agenda, Bulwark doit accéder aux calendriers de cet appareil. Il ne modifie que les calendriers de son propre compte.",
|
||||
"rationale_contacts": "Pour afficher vos carnets d'adresses dans l'application Contacts, Bulwark doit accéder aux contacts de cet appareil. Il ne modifie que les contacts de son propre compte.",
|
||||
"rationale_title_calendar": "Autoriser l'accès aux calendriers",
|
||||
"rationale_title_contacts": "Autoriser l'accès aux contacts",
|
||||
"read_only": "Lecture seule",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Application Agenda",
|
||||
"reminder_question_message": "Les événements synchronisés peuvent vous être rappelés par l'application d'agenda de cet appareil ou par Bulwark. Choisissez l'un des deux pour ne pas recevoir deux rappels. Vous pourrez modifier ce choix plus tard.",
|
||||
"reminder_question_title": "Qui doit vous envoyer les rappels ?",
|
||||
"reminders_desc": "Qui vous rappelle les événements synchronisés. L'autre reste silencieux pour que vous ne receviez pas deux rappels.",
|
||||
"reminders_label": "Rappels",
|
||||
"reminders_nobody": "Les notifications d'événements de Bulwark sont désactivées : rien ne vous rappelle donc les événements synchronisés.",
|
||||
"remove": "Supprimer",
|
||||
"remove_from_device": "Supprimer de cet appareil",
|
||||
"remove_message": "Les contacts et calendriers du compte sont supprimés de cet appareil. Les modifications faites ici qui n'ont pas atteint le serveur sont perdues.",
|
||||
"remove_title": "Supprimer de cet appareil ?",
|
||||
"review_deletions": "Vérifier les suppressions",
|
||||
"shared_by": "Partagé par {name}",
|
||||
"sign_in_again": "Se reconnecter",
|
||||
"status_auth": "Le serveur n'accepte plus la connexion. Reconnectez-vous pour continuer la synchronisation.",
|
||||
"status_cancelled": "La dernière synchronisation s'est arrêtée avant la fin. Elle reprendra la prochaine fois.",
|
||||
"status_failed": "Échec de la dernière synchronisation : {message}",
|
||||
"status_failed_plain": "Échec de la dernière synchronisation.",
|
||||
"status_io": "Le serveur était injoignable {when}. Android va réessayer.",
|
||||
"status_item_errors": "{count, plural, one {# élément n'a pas pu être synchronisé.} other {# éléments n'ont pas pu être synchronisés.}}",
|
||||
"status_off": "Désactivée",
|
||||
"status_paused": "En pause dans les paramètres Android",
|
||||
"status_permission_calendar": "L'accès aux calendriers a été désactivé : rien n'est synchronisé.",
|
||||
"status_permission_contacts": "L'accès aux contacts a été désactivé : rien n'est synchronisé.",
|
||||
"status_removed": "Supprimé dans les paramètres Android. Activez la synchronisation pour ajouter à nouveau le compte.",
|
||||
"status_safety_abort": "Arrêtée : le serveur n'a rien renvoyé alors que cet appareil contient encore des éléments. Vérifiez le serveur, puis relancez la synchronisation.",
|
||||
"status_synced": "Synchronisé {when}",
|
||||
"status_syncing": "Synchronisation…",
|
||||
"status_too_many_deletions": "De nombreux éléments ont été supprimés sur cet appareil. Ils ne seront pas supprimés sur le serveur tant que vous n'aurez pas confirmé.",
|
||||
"status_unsupported_calendar": "Le serveur ne propose aucun calendrier pour ce compte.",
|
||||
"status_unsupported_contacts": "Le serveur ne propose aucun carnet d'adresses pour ce compte.",
|
||||
"status_waiting": "En attente de la première synchronisation",
|
||||
"status_waiting_next": "En attente de la prochaine synchronisation",
|
||||
"suspended_desc": "Déconnecté. Ses contacts et calendriers restent sur cet appareil, avec les modifications faites ici, et seront de nouveau synchronisés dès que vous vous connecterez.",
|
||||
"switch_label": "Synchroniser {account} avec cet appareil",
|
||||
"sync_now": "Synchroniser maintenant",
|
||||
"turn_off_anyway": "Désactiver",
|
||||
"turn_off_failed": "Les données synchronisées n'ont pas pu être supprimées de cet appareil. La synchronisation est en pause ; réessayez plus tard.",
|
||||
"turn_off_failed_title": "Impossible de désactiver la synchronisation",
|
||||
"turn_off_pending": "{count, plural, one {# modification effectuée sur cet appareil n'a pas encore atteint le serveur. Elle sera perdue si vous désactivez la synchronisation maintenant.} other {# modifications effectuées sur cet appareil n'ont pas encore atteint le serveur. Elles seront perdues si vous désactivez la synchronisation maintenant.}}",
|
||||
"turn_off_title": "Désactiver quand même ?",
|
||||
"turn_off_unknown": "Bulwark n'a pas pu vérifier si les modifications effectuées sur cet appareil ont atteint le serveur. Elles seront perdues si vous désactivez la synchronisation maintenant.",
|
||||
"turning_off": "Envoi des modifications, puis suppression des données de cet appareil…",
|
||||
"turning_on": "Configuration…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Modèle utilisé pour enregistrer ou partager une pièce jointe."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "עד {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "הצג את לוחות השנה שלך באפליקציית היומן ובאפליקציות אחרות במכשיר הזה. השינויים מסתנכרנים בשני הכיוונים.",
|
||||
"device_sync_title": "סנכרון למכשיר הזה",
|
||||
"show_week_numbers_mobile_desc": "הצג מספרי שבועות בתצוגת החודש.",
|
||||
"time_zone": "אזור זמן",
|
||||
"time_zone_auto": "אזור הזמן של המכשיר",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "תג",
|
||||
"remove_color": "הסר תג"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "השרת כבר לא מקבל את ההתחברות של {account}, ולכן אנשי הקשר ולוחות השנה שלו במכשיר הזה הפסיקו להסתנכרן. התחבר מחדש כדי לחדש את הסנכרון. שינויים שבוצעו במכשיר הזה נשמרים.",
|
||||
"auth_problem_title": "התחבר מחדש כדי להמשיך לסנכרן",
|
||||
"problem_channel": "בעיות סנכרון",
|
||||
"sign_out_anyway": "התנתק",
|
||||
"sign_out_pending_message": "{count, plural, one {שינוי אחד שבוצע במכשיר הזה לא הגיע לשרת. הוא יאבד אם תתנתק עכשיו.} two {שני שינויים שבוצעו במכשיר הזה לא הגיעו לשרת. הם יאבדו אם תתנתק עכשיו.} other {# שינויים שבוצעו במכשיר הזה לא הגיעו לשרת. הם יאבדו אם תתנתק עכשיו.}}",
|
||||
"sign_out_pending_title": "להתנתק בכל זאת?",
|
||||
"sign_out_unknown_message": "ייתכן ששינויים שבוצעו במכשיר הזה לא הגיעו לשרת. הם יאבדו אם תתנתק עכשיו.",
|
||||
"signed_out_text": "בוצעה התנתקות מ-{account}, ולכן אנשי הקשר ולוחות השנה שלו במכשיר הזה כבר לא מסתנכרנים. התחבר מחדש כדי לחדש את הסנכרון. שינויים שבוצעו במכשיר הזה נשמרים.",
|
||||
"signed_out_title": "התחבר כדי להמשיך לסנכרן",
|
||||
"signing_out": "מסיר מהמכשיר הזה את אנשי הקשר ולוחות השנה המסונכרנים…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "מצלמה",
|
||||
"attachment_reminder_body": "ההודעה שלך מזכירה \"{keyword}\" אבל לא צורף קובץ. לשלוח בכל זאת?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {איש קשר אחד} two {שני אנשי קשר} other {# אנשי קשר}}",
|
||||
"default_book": "ספר כתובות ברירת מחדל",
|
||||
"description_mobile": "העדפות תצוגה וכלים לספרי כתובות.",
|
||||
"device_sync_desc": "הצג את ספרי הכתובות שלך באפליקציית אנשי הקשר ובאפליקציות אחרות במכשיר הזה. השינויים מסתנכרנים בשני הכיוונים.",
|
||||
"device_sync_title": "סנכרון למכשיר הזה",
|
||||
"export_description_count": "{count, plural, one {ייצוא איש קשר אחד כקובץ vCard יחיד.} two {ייצוא שני אנשי קשר כקובץ vCard יחיד.} other {ייצוא # אנשי קשר כקובץ vCard יחיד.}}",
|
||||
"export_empty": "אין אנשי קשר לייצוא",
|
||||
"import_description_mobile": "ייבוא אנשי קשר מקובץ vCard (.vcf) עם זיהוי כפילויות.",
|
||||
"manage_description_mobile": "צור, שנה שם והסר את ספרי הכתובות שמארגנים את אנשי הקשר שלך."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "מסתנכרן גם עבור {accounts}, ולכן מופיע פעמיים במכשיר הזה.",
|
||||
"android_settings": "הגדרות החשבון ב-Android",
|
||||
"blocked_calendar": "Android כבר לא מבקש גישה ללוחות השנה. אפשר את הגישה בהגדרות האפליקציה ואז הפעל שוב את הסנכרון.",
|
||||
"blocked_contacts": "Android כבר לא מבקש גישה לאנשי הקשר. אפשר את הגישה בהגדרות האפליקציה ואז הפעל שוב את הסנכרון.",
|
||||
"blocked_title": "הגישה כבויה",
|
||||
"books_default": "ספרי הכתובות שלך מסתנכרנים; ספרים משותפים רק אם תבחר בהם.",
|
||||
"books_hint": "ספרי הכתובות שלך מסתנכרנים אלא אם תכבה אותם; ספרים משותפים רק אם תפעיל אותם.",
|
||||
"books_label": "ספרי כתובות",
|
||||
"calendars_default": "לוחות השנה שלך מסתנכרנים; לוחות משותפים רק אם תבחר בהם.",
|
||||
"calendars_hint": "לוחות השנה שלך מסתנכרנים אלא אם תכבה אותם; לוחות משותפים רק אם תפעיל אותם. רשימות המשימות נשארות ב-Bulwark.",
|
||||
"calendars_label": "לוחות שנה",
|
||||
"choose": "בחירה",
|
||||
"choose_books_title": "ספרי כתובות לסנכרון",
|
||||
"choose_calendars_title": "לוחות שנה לסנכרון",
|
||||
"collections_count": "מסתנכרנים: {selected} מתוך {total}",
|
||||
"continue": "המשך",
|
||||
"deletions_delete": "מחיקה בשרת",
|
||||
"deletions_message": "פריטים רבים נמחקו בבת אחת במכשיר הזה, ולכן הם לא נמחקו בשרת. מחק אותם גם שם או החזר אותם למכשיר הזה.",
|
||||
"deletions_restore": "החזרה",
|
||||
"deletions_title": "למחוק אותם גם בשרת?",
|
||||
"denied_calendar": "ללא גישה ללוחות השנה, Bulwark לא יכולה להעביר את לוחות השנה שלך למכשיר הזה.",
|
||||
"denied_contacts": "ללא גישה לאנשי הקשר, Bulwark לא יכולה להעביר את ספרי הכתובות שלך למכשיר הזה.",
|
||||
"denied_title": "הסנכרון נשאר כבוי",
|
||||
"failed_title": "לא ניתן היה להפעיל את הסנכרון",
|
||||
"grant_access": "מתן גישה",
|
||||
"interval_15m": "כל 15 דקות",
|
||||
"interval_1h": "כל שעה",
|
||||
"interval_30m": "כל 30 דקות",
|
||||
"interval_6h": "כל 6 שעות",
|
||||
"interval_desc": "באיזו תדירות Android מסנכרן מעצמו. עריכות ב-Bulwark ושינויים שמתקבלים ב-Push מסתנכרנים מוקדם יותר.",
|
||||
"interval_label": "תדירות סנכרון",
|
||||
"interval_manual": "ללא סנכרון קבוע",
|
||||
"keep_syncing": "המשך לסנכרן",
|
||||
"list_error_auth": "השרת לא קיבל את ההתחברות.",
|
||||
"list_error_network": "לא ניתן היה להתחבר לשרת.",
|
||||
"list_error_server": "השרת לא הצליח להציג את הרשימה.",
|
||||
"list_error_signed_out": "החשבון הזה לא מחובר במכשיר הזה.",
|
||||
"master_off": "הסנכרון האוטומטי כבוי במכשיר הזה, ולכן הסנכרון מתבצע רק כשאתה מקיש על \"סנכרן עכשיו\".",
|
||||
"new_contacts_default": "ספר כתובות ברירת מחדל",
|
||||
"new_contacts_desc": "ספר הכתובות לאנשי קשר שנוצרים באפליקציות אחרות במכשיר הזה.",
|
||||
"new_contacts_label": "יעד לאנשי קשר חדשים",
|
||||
"no_books": "לחשבון הזה אין ספרי כתובות.",
|
||||
"no_calendars": "לחשבון הזה אין לוחות שנה לסנכרון.",
|
||||
"ok": "אישור",
|
||||
"open_app_settings": "פתיחת הגדרות האפליקציה",
|
||||
"own_collections": "שלך",
|
||||
"rationale_calendar": "כדי להציג את לוחות השנה שלך באפליקציית היומן, Bulwark צריכה גישה ללוחות השנה במכשיר הזה. היא משנה רק את לוחות השנה של החשבון שלה.",
|
||||
"rationale_contacts": "כדי להציג את ספרי הכתובות שלך באפליקציית אנשי הקשר, Bulwark צריכה גישה לאנשי הקשר במכשיר הזה. היא משנה רק את אנשי הקשר של החשבון שלה.",
|
||||
"rationale_title_calendar": "מתן גישה ללוחות השנה",
|
||||
"rationale_title_contacts": "מתן גישה לאנשי הקשר",
|
||||
"read_only": "קריאה בלבד",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "אפליקציית היומן",
|
||||
"reminder_question_message": "תזכורות לאירועים מסונכרנים יכולות להגיע מאפליקציית היומן במכשיר הזה או מ-Bulwark. בחר אחת מהן כדי שלא תקבל תזכורות כפולות. אפשר לשנות זאת מאוחר יותר.",
|
||||
"reminder_question_title": "מי ישלח לך תזכורות?",
|
||||
"reminders_desc": "מי מזכיר לך על אירועים מסונכרנים. השני שותק, כך שלא תקבל תזכורות כפולות.",
|
||||
"reminders_label": "תזכורות",
|
||||
"reminders_nobody": "התראות האירועים של Bulwark כבויות, ולכן שום דבר לא יזכיר לך על אירועים מסונכרנים.",
|
||||
"remove": "הסרה",
|
||||
"remove_from_device": "הסרה מהמכשיר הזה",
|
||||
"remove_message": "אנשי הקשר ולוחות השנה של החשבון יוסרו מהמכשיר הזה. שינויים שבוצעו כאן ועדיין לא הגיעו לשרת יאבדו.",
|
||||
"remove_title": "להסיר מהמכשיר הזה?",
|
||||
"review_deletions": "בדיקת המחיקות",
|
||||
"shared_by": "משותף על ידי {name}",
|
||||
"sign_in_again": "התחברות מחדש",
|
||||
"status_auth": "השרת כבר לא מקבל את ההתחברות. התחבר מחדש כדי להמשיך לסנכרן.",
|
||||
"status_cancelled": "הסנכרון האחרון נעצר מוקדם. הוא ימשיך בפעם הבאה.",
|
||||
"status_failed": "הסנכרון האחרון נכשל: {message}",
|
||||
"status_failed_plain": "הסנכרון האחרון נכשל.",
|
||||
"status_io": "לא ניתן היה להתחבר לשרת {when}. Android ינסה שוב.",
|
||||
"status_item_errors": "{count, plural, one {לא ניתן היה לסנכרן פריט אחד.} two {לא ניתן היה לסנכרן שני פריטים.} other {לא ניתן היה לסנכרן # פריטים.}}",
|
||||
"status_off": "כבוי",
|
||||
"status_paused": "מושהה בהגדרות Android",
|
||||
"status_permission_calendar": "הגישה ללוחות השנה כובתה, ולכן שום דבר לא מסתנכרן.",
|
||||
"status_permission_contacts": "הגישה לאנשי הקשר כובתה, ולכן שום דבר לא מסתנכרן.",
|
||||
"status_removed": "הוסר בהגדרות Android. הפעל את הסנכרון כדי להוסיף את החשבון מחדש.",
|
||||
"status_safety_abort": "נעצר: השרת לא החזיר דבר, אף שבמכשיר הזה עדיין יש פריטים. בדוק את השרת ואז סנכרן שוב.",
|
||||
"status_synced": "סונכרן {when}",
|
||||
"status_syncing": "מסנכרן…",
|
||||
"status_too_many_deletions": "פריטים רבים נמחקו במכשיר הזה. הם לא יימחקו בשרת עד שתאשר.",
|
||||
"status_unsupported_calendar": "השרת לא מציע לוחות שנה לחשבון הזה.",
|
||||
"status_unsupported_contacts": "השרת לא מציע ספרי כתובות לחשבון הזה.",
|
||||
"status_waiting": "ממתין לסנכרון הראשון",
|
||||
"status_waiting_next": "ממתין לסנכרון הבא",
|
||||
"suspended_desc": "מנותק. אנשי הקשר ולוחות השנה שלו נשארים במכשיר הזה, עם השינויים שבוצעו כאן, ויסתנכרנו שוב אחרי שתתחבר.",
|
||||
"switch_label": "סנכרון {account} למכשיר הזה",
|
||||
"sync_now": "סנכרן עכשיו",
|
||||
"turn_off_anyway": "כיבוי",
|
||||
"turn_off_failed": "לא ניתן היה להסיר את הנתונים המסונכרנים מהמכשיר הזה. הסנכרון מושהה; נסה שוב מאוחר יותר.",
|
||||
"turn_off_failed_title": "לא ניתן היה לכבות את הסנכרון",
|
||||
"turn_off_pending": "{count, plural, one {שינוי אחד שבוצע במכשיר הזה עדיין לא הגיע לשרת. הוא יאבד אם תכבה את הסנכרון עכשיו.} two {שני שינויים שבוצעו במכשיר הזה עדיין לא הגיעו לשרת. הם יאבדו אם תכבה את הסנכרון עכשיו.} other {# שינויים שבוצעו במכשיר הזה עדיין לא הגיעו לשרת. הם יאבדו אם תכבה את הסנכרון עכשיו.}}",
|
||||
"turn_off_title": "לכבות בכל זאת?",
|
||||
"turn_off_unknown": "Bulwark לא הצליחה לבדוק אם השינויים שבוצעו במכשיר הזה הגיעו לשרת. הם יאבדו אם תכבה את הסנכרון עכשיו.",
|
||||
"turning_off": "מעלה שינויים ואז מסיר את הנתונים מהמכשיר הזה…",
|
||||
"turning_on": "מגדיר…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "התבנית שמשמשת בעת שמירה או שיתוף של קובץ מצורף."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Eddig: {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "A naptáraid megjelennek a Naptár alkalmazásban és az eszköz más alkalmazásaiban. A módosítások mindkét irányban szinkronizálódnak.",
|
||||
"device_sync_title": "Szinkronizálás ezzel az eszközzel",
|
||||
"show_week_numbers_mobile_desc": "Hétszámok megjelenítése a havi nézetben.",
|
||||
"time_zone": "Időzóna",
|
||||
"time_zone_auto": "Eszköz időzónája",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Címke",
|
||||
"remove_color": "Címke eltávolítása"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "A szerver már nem fogadja el ennek a fióknak a bejelentkezését: {account}. Emiatt a fiók névjegyei és naptárai ezen az eszközön már nem szinkronizálódnak. A folytatáshoz jelentkezz be újra. Az ezen az eszközön végzett módosítások megmaradnak.",
|
||||
"auth_problem_title": "Jelentkezz be újra a szinkronizálás folytatásához",
|
||||
"problem_channel": "Szinkronizálási problémák",
|
||||
"sign_out_anyway": "Kijelentkezés",
|
||||
"sign_out_pending_message": "{count, plural, one {Ezen az eszközön # módosítás nem jutott el a szerverre. Elvész, ha most kijelentkezel.} other {Ezen az eszközön # módosítás nem jutott el a szerverre. Elvesznek, ha most kijelentkezel.}}",
|
||||
"sign_out_pending_title": "Mindenképp kijelentkezel?",
|
||||
"sign_out_unknown_message": "Előfordulhat, hogy az ezen az eszközön végzett módosítások nem jutottak el a szerverre. Elvesznek, ha most kijelentkezel.",
|
||||
"signed_out_text": "Ez a fiók már nincs bejelentkezve: {account}. Emiatt a fiók névjegyei és naptárai ezen az eszközön már nem szinkronizálódnak. A folytatáshoz jelentkezz be újra. Az ezen az eszközön végzett módosítások megmaradnak.",
|
||||
"signed_out_title": "Jelentkezz be a szinkronizálás folytatásához",
|
||||
"signing_out": "A szinkronizált névjegyek és naptárak eltávolítása erről az eszközről…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "Az üzeneted említi a(z) „{keyword}” kifejezést, de nincs fájl csatolva. Elküldöd így is?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# névjegy} other {# névjegy}}",
|
||||
"default_book": "Alapértelmezett címjegyzék",
|
||||
"description_mobile": "Megjelenítési beállítások és címjegyzék-eszközök.",
|
||||
"device_sync_desc": "A címjegyzékeid megjelennek a Névjegyek alkalmazásban és az eszköz más alkalmazásaiban. A módosítások mindkét irányban szinkronizálódnak.",
|
||||
"device_sync_title": "Szinkronizálás ezzel az eszközzel",
|
||||
"export_description_count": "{count, plural, one {# névjegy exportálása egyetlen vCard-fájlba.} other {# névjegy exportálása egyetlen vCard-fájlba.}}",
|
||||
"export_empty": "Nincs exportálható névjegy",
|
||||
"import_description_mobile": "Névjegyek importálása vCard (.vcf) fájlból, a duplikátumok felismerésével.",
|
||||
"manage_description_mobile": "Itt hozhatod létre, nevezheted át és távolíthatod el a névjegyeidet rendező címjegyzékeket."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Más fiókhoz is szinkronizálódik ({accounts}), ezért kétszer jelenik meg ezen az eszközön.",
|
||||
"android_settings": "Android-fiókbeállítások",
|
||||
"blocked_calendar": "Az Android már nem kér hozzáférést a naptárakhoz. Engedélyezd az alkalmazás beállításaiban, majd kapcsold be újra a szinkronizálást.",
|
||||
"blocked_contacts": "Az Android már nem kér hozzáférést a névjegyekhez. Engedélyezd az alkalmazás beállításaiban, majd kapcsold be újra a szinkronizálást.",
|
||||
"blocked_title": "A hozzáférés ki van kapcsolva",
|
||||
"books_default": "A saját címjegyzékeid szinkronizálódnak, a megosztottak csak akkor, ha kiválasztod őket.",
|
||||
"books_hint": "A saját címjegyzékeid szinkronizálódnak, hacsak ki nem kapcsolod őket; a megosztottak csak akkor, ha bekapcsolod őket.",
|
||||
"books_label": "Címjegyzékek",
|
||||
"calendars_default": "A saját naptáraid szinkronizálódnak, a megosztottak csak akkor, ha kiválasztod őket.",
|
||||
"calendars_hint": "A saját naptáraid szinkronizálódnak, hacsak ki nem kapcsolod őket; a megosztottak csak akkor, ha bekapcsolod őket. A feladatlisták a Bulwarkban maradnak.",
|
||||
"calendars_label": "Naptárak",
|
||||
"choose": "Kiválasztás",
|
||||
"choose_books_title": "Szinkronizálandó címjegyzékek",
|
||||
"choose_calendars_title": "Szinkronizálandó naptárak",
|
||||
"collections_count": "Szinkronizálva: {selected}/{total}",
|
||||
"continue": "Tovább",
|
||||
"deletions_delete": "Törlés a szerveren",
|
||||
"deletions_message": "Ezen az eszközön egyszerre sok elemet töröltek, ezért a szerveren nem lettek törölve. Töröld őket ott is, vagy állítsd vissza őket ezen az eszközön.",
|
||||
"deletions_restore": "Visszaállítás",
|
||||
"deletions_title": "A szerveren is törlöd őket?",
|
||||
"denied_calendar": "A naptárakhoz való hozzáférés nélkül a Bulwark nem tudja a naptáraidat erre az eszközre hozni.",
|
||||
"denied_contacts": "A névjegyekhez való hozzáférés nélkül a Bulwark nem tudja a címjegyzékeidet erre az eszközre hozni.",
|
||||
"denied_title": "A szinkronizálás kikapcsolva marad",
|
||||
"failed_title": "Nem sikerült bekapcsolni a szinkronizálást",
|
||||
"grant_access": "Hozzáférés megadása",
|
||||
"interval_15m": "15 percenként",
|
||||
"interval_1h": "Óránként",
|
||||
"interval_30m": "30 percenként",
|
||||
"interval_6h": "6 óránként",
|
||||
"interval_desc": "Milyen gyakran szinkronizál az Android magától. A Bulwarkban végzett szerkesztések és a push útján jelzett módosítások hamarabb szinkronizálódnak.",
|
||||
"interval_label": "Szinkronizálási időköz",
|
||||
"interval_manual": "Nincs rendszeres szinkronizálás",
|
||||
"keep_syncing": "Szinkronizálás folytatása",
|
||||
"list_error_auth": "A szerver nem fogadta el a bejelentkezést.",
|
||||
"list_error_network": "Nem sikerült elérni a szervert.",
|
||||
"list_error_server": "A szerver nem tudta visszaadni a listát.",
|
||||
"list_error_signed_out": "Ez a fiók nincs bejelentkezve ezen az eszközön.",
|
||||
"master_off": "Az automatikus szinkronizálás ki van kapcsolva ezen az eszközön, ezért szinkronizálás csak akkor történik, ha a „Szinkronizálás most” gombra koppintasz.",
|
||||
"new_contacts_default": "Alapértelmezett címjegyzék",
|
||||
"new_contacts_desc": "A címjegyzék az eszköz más alkalmazásaiban létrehozott névjegyekhez.",
|
||||
"new_contacts_label": "Új névjegyek helye",
|
||||
"no_books": "Ennek a fióknak nincs címjegyzéke.",
|
||||
"no_calendars": "Ennek a fióknak nincs szinkronizálható naptára.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Beállítások megnyitása",
|
||||
"own_collections": "Saját",
|
||||
"rationale_calendar": "Ahhoz, hogy a naptáraid megjelenjenek a Naptár alkalmazásban, a Bulwarknak hozzá kell férnie az eszköz naptáraihoz. Csak a saját fiókja naptárait módosítja.",
|
||||
"rationale_contacts": "Ahhoz, hogy a címjegyzékeid megjelenjenek a Névjegyek alkalmazásban, a Bulwarknak hozzá kell férnie az eszköz névjegyeihez. Csak a saját fiókja névjegyeit módosítja.",
|
||||
"rationale_title_calendar": "Hozzáférés engedélyezése a naptárakhoz",
|
||||
"rationale_title_contacts": "Hozzáférés engedélyezése a névjegyekhez",
|
||||
"read_only": "Csak olvasható",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Naptár alkalmazás",
|
||||
"reminder_question_message": "A szinkronizált eseményekre az eszköz naptáralkalmazása vagy a Bulwark emlékeztethet. Válassz egyet, hogy ne kapj kétszer emlékeztetőt. Később módosíthatod.",
|
||||
"reminder_question_title": "Ki emlékeztessen?",
|
||||
"reminders_desc": "Ki emlékeztet a szinkronizált eseményekre. A másik csendben marad, így nem kapsz kétszer emlékeztetőt.",
|
||||
"reminders_label": "Emlékeztetők",
|
||||
"reminders_nobody": "A Bulwark eseményértesítései ki vannak kapcsolva, így semmi sem emlékeztet a szinkronizált eseményekre.",
|
||||
"remove": "Eltávolítás",
|
||||
"remove_from_device": "Eltávolítás erről az eszközről",
|
||||
"remove_message": "A fiók névjegyei és naptárai törlődnek erről az eszközről. Az itt végzett, a szerverre még el nem jutott módosítások elvesznek.",
|
||||
"remove_title": "Eltávolítod erről az eszközről?",
|
||||
"review_deletions": "Törlések áttekintése",
|
||||
"shared_by": "Megosztotta: {name}",
|
||||
"sign_in_again": "Újbóli bejelentkezés",
|
||||
"status_auth": "A szerver már nem fogadja el a bejelentkezést. Jelentkezz be újra a szinkronizálás folytatásához.",
|
||||
"status_cancelled": "A legutóbbi szinkronizálás idő előtt leállt. Legközelebb folytatódik.",
|
||||
"status_failed": "A legutóbbi szinkronizálás sikertelen: {message}",
|
||||
"status_failed_plain": "A legutóbbi szinkronizálás sikertelen.",
|
||||
"status_io": "A szerver nem volt elérhető ({when}). Az Android újra megpróbálja.",
|
||||
"status_item_errors": "{count, plural, one {# elemet nem sikerült szinkronizálni.} other {# elemet nem sikerült szinkronizálni.}}",
|
||||
"status_off": "Kikapcsolva",
|
||||
"status_paused": "Szüneteltetve az Android-beállításokban",
|
||||
"status_permission_calendar": "A naptárakhoz való hozzáférés ki lett kapcsolva, ezért semmi sem szinkronizálódik.",
|
||||
"status_permission_contacts": "A névjegyekhez való hozzáférés ki lett kapcsolva, ezért semmi sem szinkronizálódik.",
|
||||
"status_removed": "Eltávolítva az Android-beállításokban. Kapcsold be a szinkronizálást a fiók újbóli hozzáadásához.",
|
||||
"status_safety_abort": "Leállítva: a szerver semmit sem adott vissza, pedig ezen az eszközön még vannak elemek. Ellenőrizd a szervert, majd szinkronizálj újra.",
|
||||
"status_synced": "Szinkronizálva: {when}",
|
||||
"status_syncing": "Szinkronizálás…",
|
||||
"status_too_many_deletions": "Ezen az eszközön sok elemet töröltek. A szerveren csak akkor törlődnek, ha megerősíted.",
|
||||
"status_unsupported_calendar": "A szerver nem kínál naptárakat ehhez a fiókhoz.",
|
||||
"status_unsupported_contacts": "A szerver nem kínál címjegyzékeket ehhez a fiókhoz.",
|
||||
"status_waiting": "Várakozás az első szinkronizálásra",
|
||||
"status_waiting_next": "Várakozás a következő szinkronizálásra",
|
||||
"suspended_desc": "Kijelentkezve. A fiók névjegyei és naptárai az itt végzett módosításokkal együtt ezen az eszközön maradnak, és bejelentkezés után újra szinkronizálódnak.",
|
||||
"switch_label": "{account} szinkronizálása ezzel az eszközzel",
|
||||
"sync_now": "Szinkronizálás most",
|
||||
"turn_off_anyway": "Kikapcsolás",
|
||||
"turn_off_failed": "A szinkronizált adatokat nem sikerült eltávolítani erről az eszközről. A szinkronizálás szünetel; próbáld újra később.",
|
||||
"turn_off_failed_title": "Nem sikerült kikapcsolni a szinkronizálást",
|
||||
"turn_off_pending": "{count, plural, one {Ezen az eszközön # módosítás még nem jutott el a szerverre. Elvész, ha most kikapcsolod a szinkronizálást.} other {Ezen az eszközön # módosítás még nem jutott el a szerverre. Elvesznek, ha most kikapcsolod a szinkronizálást.}}",
|
||||
"turn_off_title": "Mindenképp kikapcsolod?",
|
||||
"turn_off_unknown": "A Bulwark nem tudta ellenőrizni, hogy az ezen az eszközön végzett módosítások eljutottak-e a szerverre. Elvesznek, ha most kikapcsolod a szinkronizálást.",
|
||||
"turning_off": "Módosítások feltöltése, majd az adatok eltávolítása erről az eszközről…",
|
||||
"turning_on": "Beállítás folyamatban…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Csatolmány mentésekor vagy megosztásakor használt sablon."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Fino al {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Mostra i tuoi calendari nell'app Calendario e nelle altre app di questo dispositivo. Le modifiche vengono sincronizzate in entrambe le direzioni.",
|
||||
"device_sync_title": "Sincronizza con questo dispositivo",
|
||||
"show_week_numbers_mobile_desc": "Mostra i numeri di settimana nella vista mensile.",
|
||||
"time_zone": "Fuso orario",
|
||||
"time_zone_auto": "Fuso orario del dispositivo",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etichetta",
|
||||
"remove_color": "Rimuovi etichetta"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Il server non accetta più l'accesso di {account}, quindi i suoi contatti e calendari su questo dispositivo non vengono più sincronizzati. Accedi di nuovo per riprendere. Le modifiche fatte su questo dispositivo vengono mantenute.",
|
||||
"auth_problem_title": "Accedi di nuovo per continuare a sincronizzare",
|
||||
"problem_channel": "Problemi di sincronizzazione",
|
||||
"sign_out_anyway": "Esci",
|
||||
"sign_out_pending_message": "{count, plural, one {# modifica fatta su questo dispositivo non ha raggiunto il server. Andrà persa se esci ora.} other {# modifiche fatte su questo dispositivo non hanno raggiunto il server. Andranno perse se esci ora.}}",
|
||||
"sign_out_pending_title": "Uscire comunque?",
|
||||
"sign_out_unknown_message": "Le modifiche fatte su questo dispositivo potrebbero non aver raggiunto il server. Andranno perse se esci ora.",
|
||||
"signed_out_text": "{account} è stato disconnesso, quindi i suoi contatti e calendari su questo dispositivo non vengono più sincronizzati. Accedi di nuovo per riprendere. Le modifiche fatte su questo dispositivo vengono mantenute.",
|
||||
"signed_out_title": "Accedi per continuare a sincronizzare",
|
||||
"signing_out": "Rimozione dei contatti e dei calendari sincronizzati da questo dispositivo…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Fotocamera",
|
||||
"attachment_reminder_body": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contatto} other {# contatti}}",
|
||||
"default_book": "Rubrica predefinita",
|
||||
"description_mobile": "Preferenze di visualizzazione e strumenti per le rubriche.",
|
||||
"device_sync_desc": "Mostra le tue rubriche nell'app Contatti e nelle altre app di questo dispositivo. Le modifiche vengono sincronizzate in entrambe le direzioni.",
|
||||
"device_sync_title": "Sincronizza con questo dispositivo",
|
||||
"export_description_count": "{count, plural, one {Esporta # contatto come un unico file vCard.} other {Esporta # contatti come un unico file vCard.}}",
|
||||
"export_empty": "Nessun contatto da esportare",
|
||||
"import_description_mobile": "Importa contatti da un file vCard (.vcf) con rilevamento dei duplicati.",
|
||||
"manage_description_mobile": "Crea, rinomina e rimuovi le rubriche che organizzano i tuoi contatti."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Si sincronizza anche per {accounts}, quindi compare due volte su questo dispositivo.",
|
||||
"android_settings": "Impostazioni account Android",
|
||||
"blocked_calendar": "Android non chiede più l'accesso ai calendari. Consentilo nelle impostazioni dell'app, poi riattiva la sincronizzazione.",
|
||||
"blocked_contacts": "Android non chiede più l'accesso ai contatti. Consentilo nelle impostazioni dell'app, poi riattiva la sincronizzazione.",
|
||||
"blocked_title": "L'accesso è disattivato",
|
||||
"books_default": "Le tue rubriche vengono sincronizzate; quelle condivise solo se le scegli.",
|
||||
"books_hint": "Le tue rubriche vengono sincronizzate a meno che tu non le disattivi; quelle condivise solo se le attivi.",
|
||||
"books_label": "Rubriche",
|
||||
"calendars_default": "I tuoi calendari vengono sincronizzati; quelli condivisi solo se li scegli.",
|
||||
"calendars_hint": "I tuoi calendari vengono sincronizzati a meno che tu non li disattivi; quelli condivisi solo se li attivi. Gli elenchi di attività restano in Bulwark.",
|
||||
"calendars_label": "Calendari",
|
||||
"choose": "Scegli",
|
||||
"choose_books_title": "Rubriche da sincronizzare",
|
||||
"choose_calendars_title": "Calendari da sincronizzare",
|
||||
"collections_count": "Sincronizzazione: {selected} su {total}",
|
||||
"continue": "Continua",
|
||||
"deletions_delete": "Elimina dal server",
|
||||
"deletions_message": "Molti elementi sono stati eliminati contemporaneamente su questo dispositivo, quindi non sono stati eliminati sul server. Eliminali anche lì o ripristinali su questo dispositivo.",
|
||||
"deletions_restore": "Ripristina",
|
||||
"deletions_title": "Eliminarli anche sul server?",
|
||||
"denied_calendar": "Senza l'accesso ai calendari, Bulwark non può portare i tuoi calendari su questo dispositivo.",
|
||||
"denied_contacts": "Senza l'accesso ai contatti, Bulwark non può portare le tue rubriche su questo dispositivo.",
|
||||
"denied_title": "La sincronizzazione resta disattivata",
|
||||
"failed_title": "Impossibile attivare la sincronizzazione",
|
||||
"grant_access": "Consenti l'accesso",
|
||||
"interval_15m": "Ogni 15 minuti",
|
||||
"interval_1h": "Ogni ora",
|
||||
"interval_30m": "Ogni 30 minuti",
|
||||
"interval_6h": "Ogni 6 ore",
|
||||
"interval_desc": "La frequenza con cui Android sincronizza da solo. Le modifiche fatte in Bulwark e quelle notificate via push vengono sincronizzate prima.",
|
||||
"interval_label": "Intervallo di sincronizzazione",
|
||||
"interval_manual": "Nessuna sincronizzazione periodica",
|
||||
"keep_syncing": "Continua a sincronizzare",
|
||||
"list_error_auth": "Il server non ha accettato l'accesso.",
|
||||
"list_error_network": "Impossibile raggiungere il server.",
|
||||
"list_error_server": "Il server non è riuscito a fornire l'elenco.",
|
||||
"list_error_signed_out": "Su questo dispositivo non hai eseguito l'accesso a questo account.",
|
||||
"master_off": "La sincronizzazione automatica è disattivata su questo dispositivo, quindi si sincronizza solo quando tocchi \"Sincronizza ora\".",
|
||||
"new_contacts_default": "Rubrica predefinita",
|
||||
"new_contacts_desc": "La rubrica per i contatti creati in altre app su questo dispositivo.",
|
||||
"new_contacts_label": "Salva i nuovi contatti in",
|
||||
"no_books": "Questo account non ha rubriche.",
|
||||
"no_calendars": "Questo account non ha calendari da sincronizzare.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Apri impostazioni app",
|
||||
"own_collections": "Personali",
|
||||
"rationale_calendar": "Per mostrare i tuoi calendari nell'app Calendario, Bulwark ha bisogno dell'accesso ai calendari di questo dispositivo. Modifica solo i calendari del proprio account.",
|
||||
"rationale_contacts": "Per mostrare le tue rubriche nell'app Contatti, Bulwark ha bisogno dell'accesso ai contatti di questo dispositivo. Modifica solo i contatti del proprio account.",
|
||||
"rationale_title_calendar": "Consenti l'accesso ai calendari",
|
||||
"rationale_title_contacts": "Consenti l'accesso ai contatti",
|
||||
"read_only": "Sola lettura",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "App Calendario",
|
||||
"reminder_question_message": "I promemoria degli eventi sincronizzati possono arrivare dall'app di calendario di questo dispositivo o da Bulwark. Scegline uno per non riceverli due volte. Puoi cambiare idea in seguito.",
|
||||
"reminder_question_title": "Chi deve inviarti i promemoria?",
|
||||
"reminders_desc": "Chi ti ricorda gli eventi sincronizzati. L'altro resta in silenzio, così non ricevi due promemoria.",
|
||||
"reminders_label": "Promemoria",
|
||||
"reminders_nobody": "Le notifiche degli eventi di Bulwark sono disattivate, quindi niente ti ricorda gli eventi sincronizzati.",
|
||||
"remove": "Rimuovi",
|
||||
"remove_from_device": "Rimuovi da questo dispositivo",
|
||||
"remove_message": "I contatti e i calendari dell'account vengono rimossi da questo dispositivo. Le modifiche fatte qui che non hanno raggiunto il server andranno perse.",
|
||||
"remove_title": "Rimuovere da questo dispositivo?",
|
||||
"review_deletions": "Controlla eliminazioni",
|
||||
"shared_by": "Condiviso da {name}",
|
||||
"sign_in_again": "Accedi di nuovo",
|
||||
"status_auth": "Il server non accetta più l'accesso. Accedi di nuovo per continuare a sincronizzare.",
|
||||
"status_cancelled": "L'ultima sincronizzazione si è interrotta prima del termine. Riprenderà la prossima volta.",
|
||||
"status_failed": "Ultima sincronizzazione non riuscita: {message}",
|
||||
"status_failed_plain": "Ultima sincronizzazione non riuscita.",
|
||||
"status_io": "Il server non era raggiungibile {when}. Android riproverà.",
|
||||
"status_item_errors": "{count, plural, one {Impossibile sincronizzare # elemento.} other {Impossibile sincronizzare # elementi.}}",
|
||||
"status_off": "Disattivata",
|
||||
"status_paused": "In pausa nelle impostazioni di Android",
|
||||
"status_permission_calendar": "L'accesso ai calendari è stato disattivato, quindi non viene sincronizzato nulla.",
|
||||
"status_permission_contacts": "L'accesso ai contatti è stato disattivato, quindi non viene sincronizzato nulla.",
|
||||
"status_removed": "Rimosso nelle impostazioni di Android. Attiva la sincronizzazione per aggiungere di nuovo l'account.",
|
||||
"status_safety_abort": "Interrotta: il server non ha restituito nulla mentre questo dispositivo contiene ancora elementi. Controlla il server, poi sincronizza di nuovo.",
|
||||
"status_synced": "Sincronizzato {when}",
|
||||
"status_syncing": "Sincronizzazione…",
|
||||
"status_too_many_deletions": "Molti elementi sono stati eliminati su questo dispositivo. Non verranno eliminati sul server finché non confermi.",
|
||||
"status_unsupported_calendar": "Il server non offre calendari per questo account.",
|
||||
"status_unsupported_contacts": "Il server non offre rubriche per questo account.",
|
||||
"status_waiting": "In attesa della prima sincronizzazione",
|
||||
"status_waiting_next": "In attesa della prossima sincronizzazione",
|
||||
"suspended_desc": "Disconnesso. I suoi contatti e calendari restano su questo dispositivo, con le modifiche fatte qui, e verranno sincronizzati di nuovo quando accedi.",
|
||||
"switch_label": "Sincronizza {account} con questo dispositivo",
|
||||
"sync_now": "Sincronizza ora",
|
||||
"turn_off_anyway": "Disattiva",
|
||||
"turn_off_failed": "Impossibile rimuovere i dati sincronizzati da questo dispositivo. La sincronizzazione è in pausa; riprova più tardi.",
|
||||
"turn_off_failed_title": "Impossibile disattivare la sincronizzazione",
|
||||
"turn_off_pending": "{count, plural, one {# modifica fatta su questo dispositivo non ha ancora raggiunto il server. Andrà persa se disattivi ora la sincronizzazione.} other {# modifiche fatte su questo dispositivo non hanno ancora raggiunto il server. Andranno perse se disattivi ora la sincronizzazione.}}",
|
||||
"turn_off_title": "Disattivare comunque?",
|
||||
"turn_off_unknown": "Bulwark non è riuscito a verificare se le modifiche fatte su questo dispositivo hanno raggiunto il server. Andranno perse se disattivi ora la sincronizzazione.",
|
||||
"turning_off": "Caricamento delle modifiche, poi rimozione dei dati da questo dispositivo…",
|
||||
"turning_on": "Configurazione…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Modello usato quando salvi o condividi un allegato."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "{date}まで"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "カレンダーアプリやこのデバイスの他のアプリにカレンダーを表示します。変更は双方向に同期されます。",
|
||||
"device_sync_title": "このデバイスに同期",
|
||||
"show_week_numbers_mobile_desc": "月表示に週番号を表示する",
|
||||
"time_zone": "タイムゾーン",
|
||||
"time_zone_auto": "デバイスのタイムゾーン",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "ラベル",
|
||||
"remove_color": "ラベルを削除"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "サーバーが {account} のサインインを受け付けなくなったため、このデバイス上のこのアカウントの連絡先とカレンダーの同期が停止しました。再開するには、もう一度サインインしてください。このデバイスで行った変更は保持されます。",
|
||||
"auth_problem_title": "同期を続けるには再度サインインしてください",
|
||||
"problem_channel": "同期の問題",
|
||||
"sign_out_anyway": "サインアウト",
|
||||
"sign_out_pending_message": "{count, plural, other {このデバイスで行った#件の変更がサーバーに届いていません。今サインアウトすると失われます。}}",
|
||||
"sign_out_pending_title": "それでもサインアウトしますか?",
|
||||
"sign_out_unknown_message": "このデバイスで行った変更がサーバーに届いていない可能性があります。今サインアウトすると失われます。",
|
||||
"signed_out_text": "{account} からサインアウトされたため、このデバイス上のこのアカウントの連絡先とカレンダーは同期されなくなりました。再開するには、もう一度サインインしてください。このデバイスで行った変更は保持されます。",
|
||||
"signed_out_title": "同期を続けるにはサインインしてください",
|
||||
"signing_out": "同期した連絡先とカレンダーをこのデバイスから削除しています…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "カメラ",
|
||||
"attachment_reminder_body": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, other {#件の連絡先}}",
|
||||
"default_book": "デフォルトのアドレス帳",
|
||||
"description_mobile": "表示設定とアドレス帳のツールです。",
|
||||
"device_sync_desc": "連絡帳アプリやこのデバイスの他のアプリにアドレス帳を表示します。変更は双方向に同期されます。",
|
||||
"device_sync_title": "このデバイスに同期",
|
||||
"export_description_count": "{count, plural, other {#件の連絡先を1つの vCard ファイルとしてエクスポートします。}}",
|
||||
"export_empty": "エクスポートする連絡先がありません",
|
||||
"import_description_mobile": "重複を検出しながら、vCard(.vcf)ファイルから連絡先をインポートします。",
|
||||
"manage_description_mobile": "連絡先を整理するアドレス帳の作成、名前変更、削除ができます。"
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "{accounts} でも同期されているため、このデバイスには2回表示されます。",
|
||||
"android_settings": "Android のアカウント設定",
|
||||
"blocked_calendar": "Android はカレンダーへのアクセスを求めなくなりました。アプリの設定で許可してから、同期をもう一度オンにしてください。",
|
||||
"blocked_contacts": "Android は連絡先へのアクセスを求めなくなりました。アプリの設定で許可してから、同期をもう一度オンにしてください。",
|
||||
"blocked_title": "アクセスがオフになっています",
|
||||
"books_default": "自分のアドレス帳は同期されます。共有されたアドレス帳は選択した場合のみ同期されます。",
|
||||
"books_hint": "自分のアドレス帳はオフにしない限り同期されます。共有されたアドレス帳はオンにした場合のみ同期されます。",
|
||||
"books_label": "アドレス帳",
|
||||
"calendars_default": "自分のカレンダーは同期されます。共有されたカレンダーは選択した場合のみ同期されます。",
|
||||
"calendars_hint": "自分のカレンダーはオフにしない限り同期されます。共有されたカレンダーはオンにした場合のみ同期されます。タスクリストは Bulwark に残ります。",
|
||||
"calendars_label": "カレンダー",
|
||||
"choose": "選択",
|
||||
"choose_books_title": "同期するアドレス帳",
|
||||
"choose_calendars_title": "同期するカレンダー",
|
||||
"collections_count": "{total}件中{selected}件を同期",
|
||||
"continue": "続行",
|
||||
"deletions_delete": "サーバーで削除",
|
||||
"deletions_message": "このデバイスで多数の項目が一度に削除されたため、サーバーでは削除されていません。サーバーでも削除するか、このデバイスに復元してください。",
|
||||
"deletions_restore": "復元",
|
||||
"deletions_title": "サーバーでも削除しますか?",
|
||||
"denied_calendar": "カレンダーへのアクセスがないと、Bulwark はカレンダーをこのデバイスに追加できません。",
|
||||
"denied_contacts": "連絡先へのアクセスがないと、Bulwark はアドレス帳をこのデバイスに追加できません。",
|
||||
"denied_title": "同期はオフのままです",
|
||||
"failed_title": "同期をオンにできませんでした",
|
||||
"grant_access": "アクセスを許可",
|
||||
"interval_15m": "15分ごと",
|
||||
"interval_1h": "1時間ごと",
|
||||
"interval_30m": "30分ごと",
|
||||
"interval_6h": "6時間ごと",
|
||||
"interval_desc": "Android が自動で同期する頻度です。Bulwark での編集やプッシュで通知された変更は、より早く同期されます。",
|
||||
"interval_label": "同期の間隔",
|
||||
"interval_manual": "定期的に同期しない",
|
||||
"keep_syncing": "同期を続ける",
|
||||
"list_error_auth": "サーバーがサインインを受け付けませんでした。",
|
||||
"list_error_network": "サーバーに接続できませんでした。",
|
||||
"list_error_server": "サーバーが一覧を返せませんでした。",
|
||||
"list_error_signed_out": "このデバイスではこのアカウントにサインインしていません。",
|
||||
"master_off": "このデバイスでは自動同期がオフになっているため、「今すぐ同期」をタップしたときにのみ同期されます。",
|
||||
"new_contacts_default": "デフォルトのアドレス帳",
|
||||
"new_contacts_desc": "このデバイスの他のアプリで作成した連絡先の保存先となるアドレス帳です。",
|
||||
"new_contacts_label": "新しい連絡先の保存先",
|
||||
"no_books": "このアカウントにはアドレス帳がありません。",
|
||||
"no_calendars": "このアカウントには同期できるカレンダーがありません。",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "アプリの設定を開く",
|
||||
"own_collections": "自分のもの",
|
||||
"rationale_calendar": "カレンダーアプリにカレンダーを表示するには、Bulwark にこのデバイスのカレンダーへのアクセスを許可する必要があります。Bulwark が変更するのは自分のアカウントのカレンダーだけです。",
|
||||
"rationale_contacts": "連絡帳アプリにアドレス帳を表示するには、Bulwark にこのデバイスの連絡先へのアクセスを許可する必要があります。Bulwark が変更するのは自分のアカウントの連絡先だけです。",
|
||||
"rationale_title_calendar": "カレンダーへのアクセスを許可",
|
||||
"rationale_title_contacts": "連絡先へのアクセスを許可",
|
||||
"read_only": "読み取り専用",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "カレンダーアプリ",
|
||||
"reminder_question_message": "同期した予定のリマインダーは、このデバイスのカレンダーアプリか Bulwark のどちらかから通知できます。重複して通知されないよう、どちらか一方を選んでください。後で変更できます。",
|
||||
"reminder_question_title": "どちらから通知しますか?",
|
||||
"reminders_desc": "同期した予定のリマインダーを通知するアプリです。もう一方は通知しないため、重複して通知されません。",
|
||||
"reminders_label": "リマインダー",
|
||||
"reminders_nobody": "Bulwark の予定の通知がオフになっているため、同期した予定のリマインダーは通知されません。",
|
||||
"remove": "削除",
|
||||
"remove_from_device": "このデバイスから削除",
|
||||
"remove_message": "このアカウントの連絡先とカレンダーがこのデバイスから削除されます。ここで行った変更のうち、サーバーに届いていないものは失われます。",
|
||||
"remove_title": "このデバイスから削除しますか?",
|
||||
"review_deletions": "削除を確認",
|
||||
"shared_by": "{name} が共有",
|
||||
"sign_in_again": "再度サインイン",
|
||||
"status_auth": "サーバーがサインインを受け付けなくなりました。同期を続けるには、再度サインインしてください。",
|
||||
"status_cancelled": "前回の同期は途中で停止しました。次回に続きから同期します。",
|
||||
"status_failed": "前回の同期に失敗しました:{message}",
|
||||
"status_failed_plain": "前回の同期に失敗しました。",
|
||||
"status_io": "サーバーに接続できませんでした({when})。Android が再試行します。",
|
||||
"status_item_errors": "{count, plural, other {#件の項目を同期できませんでした。}}",
|
||||
"status_off": "オフ",
|
||||
"status_paused": "Android の設定で一時停止中",
|
||||
"status_permission_calendar": "カレンダーへのアクセスがオフになったため、何も同期されません。",
|
||||
"status_permission_contacts": "連絡先へのアクセスがオフになったため、何も同期されません。",
|
||||
"status_removed": "Android の設定で削除されました。アカウントを再度追加するには、同期をオンにしてください。",
|
||||
"status_safety_abort": "停止しました:このデバイスに項目が残っているのに、サーバーから何も返されませんでした。サーバーを確認してから、もう一度同期してください。",
|
||||
"status_synced": "最終同期:{when}",
|
||||
"status_syncing": "同期中…",
|
||||
"status_too_many_deletions": "このデバイスで多数の項目が削除されました。確認するまでサーバーでは削除されません。",
|
||||
"status_unsupported_calendar": "サーバーはこのアカウントのカレンダーを提供していません。",
|
||||
"status_unsupported_contacts": "サーバーはこのアカウントのアドレス帳を提供していません。",
|
||||
"status_waiting": "初回の同期を待っています",
|
||||
"status_waiting_next": "次回の同期を待っています",
|
||||
"suspended_desc": "サインアウトしました。このアカウントの連絡先とカレンダーは、ここで行った変更とともにこのデバイスに残り、サインインすると再び同期されます。",
|
||||
"switch_label": "{account} をこのデバイスに同期",
|
||||
"sync_now": "今すぐ同期",
|
||||
"turn_off_anyway": "オフにする",
|
||||
"turn_off_failed": "同期したデータをこのデバイスから削除できませんでした。同期は一時停止しています。後でもう一度お試しください。",
|
||||
"turn_off_failed_title": "同期をオフにできませんでした",
|
||||
"turn_off_pending": "{count, plural, other {このデバイスで行った#件の変更がまだサーバーに届いていません。今同期をオフにすると失われます。}}",
|
||||
"turn_off_title": "それでもオフにしますか?",
|
||||
"turn_off_unknown": "このデバイスで行った変更がサーバーに届いたかどうかを Bulwark で確認できませんでした。今同期をオフにすると失われます。",
|
||||
"turning_off": "変更をアップロードしてから、このデバイスのデータを削除しています…",
|
||||
"turning_on": "設定しています…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "添付ファイルを保存または共有するときに使用するテンプレートです。"
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "{date}까지"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "캘린더 앱과 이 기기의 다른 앱에 내 캘린더를 표시해요. 변경 사항은 양방향으로 동기화돼요.",
|
||||
"device_sync_title": "이 기기에 동기화",
|
||||
"show_week_numbers_mobile_desc": "월간 보기에 주 차 번호를 표시해요.",
|
||||
"time_zone": "시간대",
|
||||
"time_zone_auto": "기기 시간대",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "태그",
|
||||
"remove_color": "태그 제거"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "서버가 더 이상 {account} 계정의 로그인을 허용하지 않아서 이 기기에 있는 이 계정의 연락처와 캘린더 동기화가 중단되었어요. 다시 시작하려면 다시 로그인해 주세요. 이 기기에서 변경한 내용은 유지돼요.",
|
||||
"auth_problem_title": "계속 동기화하려면 다시 로그인해 주세요",
|
||||
"problem_channel": "동기화 문제",
|
||||
"sign_out_anyway": "로그아웃",
|
||||
"sign_out_pending_message": "{count, plural, other {이 기기에서 변경한 내용 #개가 서버에 전달되지 않았어요. 지금 로그아웃하면 사라져요.}}",
|
||||
"sign_out_pending_title": "그래도 로그아웃할까요?",
|
||||
"sign_out_unknown_message": "이 기기에서 변경한 내용이 서버에 전달되지 않았을 수 있어요. 지금 로그아웃하면 사라져요.",
|
||||
"signed_out_text": "{account} 계정에서 로그아웃되어 이 기기에 있는 이 계정의 연락처와 캘린더가 더 이상 동기화되지 않아요. 다시 시작하려면 다시 로그인해 주세요. 이 기기에서 변경한 내용은 유지돼요.",
|
||||
"signed_out_title": "계속 동기화하려면 로그인해 주세요",
|
||||
"signing_out": "동기화된 연락처와 캘린더를 이 기기에서 삭제하는 중…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "카메라",
|
||||
"attachment_reminder_body": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 첨부된 파일이 없어요. 그래도 보낼까요?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, other {연락처 #개}}",
|
||||
"default_book": "기본 주소록",
|
||||
"description_mobile": "표시 설정과 주소록 도구예요.",
|
||||
"device_sync_desc": "연락처 앱과 이 기기의 다른 앱에 내 주소록을 표시해요. 변경 사항은 양방향으로 동기화돼요.",
|
||||
"device_sync_title": "이 기기에 동기화",
|
||||
"export_description_count": "{count, plural, other {연락처 #개를 하나의 vCard 파일로 내보내요.}}",
|
||||
"export_empty": "내보낼 연락처가 없어요",
|
||||
"import_description_mobile": "vCard(.vcf) 파일에서 연락처를 가져오고 중복을 감지해요.",
|
||||
"manage_description_mobile": "연락처를 정리하는 주소록을 만들고, 이름을 바꾸고, 삭제할 수 있어요."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "{accounts} 계정에서도 동기화되어 이 기기에 두 번 표시돼요.",
|
||||
"android_settings": "Android 계정 설정",
|
||||
"blocked_calendar": "Android가 더 이상 캘린더 접근 권한을 요청하지 않아요. 앱 설정에서 허용한 후 동기화를 다시 켜 주세요.",
|
||||
"blocked_contacts": "Android가 더 이상 연락처 접근 권한을 요청하지 않아요. 앱 설정에서 허용한 후 동기화를 다시 켜 주세요.",
|
||||
"blocked_title": "접근 권한이 꺼져 있어요",
|
||||
"books_default": "내 주소록은 동기화되고, 공유된 주소록은 선택한 경우에만 동기화돼요.",
|
||||
"books_hint": "내 주소록은 끄지 않는 한 동기화되고, 공유된 주소록은 켠 경우에만 동기화돼요.",
|
||||
"books_label": "주소록",
|
||||
"calendars_default": "내 캘린더는 동기화되고, 공유된 캘린더는 선택한 경우에만 동기화돼요.",
|
||||
"calendars_hint": "내 캘린더는 끄지 않는 한 동기화되고, 공유된 캘린더는 켠 경우에만 동기화돼요. 할 일 목록은 Bulwark에 남아요.",
|
||||
"calendars_label": "캘린더",
|
||||
"choose": "선택",
|
||||
"choose_books_title": "동기화할 주소록",
|
||||
"choose_calendars_title": "동기화할 캘린더",
|
||||
"collections_count": "{total}개 중 {selected}개 동기화",
|
||||
"continue": "계속",
|
||||
"deletions_delete": "서버에서 삭제",
|
||||
"deletions_message": "이 기기에서 많은 항목이 한꺼번에 삭제되어 서버에서는 삭제되지 않았어요. 서버에서도 삭제하거나 이 기기에 다시 복원해 주세요.",
|
||||
"deletions_restore": "복원",
|
||||
"deletions_title": "서버에서도 삭제할까요?",
|
||||
"denied_calendar": "캘린더 접근 권한이 없으면 Bulwark가 내 캘린더를 이 기기에 추가할 수 없어요.",
|
||||
"denied_contacts": "연락처 접근 권한이 없으면 Bulwark가 내 주소록을 이 기기에 추가할 수 없어요.",
|
||||
"denied_title": "동기화가 꺼진 상태로 유지돼요",
|
||||
"failed_title": "동기화를 켤 수 없어요",
|
||||
"grant_access": "접근 권한 허용",
|
||||
"interval_15m": "15분마다",
|
||||
"interval_1h": "1시간마다",
|
||||
"interval_30m": "30분마다",
|
||||
"interval_6h": "6시간마다",
|
||||
"interval_desc": "Android가 자동으로 동기화하는 주기예요. Bulwark에서 수정한 내용과 푸시로 알려진 변경 사항은 더 빨리 동기화돼요.",
|
||||
"interval_label": "동기화 주기",
|
||||
"interval_manual": "정기 동기화 안 함",
|
||||
"keep_syncing": "계속 동기화",
|
||||
"list_error_auth": "서버가 로그인을 허용하지 않았어요.",
|
||||
"list_error_network": "서버에 연결할 수 없어요.",
|
||||
"list_error_server": "서버에서 목록을 가져올 수 없어요.",
|
||||
"list_error_signed_out": "이 기기에서 이 계정에 로그인되어 있지 않아요.",
|
||||
"master_off": "이 기기에서 자동 동기화가 꺼져 있어서 \"지금 동기화\"를 탭할 때만 동기화돼요.",
|
||||
"new_contacts_default": "기본 주소록",
|
||||
"new_contacts_desc": "이 기기의 다른 앱에서 만든 연락처를 저장할 주소록이에요.",
|
||||
"new_contacts_label": "새 연락처 저장 위치",
|
||||
"no_books": "이 계정에는 주소록이 없어요.",
|
||||
"no_calendars": "이 계정에는 동기화할 캘린더가 없어요.",
|
||||
"ok": "확인",
|
||||
"open_app_settings": "앱 설정 열기",
|
||||
"own_collections": "내 항목",
|
||||
"rationale_calendar": "캘린더 앱에 내 캘린더를 표시하려면 Bulwark에 이 기기의 캘린더 접근 권한이 필요해요. Bulwark는 자기 계정의 캘린더만 변경해요.",
|
||||
"rationale_contacts": "연락처 앱에 내 주소록을 표시하려면 Bulwark에 이 기기의 연락처 접근 권한이 필요해요. Bulwark는 자기 계정의 연락처만 변경해요.",
|
||||
"rationale_title_calendar": "캘린더 접근 허용",
|
||||
"rationale_title_contacts": "연락처 접근 허용",
|
||||
"read_only": "읽기 전용",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "캘린더 앱",
|
||||
"reminder_question_message": "동기화된 일정 알림은 이 기기의 캘린더 앱이나 Bulwark에서 받을 수 있어요. 알림을 두 번 받지 않도록 하나를 선택해 주세요. 나중에 변경할 수 있어요.",
|
||||
"reminder_question_title": "어디에서 알림을 받을까요?",
|
||||
"reminders_desc": "동기화된 일정 알림을 보내는 앱이에요. 다른 쪽은 알림을 보내지 않아 알림을 두 번 받지 않아요.",
|
||||
"reminders_label": "알림",
|
||||
"reminders_nobody": "Bulwark의 일정 알림이 꺼져 있어서 동기화된 일정 알림을 받을 수 없어요.",
|
||||
"remove": "제거",
|
||||
"remove_from_device": "이 기기에서 제거",
|
||||
"remove_message": "이 계정의 연락처와 캘린더가 이 기기에서 제거돼요. 여기에서 변경한 내용 중 서버에 전달되지 않은 내용은 사라져요.",
|
||||
"remove_title": "이 기기에서 제거할까요?",
|
||||
"review_deletions": "삭제 항목 검토",
|
||||
"shared_by": "{name} 님이 공유함",
|
||||
"sign_in_again": "다시 로그인",
|
||||
"status_auth": "서버가 더 이상 로그인을 허용하지 않아요. 계속 동기화하려면 다시 로그인해 주세요.",
|
||||
"status_cancelled": "마지막 동기화가 도중에 멈췄어요. 다음번에 이어서 진행해요.",
|
||||
"status_failed": "마지막 동기화 실패: {message}",
|
||||
"status_failed_plain": "마지막 동기화에 실패했어요.",
|
||||
"status_io": "서버에 연결할 수 없었어요({when}). Android가 다시 시도해요.",
|
||||
"status_item_errors": "{count, plural, other {항목 #개를 동기화할 수 없었어요.}}",
|
||||
"status_off": "끔",
|
||||
"status_paused": "Android 설정에서 일시중지됨",
|
||||
"status_permission_calendar": "캘린더 접근 권한이 꺼져서 아무것도 동기화되지 않아요.",
|
||||
"status_permission_contacts": "연락처 접근 권한이 꺼져서 아무것도 동기화되지 않아요.",
|
||||
"status_removed": "Android 설정에서 삭제되었어요. 계정을 다시 추가하려면 동기화를 켜 주세요.",
|
||||
"status_safety_abort": "중지됨: 이 기기에 아직 항목이 있는데 서버가 아무것도 반환하지 않았어요. 서버를 확인한 후 다시 동기화해 주세요.",
|
||||
"status_synced": "마지막 동기화: {when}",
|
||||
"status_syncing": "동기화 중…",
|
||||
"status_too_many_deletions": "이 기기에서 많은 항목이 삭제되었어요. 확인하기 전까지 서버에서는 삭제되지 않아요.",
|
||||
"status_unsupported_calendar": "서버가 이 계정에 캘린더를 제공하지 않아요.",
|
||||
"status_unsupported_contacts": "서버가 이 계정에 주소록을 제공하지 않아요.",
|
||||
"status_waiting": "첫 동기화를 기다리는 중",
|
||||
"status_waiting_next": "다음 동기화를 기다리는 중",
|
||||
"suspended_desc": "로그아웃됨. 이 계정의 연락처와 캘린더는 여기에서 변경한 내용과 함께 이 기기에 남아 있고, 로그인하면 다시 동기화돼요.",
|
||||
"switch_label": "{account} 계정을 이 기기에 동기화",
|
||||
"sync_now": "지금 동기화",
|
||||
"turn_off_anyway": "끄기",
|
||||
"turn_off_failed": "동기화된 데이터를 이 기기에서 제거할 수 없었어요. 동기화가 일시중지되었어요. 나중에 다시 시도해 주세요.",
|
||||
"turn_off_failed_title": "동기화를 끌 수 없어요",
|
||||
"turn_off_pending": "{count, plural, other {이 기기에서 변경한 내용 #개가 아직 서버에 전달되지 않았어요. 지금 동기화를 끄면 사라져요.}}",
|
||||
"turn_off_title": "그래도 끌까요?",
|
||||
"turn_off_unknown": "이 기기에서 변경한 내용이 서버에 전달되었는지 Bulwark에서 확인할 수 없었어요. 지금 동기화를 끄면 사라져요.",
|
||||
"turning_off": "변경 사항을 업로드한 후 이 기기에서 데이터를 제거하는 중…",
|
||||
"turning_on": "설정하는 중…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "첨부 파일을 저장하거나 공유할 때 사용하는 템플릿이에요."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Līdz {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Rādiet savus kalendārus lietotnē Kalendārs un citās šīs ierīces lietotnēs. Izmaiņas tiek sinhronizētas abos virzienos.",
|
||||
"device_sync_title": "Sinhronizēt ar šo ierīci",
|
||||
"show_week_numbers_mobile_desc": "Rādīt nedēļu numurus mēneša skatā.",
|
||||
"time_zone": "Laika josla",
|
||||
"time_zone_auto": "Ierīces laika josla",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Tags",
|
||||
"remove_color": "Noņemt tagu"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Serveris vairs nepieņem konta {account} pierakstīšanos, tāpēc tā kontakti un kalendāri šajā ierīcē vairs netiek sinhronizēti. Lai turpinātu, pierakstieties vēlreiz. Šajā ierīcē veiktās izmaiņas tiek saglabātas.",
|
||||
"auth_problem_title": "Pierakstieties vēlreiz, lai turpinātu sinhronizāciju",
|
||||
"problem_channel": "Sinhronizācijas problēmas",
|
||||
"sign_out_anyway": "Izrakstīties",
|
||||
"sign_out_pending_message": "{count, plural, zero {# izmaiņas, kas veiktas šajā ierīcē, nav nonākušas serverī. Tās tiks zaudētas, ja tagad izrakstīsieties.} one {# izmaiņa, kas veikta šajā ierīcē, nav nonākusi serverī. Tā tiks zaudēta, ja tagad izrakstīsieties.} other {# izmaiņas, kas veiktas šajā ierīcē, nav nonākušas serverī. Tās tiks zaudētas, ja tagad izrakstīsieties.}}",
|
||||
"sign_out_pending_title": "Tomēr izrakstīties?",
|
||||
"sign_out_unknown_message": "Šajā ierīcē veiktās izmaiņas, iespējams, nav nonākušas serverī. Tās tiks zaudētas, ja tagad izrakstīsieties.",
|
||||
"signed_out_text": "Konts {account} tika izrakstīts, tāpēc tā kontakti un kalendāri šajā ierīcē vairs netiek sinhronizēti. Lai turpinātu, pierakstieties vēlreiz. Šajā ierīcē veiktās izmaiņas tiek saglabātas.",
|
||||
"signed_out_title": "Pierakstieties, lai turpinātu sinhronizāciju",
|
||||
"signing_out": "Sinhronizētie kontakti un kalendāri tiek noņemti no šīs ierīces…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "Jūsu ziņojumā minēts \"{keyword}\", bet nav pievienots neviens fails. Vai tomēr sūtīt?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, zero {# kontakti} one {# kontakts} other {# kontakti}}",
|
||||
"default_book": "Noklusējuma adrešu grāmata",
|
||||
"description_mobile": "Attēlošanas preferences un adrešu grāmatu rīki.",
|
||||
"device_sync_desc": "Rādiet savas adrešu grāmatas lietotnē Kontakti un citās šīs ierīces lietotnēs. Izmaiņas tiek sinhronizētas abos virzienos.",
|
||||
"device_sync_title": "Sinhronizēt ar šo ierīci",
|
||||
"export_description_count": "{count, plural, zero {Eksportēt # kontaktus vienā vCard failā.} one {Eksportēt # kontaktu vienā vCard failā.} other {Eksportēt # kontaktus vienā vCard failā.}}",
|
||||
"export_empty": "Nav kontaktu, ko eksportēt",
|
||||
"import_description_mobile": "Importēt kontaktus no vCard (.vcf) faila ar dublikātu noteikšanu.",
|
||||
"manage_description_mobile": "Izveidojiet, pārdēvējiet un noņemiet adrešu grāmatas, kurās kārtoti jūsu kontakti."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Sinhronizējas arī kontiem {accounts}, tāpēc šajā ierīcē parādās divreiz.",
|
||||
"android_settings": "Android konta iestatījumi",
|
||||
"blocked_calendar": "Android vairs neprasa piekļuvi kalendāriem. Atļaujiet to lietotnes iestatījumos un pēc tam atkal ieslēdziet sinhronizāciju.",
|
||||
"blocked_contacts": "Android vairs neprasa piekļuvi kontaktiem. Atļaujiet to lietotnes iestatījumos un pēc tam atkal ieslēdziet sinhronizāciju.",
|
||||
"blocked_title": "Piekļuve ir izslēgta",
|
||||
"books_default": "Jūsu adrešu grāmatas tiek sinhronizētas, koplietotās — tikai tad, ja tās izvēlaties.",
|
||||
"books_hint": "Jūsu adrešu grāmatas tiek sinhronizētas, ja vien tās neizslēdzat; koplietotās — tikai tad, ja tās ieslēdzat.",
|
||||
"books_label": "Adrešu grāmatas",
|
||||
"calendars_default": "Jūsu kalendāri tiek sinhronizēti, koplietotie — tikai tad, ja tos izvēlaties.",
|
||||
"calendars_hint": "Jūsu kalendāri tiek sinhronizēti, ja vien tos neizslēdzat; koplietotie — tikai tad, ja tos ieslēdzat. Uzdevumu saraksti paliek Bulwark.",
|
||||
"calendars_label": "Kalendāri",
|
||||
"choose": "Izvēlēties",
|
||||
"choose_books_title": "Sinhronizējamās adrešu grāmatas",
|
||||
"choose_calendars_title": "Sinhronizējamie kalendāri",
|
||||
"collections_count": "Sinhronizē: {selected} no {total}",
|
||||
"continue": "Turpināt",
|
||||
"deletions_delete": "Dzēst serverī",
|
||||
"deletions_message": "Šajā ierīcē vienlaikus tika izdzēsts daudz vienumu, tāpēc tie netika izdzēsti serverī. Izdzēsiet tos arī tur vai atjaunojiet tos šajā ierīcē.",
|
||||
"deletions_restore": "Atjaunot",
|
||||
"deletions_title": "Vai dzēst tos arī serverī?",
|
||||
"denied_calendar": "Bez piekļuves kalendāriem Bulwark nevar ievietot jūsu kalendārus šajā ierīcē.",
|
||||
"denied_contacts": "Bez piekļuves kontaktiem Bulwark nevar ievietot jūsu adrešu grāmatas šajā ierīcē.",
|
||||
"denied_title": "Sinhronizācija paliek izslēgta",
|
||||
"failed_title": "Sinhronizāciju neizdevās ieslēgt",
|
||||
"grant_access": "Piešķirt piekļuvi",
|
||||
"interval_15m": "Ik pēc 15 minūtēm",
|
||||
"interval_1h": "Ik pēc stundas",
|
||||
"interval_30m": "Ik pēc 30 minūtēm",
|
||||
"interval_6h": "Ik pēc 6 stundām",
|
||||
"interval_desc": "Cik bieži Android sinhronizē patstāvīgi. Labojumi Bulwark un ar push paziņotās izmaiņas tiek sinhronizēti ātrāk.",
|
||||
"interval_label": "Sinhronizācijas intervāls",
|
||||
"interval_manual": "Bez regulāras sinhronizācijas",
|
||||
"keep_syncing": "Turpināt sinhronizāciju",
|
||||
"list_error_auth": "Serveris nepieņēma pierakstīšanos.",
|
||||
"list_error_network": "Neizdevās sazināties ar serveri.",
|
||||
"list_error_server": "Serveris nespēja sniegt sarakstu.",
|
||||
"list_error_signed_out": "Šis konts šajā ierīcē nav pierakstīts.",
|
||||
"master_off": "Automātiskā sinhronizācija šajā ierīcē ir izslēgta, tāpēc sinhronizācija notiek tikai tad, kad pieskaraties \"Sinhronizēt tagad\".",
|
||||
"new_contacts_default": "Noklusējuma adrešu grāmata",
|
||||
"new_contacts_desc": "Adrešu grāmata kontaktiem, kas izveidoti citās šīs ierīces lietotnēs.",
|
||||
"new_contacts_label": "Kur saglabāt jaunos kontaktus",
|
||||
"no_books": "Šim kontam nav adrešu grāmatu.",
|
||||
"no_calendars": "Šim kontam nav sinhronizējamu kalendāru.",
|
||||
"ok": "Labi",
|
||||
"open_app_settings": "Atvērt iestatījumus",
|
||||
"own_collections": "Jūsu",
|
||||
"rationale_calendar": "Lai rādītu jūsu kalendārus lietotnē Kalendārs, Bulwark nepieciešama piekļuve šīs ierīces kalendāriem. Bulwark maina tikai sava konta kalendārus.",
|
||||
"rationale_contacts": "Lai rādītu jūsu adrešu grāmatas lietotnē Kontakti, Bulwark nepieciešama piekļuve šīs ierīces kontaktiem. Bulwark maina tikai sava konta kontaktus.",
|
||||
"rationale_title_calendar": "Atļaut piekļuvi kalendāriem",
|
||||
"rationale_title_contacts": "Atļaut piekļuvi kontaktiem",
|
||||
"read_only": "Tikai lasāms",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Lietotne Kalendārs",
|
||||
"reminder_question_message": "Atgādinājumus par sinhronizētajiem notikumiem var sūtīt šīs ierīces kalendāra lietotne vai Bulwark. Izvēlieties vienu, lai nesaņemtu atgādinājumus divreiz. Vēlāk to var mainīt.",
|
||||
"reminder_question_title": "Kas jums atgādinās?",
|
||||
"reminders_desc": "Kas jums atgādina par sinhronizētajiem notikumiem. Otrs klusē, lai jūs nesaņemtu atgādinājumus divreiz.",
|
||||
"reminders_label": "Atgādinājumi",
|
||||
"reminders_nobody": "Bulwark notikumu paziņojumi ir izslēgti, tāpēc nekas neatgādina par sinhronizētajiem notikumiem.",
|
||||
"remove": "Noņemt",
|
||||
"remove_from_device": "Noņemt no šīs ierīces",
|
||||
"remove_message": "Konta kontakti un kalendāri tiks noņemti no šīs ierīces. Šeit veiktās izmaiņas, kas nav nonākušas serverī, tiks zaudētas.",
|
||||
"remove_title": "Vai noņemt no šīs ierīces?",
|
||||
"review_deletions": "Pārskatīt dzēšanas",
|
||||
"shared_by": "Kopīgoja: {name}",
|
||||
"sign_in_again": "Pierakstīties vēlreiz",
|
||||
"status_auth": "Serveris vairs nepieņem pierakstīšanos. Pierakstieties vēlreiz, lai turpinātu sinhronizāciju.",
|
||||
"status_cancelled": "Pēdējā sinhronizācija apstājās priekšlaicīgi. Nākamreiz tā turpināsies.",
|
||||
"status_failed": "Pēdējā sinhronizācija neizdevās: {message}",
|
||||
"status_failed_plain": "Pēdējā sinhronizācija neizdevās.",
|
||||
"status_io": "Serveris {when} nebija sasniedzams. Android mēģinās vēlreiz.",
|
||||
"status_item_errors": "{count, plural, zero {# vienumus neizdevās sinhronizēt.} one {# vienumu neizdevās sinhronizēt.} other {# vienumus neizdevās sinhronizēt.}}",
|
||||
"status_off": "Izslēgta",
|
||||
"status_paused": "Apturēta Android iestatījumos",
|
||||
"status_permission_calendar": "Piekļuve kalendāriem tika izslēgta, tāpēc nekas netiek sinhronizēts.",
|
||||
"status_permission_contacts": "Piekļuve kontaktiem tika izslēgta, tāpēc nekas netiek sinhronizēts.",
|
||||
"status_removed": "Noņemts Android iestatījumos. Ieslēdziet sinhronizāciju, lai kontu pievienotu vēlreiz.",
|
||||
"status_safety_abort": "Apturēts: serveris neatgrieza neko, lai gan šajā ierīcē joprojām ir vienumi. Pārbaudiet serveri un pēc tam sinhronizējiet vēlreiz.",
|
||||
"status_synced": "Sinhronizēts {when}",
|
||||
"status_syncing": "Sinhronizē…",
|
||||
"status_too_many_deletions": "Šajā ierīcē tika izdzēsts daudz vienumu. Serverī tie netiks izdzēsti, kamēr jūs to neapstiprināsiet.",
|
||||
"status_unsupported_calendar": "Serveris šim kontam nepiedāvā kalendārus.",
|
||||
"status_unsupported_contacts": "Serveris šim kontam nepiedāvā adrešu grāmatas.",
|
||||
"status_waiting": "Gaida pirmo sinhronizāciju",
|
||||
"status_waiting_next": "Gaida nākamo sinhronizāciju",
|
||||
"suspended_desc": "Izrakstīts. Konta kontakti un kalendāri paliek šajā ierīcē kopā ar šeit veiktajām izmaiņām un tiks atkal sinhronizēti, tiklīdz pierakstīsieties.",
|
||||
"switch_label": "Sinhronizēt {account} ar šo ierīci",
|
||||
"sync_now": "Sinhronizēt tagad",
|
||||
"turn_off_anyway": "Izslēgt",
|
||||
"turn_off_failed": "Sinhronizētos datus neizdevās noņemt no šīs ierīces. Sinhronizācija ir apturēta; vēlāk mēģiniet vēlreiz.",
|
||||
"turn_off_failed_title": "Sinhronizāciju neizdevās izslēgt",
|
||||
"turn_off_pending": "{count, plural, zero {# izmaiņas, kas veiktas šajā ierīcē, vēl nav nonākušas serverī. Tās tiks zaudētas, ja tagad izslēgsiet sinhronizāciju.} one {# izmaiņa, kas veikta šajā ierīcē, vēl nav nonākusi serverī. Tā tiks zaudēta, ja tagad izslēgsiet sinhronizāciju.} other {# izmaiņas, kas veiktas šajā ierīcē, vēl nav nonākušas serverī. Tās tiks zaudētas, ja tagad izslēgsiet sinhronizāciju.}}",
|
||||
"turn_off_title": "Tomēr izslēgt?",
|
||||
"turn_off_unknown": "Bulwark nevarēja pārbaudīt, vai šajā ierīcē veiktās izmaiņas ir nonākušas serverī. Tās tiks zaudētas, ja tagad izslēgsiet sinhronizāciju.",
|
||||
"turning_off": "Augšupielādē izmaiņas, pēc tam noņem datus no šīs ierīces…",
|
||||
"turning_on": "Notiek iestatīšana…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Veidne, ko izmanto, saglabājot vai kopīgojot pielikumu."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "{date} хүртэл"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Хуанлиудаа энэ төхөөрөмжийн Хуанли апп болон бусад аппуудад харуулна. Өөрчлөлтүүд хоёр чиглэлд синк хийгдэнэ.",
|
||||
"device_sync_title": "Энэ төхөөрөмжтэй синк хийх",
|
||||
"show_week_numbers_mobile_desc": "Сарын харагдацад долоо хоногийн дугаарыг харуулах.",
|
||||
"time_zone": "Цагийн бүс",
|
||||
"time_zone_auto": "Төхөөрөмжийн цагийн бүс",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Таг",
|
||||
"remove_color": "Тагийг хасах"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Сервер {account} бүртгэлийн нэвтрэлтийг хүлээн авахаа больсон тул энэ төхөөрөмж дээрх түүний харилцагчид болон хуанлиуд синк хийгдэхээ болилоо. Үргэлжлүүлэхийн тулд дахин нэвтэрнэ үү. Энэ төхөөрөмж дээр хийсэн өөрчлөлтүүд хадгалагдана.",
|
||||
"auth_problem_title": "Синкийг үргэлжлүүлэхийн тулд дахин нэвтэрнэ үү",
|
||||
"problem_channel": "Синкийн асуудлууд",
|
||||
"sign_out_anyway": "Гарах",
|
||||
"sign_out_pending_message": "{count, plural, one {Энэ төхөөрөмж дээр хийсэн # өөрчлөлт серверт хүрээгүй байна. Одоо гарвал энэ нь алга болно.} other {Энэ төхөөрөмж дээр хийсэн # өөрчлөлт серверт хүрээгүй байна. Одоо гарвал эдгээр нь алга болно.}}",
|
||||
"sign_out_pending_title": "Ямар ч байсан гарах уу?",
|
||||
"sign_out_unknown_message": "Энэ төхөөрөмж дээр хийсэн өөрчлөлтүүд серверт хүрээгүй байж магадгүй. Одоо гарвал тэдгээр нь алга болно.",
|
||||
"signed_out_text": "{account} бүртгэлээс гарсан тул энэ төхөөрөмж дээрх түүний харилцагчид болон хуанлиуд синк хийгдэхээ болилоо. Үргэлжлүүлэхийн тулд дахин нэвтэрнэ үү. Энэ төхөөрөмж дээр хийсэн өөрчлөлтүүд хадгалагдана.",
|
||||
"signed_out_title": "Синкийг үргэлжлүүлэхийн тулд нэвтэрнэ үү",
|
||||
"signing_out": "Синк хийсэн харилцагчид болон хуанлиудыг энэ төхөөрөмжөөс устгаж байна…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Камер",
|
||||
"attachment_reminder_body": "Таны имэйлд \"{keyword}\" гэж дурдсан ч файл хавсаргаагүй байна. Ямар ч байсан илгээх үү?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# харилцагч} other {# харилцагч}}",
|
||||
"default_book": "Өгөгдмөл хаягийн дэвтэр",
|
||||
"description_mobile": "Харагдах байдлын тохиргоо болон хаягийн дэвтрийн хэрэгслүүд.",
|
||||
"device_sync_desc": "Хаягийн дэвтрүүдээ энэ төхөөрөмжийн Харилцагчид апп болон бусад аппуудад харуулна. Өөрчлөлтүүд хоёр чиглэлд синк хийгдэнэ.",
|
||||
"device_sync_title": "Энэ төхөөрөмжтэй синк хийх",
|
||||
"export_description_count": "{count, plural, one {# харилцагчийг нэг vCard файл болгон экспортлох.} other {# харилцагчийг нэг vCard файл болгон экспортлох.}}",
|
||||
"export_empty": "Экспортлох харилцагч алга",
|
||||
"import_description_mobile": "vCard (.vcf) файлаас давхардлыг илрүүлэн харилцагчдыг импортлох.",
|
||||
"manage_description_mobile": "Харилцагчдаа цэгцлэх хаягийн дэвтрүүдийг үүсгэх, нэрийг нь өөрчлөх, устгах."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "{accounts} бүртгэлд мөн синк хийгддэг тул энэ төхөөрөмж дээр хоёр удаа харагдана.",
|
||||
"android_settings": "Android бүртгэлийн тохиргоо",
|
||||
"blocked_calendar": "Android хуанлид хандах зөвшөөрөл дахин асуухгүй. Аппын тохиргооноос зөвшөөрөөд синкийг дахин асаана уу.",
|
||||
"blocked_contacts": "Android харилцагчдад хандах зөвшөөрөл дахин асуухгүй. Аппын тохиргооноос зөвшөөрөөд синкийг дахин асаана уу.",
|
||||
"blocked_title": "Хандалт унтарсан байна",
|
||||
"books_default": "Таны өөрийн хаягийн дэвтрүүд синк хийгдэнэ; хуваалцсан нь зөвхөн та сонгосон үед.",
|
||||
"books_hint": "Таны өөрийн хаягийн дэвтрүүд таныг унтраахаас бусад тохиолдолд синк хийгдэнэ; хуваалцсан нь зөвхөн та асаасан үед.",
|
||||
"books_label": "Хаягийн дэвтрүүд",
|
||||
"calendars_default": "Таны өөрийн хуанлиуд синк хийгдэнэ; хуваалцсан нь зөвхөн та сонгосон үед.",
|
||||
"calendars_hint": "Таны өөрийн хуанлиуд таныг унтраахаас бусад тохиолдолд синк хийгдэнэ; хуваалцсан нь зөвхөн та асаасан үед. Даалгаврын жагсаалтууд Bulwark-д үлдэнэ.",
|
||||
"calendars_label": "Хуанлиуд",
|
||||
"choose": "Сонгох",
|
||||
"choose_books_title": "Синк хийх хаягийн дэвтрүүд",
|
||||
"choose_calendars_title": "Синк хийх хуанлиуд",
|
||||
"collections_count": "Синк хийгдэж буй: {selected}/{total}",
|
||||
"continue": "Үргэлжлүүлэх",
|
||||
"deletions_delete": "Сервер дээр устгах",
|
||||
"deletions_message": "Энэ төхөөрөмж дээр олон зүйлийг нэг дор устгасан тул сервер дээр устгаагүй. Тэдгээрийг тэнд ч мөн устгах эсвэл энэ төхөөрөмж дээр сэргээнэ үү.",
|
||||
"deletions_restore": "Сэргээх",
|
||||
"deletions_title": "Сервер дээр ч мөн устгах уу?",
|
||||
"denied_calendar": "Хуанлид хандах эрхгүйгээр Bulwark таны хуанлиудыг энэ төхөөрөмж дээр байршуулж чадахгүй.",
|
||||
"denied_contacts": "Харилцагчдад хандах эрхгүйгээр Bulwark таны хаягийн дэвтрүүдийг энэ төхөөрөмж дээр байршуулж чадахгүй.",
|
||||
"denied_title": "Синк унтраалттай хэвээр байна",
|
||||
"failed_title": "Синкийг асааж чадсангүй",
|
||||
"grant_access": "Хандалт олгох",
|
||||
"interval_15m": "15 минут тутамд",
|
||||
"interval_1h": "Цаг бүр",
|
||||
"interval_30m": "30 минут тутамд",
|
||||
"interval_6h": "6 цаг тутамд",
|
||||
"interval_desc": "Android өөрөө хэр олон удаа синк хийхийг заана. Bulwark дахь засварууд болон push-аар мэдэгдсэн өөрчлөлтүүд илүү эрт синк хийгдэнэ.",
|
||||
"interval_label": "Синкийн давтамж",
|
||||
"interval_manual": "Тогтмол синк хийхгүй",
|
||||
"keep_syncing": "Синкийг үргэлжлүүлэх",
|
||||
"list_error_auth": "Сервер нэвтрэлтийг хүлээн аваагүй.",
|
||||
"list_error_network": "Сервертэй холбогдож чадсангүй.",
|
||||
"list_error_server": "Сервер жагсаалтыг гаргаж чадсангүй.",
|
||||
"list_error_signed_out": "Энэ төхөөрөмж дээр энэ бүртгэлээр нэвтрээгүй байна.",
|
||||
"master_off": "Энэ төхөөрөмж дээр автомат синк унтарсан байгаа тул зөвхөн \"Одоо синк хийх\" дээр товшиход синк хийгдэнэ.",
|
||||
"new_contacts_default": "Өгөгдмөл хаягийн дэвтэр",
|
||||
"new_contacts_desc": "Энэ төхөөрөмжийн бусад аппад үүсгэсэн харилцагчдыг хадгалах хаягийн дэвтэр.",
|
||||
"new_contacts_label": "Шинэ харилцагчдыг хадгалах газар",
|
||||
"no_books": "Энэ бүртгэлд хаягийн дэвтэр алга.",
|
||||
"no_calendars": "Энэ бүртгэлд синк хийх хуанли алга.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Аппын тохиргоог нээх",
|
||||
"own_collections": "Таных",
|
||||
"rationale_calendar": "Таны хуанлиудыг Хуанли аппад харуулахын тулд Bulwark-д энэ төхөөрөмжийн хуанлид хандах эрх хэрэгтэй. Энэ нь зөвхөн өөрийн бүртгэлийн хуанлиудыг өөрчилнө.",
|
||||
"rationale_contacts": "Таны хаягийн дэвтрүүдийг Харилцагчид аппад харуулахын тулд Bulwark-д энэ төхөөрөмжийн харилцагчдад хандах эрх хэрэгтэй. Энэ нь зөвхөн өөрийн бүртгэлийн харилцагчдыг өөрчилнө.",
|
||||
"rationale_title_calendar": "Хуанлид хандахыг зөвшөөрөх",
|
||||
"rationale_title_contacts": "Харилцагчдад хандахыг зөвшөөрөх",
|
||||
"read_only": "Зөвхөн унших",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Хуанли апп",
|
||||
"reminder_question_message": "Синк хийсэн үйл явдлын сануулгыг энэ төхөөрөмжийн хуанли апп эсвэл Bulwark илгээж болно. Сануулга хоёр удаа ирэхгүйн тулд аль нэгийг нь сонгоно уу. Үүнийг дараа нь өөрчилж болно.",
|
||||
"reminder_question_title": "Хэн танд сануулах вэ?",
|
||||
"reminders_desc": "Синк хийсэн үйл явдлын талаар хэн танд сануулах. Нөгөө нь чимээгүй байх тул сануулга хоёр удаа ирэхгүй.",
|
||||
"reminders_label": "Сануулгууд",
|
||||
"reminders_nobody": "Bulwark-ын үйл явдлын мэдэгдэл унтарсан тул синк хийсэн үйл явдлын талаар юу ч танд сануулахгүй.",
|
||||
"remove": "Хасах",
|
||||
"remove_from_device": "Энэ төхөөрөмжөөс хасах",
|
||||
"remove_message": "Бүртгэлийн харилцагчид болон хуанлиуд энэ төхөөрөмжөөс хасагдана. Энд хийсэн, серверт хүрээгүй өөрчлөлтүүд алга болно.",
|
||||
"remove_title": "Энэ төхөөрөмжөөс хасах уу?",
|
||||
"review_deletions": "Устгалуудыг хянах",
|
||||
"shared_by": "Хуваалцсан: {name}",
|
||||
"sign_in_again": "Дахин нэвтрэх",
|
||||
"status_auth": "Сервер нэвтрэлтийг хүлээн авахаа больсон. Синкийг үргэлжлүүлэхийн тулд дахин нэвтэрнэ үү.",
|
||||
"status_cancelled": "Сүүлийн синк эрт зогссон. Дараагийн удаа үргэлжилнэ.",
|
||||
"status_failed": "Сүүлийн синк амжилтгүй: {message}",
|
||||
"status_failed_plain": "Сүүлийн синк амжилтгүй боллоо.",
|
||||
"status_io": "Сервертэй холбогдож чадсангүй ({when}). Android дахин оролдоно.",
|
||||
"status_item_errors": "{count, plural, one {# зүйлийг синк хийж чадсангүй.} other {# зүйлийг синк хийж чадсангүй.}}",
|
||||
"status_off": "Унтраалттай",
|
||||
"status_paused": "Android-ийн тохиргоонд түр зогсоосон",
|
||||
"status_permission_calendar": "Хуанлид хандах эрх унтарсан тул юу ч синк хийгдэхгүй.",
|
||||
"status_permission_contacts": "Харилцагчдад хандах эрх унтарсан тул юу ч синк хийгдэхгүй.",
|
||||
"status_removed": "Android-ийн тохиргооноос хасагдсан. Бүртгэлийг дахин нэмэхийн тулд синкийг асаана уу.",
|
||||
"status_safety_abort": "Зогссон: энэ төхөөрөмж дээр зүйлс байсаар байхад сервер юу ч буцаасангүй. Серверийг шалгаад дахин синк хийнэ үү.",
|
||||
"status_synced": "Сүүлийн синк: {when}",
|
||||
"status_syncing": "Синк хийж байна…",
|
||||
"status_too_many_deletions": "Энэ төхөөрөмж дээр олон зүйл устгагдсан. Таныг баталгаажуулах хүртэл сервер дээр устгахгүй.",
|
||||
"status_unsupported_calendar": "Сервер энэ бүртгэлд хуанли санал болгодоггүй.",
|
||||
"status_unsupported_contacts": "Сервер энэ бүртгэлд хаягийн дэвтэр санал болгодоггүй.",
|
||||
"status_waiting": "Анхны синкийг хүлээж байна",
|
||||
"status_waiting_next": "Дараагийн синкийг хүлээж байна",
|
||||
"suspended_desc": "Гарсан. Түүний харилцагчид болон хуанлиуд энд хийсэн өөрчлөлтүүдийн хамт энэ төхөөрөмж дээр үлдэх бөгөөд таныг нэвтэрмэгц дахин синк хийгдэнэ.",
|
||||
"switch_label": "{account} бүртгэлийг энэ төхөөрөмжтэй синк хийх",
|
||||
"sync_now": "Одоо синк хийх",
|
||||
"turn_off_anyway": "Унтраах",
|
||||
"turn_off_failed": "Синк хийсэн өгөгдлийг энэ төхөөрөмжөөс хасаж чадсангүй. Синк түр зогссон; дараа дахин оролдоно уу.",
|
||||
"turn_off_failed_title": "Синкийг унтрааж чадсангүй",
|
||||
"turn_off_pending": "{count, plural, one {Энэ төхөөрөмж дээр хийсэн # өөрчлөлт серверт хараахан хүрээгүй байна. Одоо синкийг унтраавал энэ нь алга болно.} other {Энэ төхөөрөмж дээр хийсэн # өөрчлөлт серверт хараахан хүрээгүй байна. Одоо синкийг унтраавал эдгээр нь алга болно.}}",
|
||||
"turn_off_title": "Ямар ч байсан унтраах уу?",
|
||||
"turn_off_unknown": "Энэ төхөөрөмж дээр хийсэн өөрчлөлтүүд серверт хүрсэн эсэхийг Bulwark шалгаж чадсангүй. Одоо синкийг унтраавал тэдгээр нь алга болно.",
|
||||
"turning_off": "Өөрчлөлтүүдийг илгээж, дараа нь өгөгдлийг энэ төхөөрөмжөөс хасаж байна…",
|
||||
"turning_on": "Тохируулж байна…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Хавсралтыг хадгалах эсвэл хуваалцахад ашиглах загвар."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Til {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Vis kalenderne dine i Kalender-appen og andre apper på denne enheten. Endringer synkroniseres begge veier.",
|
||||
"device_sync_title": "Synkroniser med denne enheten",
|
||||
"show_week_numbers_mobile_desc": "Vis ukenummer i månedsvisningen.",
|
||||
"time_zone": "Tidssone",
|
||||
"time_zone_auto": "Enhetens tidssone",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etikett",
|
||||
"remove_color": "Fjern etiketten"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Serveren godtar ikke lenger påloggingen for {account}, så kontaktene og kalenderne til kontoen på denne enheten synkroniseres ikke lenger. Logg på igjen for å fortsette. Endringer som er gjort på denne enheten, beholdes.",
|
||||
"auth_problem_title": "Logg på igjen for å fortsette synkroniseringen",
|
||||
"problem_channel": "Synkroniseringsproblemer",
|
||||
"sign_out_anyway": "Logg ut",
|
||||
"sign_out_pending_message": "{count, plural, one {# endring som er gjort på denne enheten, har ikke nådd serveren. Den går tapt hvis du logger ut nå.} other {# endringer som er gjort på denne enheten, har ikke nådd serveren. De går tapt hvis du logger ut nå.}}",
|
||||
"sign_out_pending_title": "Vil du logge ut likevel?",
|
||||
"sign_out_unknown_message": "Endringer som er gjort på denne enheten, har kanskje ikke nådd serveren. De går tapt hvis du logger ut nå.",
|
||||
"signed_out_text": "{account} ble logget ut, så kontaktene og kalenderne til kontoen på denne enheten synkroniseres ikke lenger. Logg på igjen for å fortsette. Endringer som er gjort på denne enheten, beholdes.",
|
||||
"signed_out_title": "Logg på for å fortsette synkroniseringen",
|
||||
"signing_out": "Fjerner synkroniserte kontakter og kalendere fra denne enheten…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "Meldingen din nevner «{keyword}», men ingen fil er vedlagt. Sende likevel?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kontakt} other {# kontakter}}",
|
||||
"default_book": "Standardadressebok",
|
||||
"description_mobile": "Visningsvalg og verktøy for adressebøker.",
|
||||
"device_sync_desc": "Vis adressebøkene dine i Kontakter-appen og andre apper på denne enheten. Endringer synkroniseres begge veier.",
|
||||
"device_sync_title": "Synkroniser med denne enheten",
|
||||
"export_description_count": "{count, plural, one {Eksporter # kontakt som én vCard-fil.} other {Eksporter # kontakter som én vCard-fil.}}",
|
||||
"export_empty": "Ingen kontakter å eksportere",
|
||||
"import_description_mobile": "Importer kontakter fra en vCard-fil (.vcf) med gjenkjenning av duplikater.",
|
||||
"manage_description_mobile": "Opprett, gi nytt navn til og fjern adressebøkene som organiserer kontaktene dine."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Synkroniseres også for {accounts}, så den vises to ganger på denne enheten.",
|
||||
"android_settings": "Android-kontoinnstillinger",
|
||||
"blocked_calendar": "Android spør ikke lenger om tilgang til kalendere. Gi tilgang i appinnstillingene, og slå deretter på synkronisering igjen.",
|
||||
"blocked_contacts": "Android spør ikke lenger om tilgang til kontakter. Gi tilgang i appinnstillingene, og slå deretter på synkronisering igjen.",
|
||||
"blocked_title": "Tilgangen er slått av",
|
||||
"books_default": "Dine egne adressebøker synkroniseres; delte bare når du velger dem.",
|
||||
"books_hint": "Dine egne adressebøker synkroniseres med mindre du slår dem av; delte bare når du slår dem på.",
|
||||
"books_label": "Adressebøker",
|
||||
"calendars_default": "Dine egne kalendere synkroniseres; delte bare når du velger dem.",
|
||||
"calendars_hint": "Dine egne kalendere synkroniseres med mindre du slår dem av; delte bare når du slår dem på. Oppgavelister blir værende i Bulwark.",
|
||||
"calendars_label": "Kalendere",
|
||||
"choose": "Velg",
|
||||
"choose_books_title": "Adressebøker som skal synkroniseres",
|
||||
"choose_calendars_title": "Kalendere som skal synkroniseres",
|
||||
"collections_count": "{selected} av {total} synkroniseres",
|
||||
"continue": "Fortsett",
|
||||
"deletions_delete": "Slett på serveren",
|
||||
"deletions_message": "Mange elementer ble slettet på én gang på denne enheten, så de ble ikke slettet på serveren. Slett dem der også, eller hent dem tilbake på denne enheten.",
|
||||
"deletions_restore": "Hent dem tilbake",
|
||||
"deletions_title": "Vil du slette dem på serveren også?",
|
||||
"denied_calendar": "Uten tilgang til kalendere kan ikke Bulwark legge kalenderne dine på denne enheten.",
|
||||
"denied_contacts": "Uten tilgang til kontakter kan ikke Bulwark legge adressebøkene dine på denne enheten.",
|
||||
"denied_title": "Synkroniseringen forblir av",
|
||||
"failed_title": "Kunne ikke slå på synkronisering",
|
||||
"grant_access": "Gi tilgang",
|
||||
"interval_15m": "Hvert 15. minutt",
|
||||
"interval_1h": "Hver time",
|
||||
"interval_30m": "Hvert 30. minutt",
|
||||
"interval_6h": "Hver 6. time",
|
||||
"interval_desc": "Hvor ofte Android synkroniserer av seg selv. Endringer i Bulwark og endringer som varsles via push, synkroniseres tidligere.",
|
||||
"interval_label": "Synkroniseringsintervall",
|
||||
"interval_manual": "Ingen jevnlig synkronisering",
|
||||
"keep_syncing": "Fortsett synkroniseringen",
|
||||
"list_error_auth": "Serveren godtok ikke påloggingen.",
|
||||
"list_error_network": "Kunne ikke nå serveren.",
|
||||
"list_error_server": "Serveren kunne ikke vise listen.",
|
||||
"list_error_signed_out": "Denne kontoen er ikke pålogget på denne enheten.",
|
||||
"master_off": "Automatisk synkronisering er slått av på denne enheten, så synkronisering skjer bare når du trykker på «Synkroniser nå».",
|
||||
"new_contacts_default": "Standardadressebok",
|
||||
"new_contacts_desc": "Adresseboken for kontakter som opprettes i andre apper på denne enheten.",
|
||||
"new_contacts_label": "Lagre nye kontakter i",
|
||||
"no_books": "Denne kontoen har ingen adressebøker.",
|
||||
"no_calendars": "Denne kontoen har ingen kalendere å synkronisere.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Åpne appinnstillingene",
|
||||
"own_collections": "Dine egne",
|
||||
"rationale_calendar": "For å vise kalenderne dine i Kalender-appen trenger Bulwark tilgang til kalenderne på denne enheten. Bulwark endrer bare kalenderne til sin egen konto.",
|
||||
"rationale_contacts": "For å vise adressebøkene dine i Kontakter-appen trenger Bulwark tilgang til kontaktene på denne enheten. Bulwark endrer bare kontaktene til sin egen konto.",
|
||||
"rationale_title_calendar": "Gi tilgang til kalendere",
|
||||
"rationale_title_contacts": "Gi tilgang til kontakter",
|
||||
"read_only": "Skrivebeskyttet",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Kalender-appen",
|
||||
"reminder_question_message": "Påminnelser om synkroniserte hendelser kan komme fra kalenderappen på denne enheten eller fra Bulwark. Velg én av dem, så du ikke får to påminnelser. Du kan endre dette senere.",
|
||||
"reminder_question_title": "Hvem skal minne deg på hendelser?",
|
||||
"reminders_desc": "Hvem som minner deg på synkroniserte hendelser. Den andre er stille, så du ikke får to påminnelser.",
|
||||
"reminders_label": "Påminnelser",
|
||||
"reminders_nobody": "Hendelsesvarslene i Bulwark er slått av, så ingenting minner deg på synkroniserte hendelser.",
|
||||
"remove": "Fjern",
|
||||
"remove_from_device": "Fjern fra denne enheten",
|
||||
"remove_message": "Kontoens kontakter og kalendere fjernes fra denne enheten. Endringer som er gjort her og ikke har nådd serveren, går tapt.",
|
||||
"remove_title": "Vil du fjerne fra denne enheten?",
|
||||
"review_deletions": "Gå gjennom slettinger",
|
||||
"shared_by": "Delt av {name}",
|
||||
"sign_in_again": "Logg på igjen",
|
||||
"status_auth": "Serveren godtar ikke lenger påloggingen. Logg på igjen for å fortsette synkroniseringen.",
|
||||
"status_cancelled": "Den siste synkroniseringen stoppet før den var ferdig. Den fortsetter neste gang.",
|
||||
"status_failed": "Den siste synkroniseringen mislyktes: {message}",
|
||||
"status_failed_plain": "Den siste synkroniseringen mislyktes.",
|
||||
"status_io": "Kunne ikke nå serveren {when}. Android prøver igjen.",
|
||||
"status_item_errors": "{count, plural, one {# element kunne ikke synkroniseres.} other {# elementer kunne ikke synkroniseres.}}",
|
||||
"status_off": "Av",
|
||||
"status_paused": "Satt på pause i Android-innstillingene",
|
||||
"status_permission_calendar": "Tilgangen til kalendere ble slått av, så ingenting synkroniseres.",
|
||||
"status_permission_contacts": "Tilgangen til kontakter ble slått av, så ingenting synkroniseres.",
|
||||
"status_removed": "Fjernet i Android-innstillingene. Slå på synkronisering for å legge til kontoen igjen.",
|
||||
"status_safety_abort": "Stoppet: Serveren returnerte ingenting, selv om denne enheten fortsatt har elementer. Sjekk serveren, og synkroniser deretter på nytt.",
|
||||
"status_synced": "Synkronisert {when}",
|
||||
"status_syncing": "Synkroniserer…",
|
||||
"status_too_many_deletions": "Mange elementer ble slettet på denne enheten. De slettes ikke på serveren før du bekrefter.",
|
||||
"status_unsupported_calendar": "Serveren tilbyr ingen kalendere for denne kontoen.",
|
||||
"status_unsupported_contacts": "Serveren tilbyr ingen adressebøker for denne kontoen.",
|
||||
"status_waiting": "Venter på den første synkroniseringen",
|
||||
"status_waiting_next": "Venter på neste synkronisering",
|
||||
"suspended_desc": "Logget ut. Kontoens kontakter og kalendere blir liggende på denne enheten sammen med endringene som er gjort her, og synkroniseres igjen når du logger på.",
|
||||
"switch_label": "Synkroniser {account} med denne enheten",
|
||||
"sync_now": "Synkroniser nå",
|
||||
"turn_off_anyway": "Slå av",
|
||||
"turn_off_failed": "De synkroniserte dataene kunne ikke fjernes fra denne enheten. Synkroniseringen er satt på pause. Prøv igjen senere.",
|
||||
"turn_off_failed_title": "Kunne ikke slå av synkronisering",
|
||||
"turn_off_pending": "{count, plural, one {# endring som er gjort på denne enheten, har ikke nådd serveren ennå. Den går tapt hvis du slår av synkronisering nå.} other {# endringer som er gjort på denne enheten, har ikke nådd serveren ennå. De går tapt hvis du slår av synkronisering nå.}}",
|
||||
"turn_off_title": "Vil du slå av likevel?",
|
||||
"turn_off_unknown": "Bulwark kunne ikke sjekke om endringer som er gjort på denne enheten, har nådd serveren. De går tapt hvis du slår av synkronisering nå.",
|
||||
"turning_off": "Laster opp endringer og fjerner deretter dataene fra denne enheten…",
|
||||
"turning_on": "Konfigurerer…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Malen som brukes når et vedlegg lagres eller deles."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Tot {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Toon uw agenda's in de Agenda-app en andere apps op dit apparaat. Wijzigingen worden in beide richtingen gesynchroniseerd.",
|
||||
"device_sync_title": "Synchroniseren met dit apparaat",
|
||||
"show_week_numbers_mobile_desc": "Weeknummers weergeven in de maandweergave.",
|
||||
"time_zone": "Tijdzone",
|
||||
"time_zone_auto": "Tijdzone van apparaat",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label verwijderen"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "De server accepteert de aanmelding van {account} niet meer, dus de contacten en agenda's van dit account op dit apparaat worden niet meer gesynchroniseerd. Meld u opnieuw aan om verder te gaan. Wijzigingen op dit apparaat blijven behouden.",
|
||||
"auth_problem_title": "Meld u opnieuw aan om te blijven synchroniseren",
|
||||
"problem_channel": "Synchronisatieproblemen",
|
||||
"sign_out_anyway": "Afmelden",
|
||||
"sign_out_pending_message": "{count, plural, one {# wijziging op dit apparaat heeft de server niet bereikt. Die gaat verloren als u zich nu afmeldt.} other {# wijzigingen op dit apparaat hebben de server niet bereikt. Die gaan verloren als u zich nu afmeldt.}}",
|
||||
"sign_out_pending_title": "Toch afmelden?",
|
||||
"sign_out_unknown_message": "Wijzigingen op dit apparaat hebben de server mogelijk niet bereikt. Ze gaan verloren als u zich nu afmeldt.",
|
||||
"signed_out_text": "{account} is afgemeld, dus de contacten en agenda's van dit account op dit apparaat worden niet meer gesynchroniseerd. Meld u opnieuw aan om verder te gaan. Wijzigingen op dit apparaat blijven behouden.",
|
||||
"signed_out_title": "Meld u aan om te blijven synchroniseren",
|
||||
"signing_out": "Gesynchroniseerde contacten en agenda's worden van dit apparaat verwijderd…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Camera",
|
||||
"attachment_reminder_body": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contact} other {# contacten}}",
|
||||
"default_book": "Standaardadresboek",
|
||||
"description_mobile": "Weergavevoorkeuren en hulpmiddelen voor adresboeken.",
|
||||
"device_sync_desc": "Toon uw adresboeken in de Contacten-app en andere apps op dit apparaat. Wijzigingen worden in beide richtingen gesynchroniseerd.",
|
||||
"device_sync_title": "Synchroniseren met dit apparaat",
|
||||
"export_description_count": "{count, plural, one {# contact exporteren als één vCard-bestand.} other {# contacten exporteren als één vCard-bestand.}}",
|
||||
"export_empty": "Geen contacten om te exporteren",
|
||||
"import_description_mobile": "Contacten importeren uit een vCard-bestand (.vcf) met detectie van duplicaten.",
|
||||
"manage_description_mobile": "Maak, hernoem en verwijder de adresboeken waarin uw contacten zijn ingedeeld."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Wordt ook gesynchroniseerd voor {accounts} en verschijnt daarom twee keer op dit apparaat.",
|
||||
"android_settings": "Android-accountinstellingen",
|
||||
"blocked_calendar": "Android vraagt niet meer om toegang tot agenda's. Sta de toegang toe in de app-instellingen en zet synchronisatie daarna weer aan.",
|
||||
"blocked_contacts": "Android vraagt niet meer om toegang tot contacten. Sta de toegang toe in de app-instellingen en zet synchronisatie daarna weer aan.",
|
||||
"blocked_title": "Toegang staat uit",
|
||||
"books_default": "Uw eigen adresboeken worden gesynchroniseerd; gedeelde alleen als u ze kiest.",
|
||||
"books_hint": "Uw eigen adresboeken worden gesynchroniseerd, tenzij u ze uitzet; gedeelde alleen als u ze aanzet.",
|
||||
"books_label": "Adresboeken",
|
||||
"calendars_default": "Uw eigen agenda's worden gesynchroniseerd; gedeelde alleen als u ze kiest.",
|
||||
"calendars_hint": "Uw eigen agenda's worden gesynchroniseerd, tenzij u ze uitzet; gedeelde alleen als u ze aanzet. Takenlijsten blijven in Bulwark.",
|
||||
"calendars_label": "Agenda's",
|
||||
"choose": "Kiezen",
|
||||
"choose_books_title": "Te synchroniseren adresboeken",
|
||||
"choose_calendars_title": "Te synchroniseren agenda's",
|
||||
"collections_count": "{selected} van {total} gesynchroniseerd",
|
||||
"continue": "Doorgaan",
|
||||
"deletions_delete": "Op server verwijderen",
|
||||
"deletions_message": "Er zijn op dit apparaat veel items tegelijk verwijderd, daarom zijn ze niet op de server verwijderd. Verwijder ze daar ook of zet ze terug op dit apparaat.",
|
||||
"deletions_restore": "Terugzetten",
|
||||
"deletions_title": "Ook op de server verwijderen?",
|
||||
"denied_calendar": "Zonder toegang tot agenda's kan Bulwark uw agenda's niet op dit apparaat zetten.",
|
||||
"denied_contacts": "Zonder toegang tot contacten kan Bulwark uw adresboeken niet op dit apparaat zetten.",
|
||||
"denied_title": "Synchronisatie blijft uit",
|
||||
"failed_title": "Synchronisatie kon niet worden aangezet",
|
||||
"grant_access": "Toegang geven",
|
||||
"interval_15m": "Elke 15 minuten",
|
||||
"interval_1h": "Elk uur",
|
||||
"interval_30m": "Elke 30 minuten",
|
||||
"interval_6h": "Elke 6 uur",
|
||||
"interval_desc": "Hoe vaak Android uit zichzelf synchroniseert. Wijzigingen in Bulwark en via push gemelde wijzigingen worden eerder gesynchroniseerd.",
|
||||
"interval_label": "Synchronisatie-interval",
|
||||
"interval_manual": "Geen regelmatige synchronisatie",
|
||||
"keep_syncing": "Blijven synchroniseren",
|
||||
"list_error_auth": "De server heeft de aanmelding niet geaccepteerd.",
|
||||
"list_error_network": "Kan de server niet bereiken.",
|
||||
"list_error_server": "De server kon ze niet weergeven.",
|
||||
"list_error_signed_out": "Dit account is niet aangemeld op dit apparaat.",
|
||||
"master_off": "Automatisch synchroniseren staat uit op dit apparaat, dus er wordt alleen gesynchroniseerd als u op \"Nu synchroniseren\" tikt.",
|
||||
"new_contacts_default": "Standaardadresboek",
|
||||
"new_contacts_desc": "Het adresboek voor contacten die in andere apps op dit apparaat worden gemaakt.",
|
||||
"new_contacts_label": "Nieuwe contacten opslaan in",
|
||||
"no_books": "Dit account heeft geen adresboeken.",
|
||||
"no_calendars": "Dit account heeft geen agenda's om te synchroniseren.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "App-instellingen openen",
|
||||
"own_collections": "Uw eigen",
|
||||
"rationale_calendar": "Om uw agenda's in de Agenda-app te tonen, heeft Bulwark toegang nodig tot de agenda's op dit apparaat. Bulwark wijzigt alleen de agenda's van zijn eigen account.",
|
||||
"rationale_contacts": "Om uw adresboeken in de Contacten-app te tonen, heeft Bulwark toegang nodig tot de contacten op dit apparaat. Bulwark wijzigt alleen de contacten van zijn eigen account.",
|
||||
"rationale_title_calendar": "Toegang tot agenda's toestaan",
|
||||
"rationale_title_contacts": "Toegang tot contacten toestaan",
|
||||
"read_only": "Alleen lezen",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Agenda-app",
|
||||
"reminder_question_message": "Gesynchroniseerde evenementen kunnen u herinneren via de agenda-app op dit apparaat of via Bulwark. Kies er één, zodat u niet dubbel wordt herinnerd. U kunt dit later wijzigen.",
|
||||
"reminder_question_title": "Wie moet u herinneren?",
|
||||
"reminders_desc": "Wie u herinnert aan gesynchroniseerde evenementen. De andere blijft stil, zodat u niet dubbel wordt herinnerd.",
|
||||
"reminders_label": "Herinneringen",
|
||||
"reminders_nobody": "De evenementmeldingen van Bulwark staan uit, dus niets herinnert u aan gesynchroniseerde evenementen.",
|
||||
"remove": "Verwijderen",
|
||||
"remove_from_device": "Verwijderen van dit apparaat",
|
||||
"remove_message": "De contacten en agenda's van het account worden van dit apparaat verwijderd. Wijzigingen die hier zijn gemaakt en de server nog niet hebben bereikt, gaan verloren.",
|
||||
"remove_title": "Verwijderen van dit apparaat?",
|
||||
"review_deletions": "Verwijderingen bekijken",
|
||||
"shared_by": "Gedeeld door {name}",
|
||||
"sign_in_again": "Opnieuw aanmelden",
|
||||
"status_auth": "De server accepteert de aanmelding niet meer. Meld u opnieuw aan om te blijven synchroniseren.",
|
||||
"status_cancelled": "De laatste synchronisatie is voortijdig gestopt. Die gaat de volgende keer verder.",
|
||||
"status_failed": "Laatste synchronisatie mislukt: {message}",
|
||||
"status_failed_plain": "Laatste synchronisatie mislukt.",
|
||||
"status_io": "De server was {when} niet bereikbaar. Android probeert het opnieuw.",
|
||||
"status_item_errors": "{count, plural, one {# item kon niet worden gesynchroniseerd.} other {# items konden niet worden gesynchroniseerd.}}",
|
||||
"status_off": "Uit",
|
||||
"status_paused": "Gepauzeerd in Android-instellingen",
|
||||
"status_permission_calendar": "Toegang tot agenda's is uitgezet, dus er wordt niets gesynchroniseerd.",
|
||||
"status_permission_contacts": "Toegang tot contacten is uitgezet, dus er wordt niets gesynchroniseerd.",
|
||||
"status_removed": "Verwijderd in Android-instellingen. Zet synchronisatie aan om het account opnieuw toe te voegen.",
|
||||
"status_safety_abort": "Gestopt: de server gaf niets terug terwijl dit apparaat nog items bevat. Controleer de server en synchroniseer daarna opnieuw.",
|
||||
"status_synced": "Laatst gesynchroniseerd: {when}",
|
||||
"status_syncing": "Synchroniseren…",
|
||||
"status_too_many_deletions": "Er zijn veel items verwijderd op dit apparaat. Ze worden pas op de server verwijderd als u dat bevestigt.",
|
||||
"status_unsupported_calendar": "De server biedt geen agenda's aan voor dit account.",
|
||||
"status_unsupported_contacts": "De server biedt geen adresboeken aan voor dit account.",
|
||||
"status_waiting": "Wachten op de eerste synchronisatie",
|
||||
"status_waiting_next": "Wachten op de volgende synchronisatie",
|
||||
"suspended_desc": "Afgemeld. De contacten en agenda's van het account blijven met de hier gemaakte wijzigingen op dit apparaat en worden weer gesynchroniseerd zodra u zich aanmeldt.",
|
||||
"switch_label": "{account} synchroniseren met dit apparaat",
|
||||
"sync_now": "Nu synchroniseren",
|
||||
"turn_off_anyway": "Uitzetten",
|
||||
"turn_off_failed": "De gesynchroniseerde gegevens konden niet van dit apparaat worden verwijderd. Synchronisatie is gepauzeerd; probeer het later opnieuw.",
|
||||
"turn_off_failed_title": "Synchronisatie kon niet worden uitgezet",
|
||||
"turn_off_pending": "{count, plural, one {# wijziging op dit apparaat heeft de server nog niet bereikt. Die gaat verloren als u synchronisatie nu uitzet.} other {# wijzigingen op dit apparaat hebben de server nog niet bereikt. Die gaan verloren als u synchronisatie nu uitzet.}}",
|
||||
"turn_off_title": "Toch uitzetten?",
|
||||
"turn_off_unknown": "Bulwark kon niet controleren of wijzigingen op dit apparaat de server hebben bereikt. Ze gaan verloren als u synchronisatie nu uitzet.",
|
||||
"turning_off": "Wijzigingen uploaden en daarna de gegevens van dit apparaat verwijderen…",
|
||||
"turning_on": "Instellen…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Sjabloon voor het opslaan of delen van een bijlage."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Do {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Pokazuj swoje kalendarze w aplikacji Kalendarz i innych aplikacjach na tym urządzeniu. Zmiany są synchronizowane w obie strony.",
|
||||
"device_sync_title": "Synchronizuj z tym urządzeniem",
|
||||
"show_week_numbers_mobile_desc": "Wyświetlaj numery tygodni w widoku miesiąca.",
|
||||
"time_zone": "Strefa czasowa",
|
||||
"time_zone_auto": "Strefa czasowa urządzenia",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etykieta",
|
||||
"remove_color": "Usuń etykietę"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Serwer nie akceptuje już logowania konta {account}, więc jego kontakty i kalendarze na tym urządzeniu przestały się synchronizować. Zaloguj się ponownie, aby wznowić synchronizację. Zmiany wprowadzone na tym urządzeniu zostaną zachowane.",
|
||||
"auth_problem_title": "Zaloguj się ponownie, aby dalej synchronizować",
|
||||
"problem_channel": "Problemy z synchronizacją",
|
||||
"sign_out_anyway": "Wyloguj się",
|
||||
"sign_out_pending_message": "{count, plural, one {# zmiana wprowadzona na tym urządzeniu nie dotarła do serwera. Zostanie utracona, jeśli wylogujesz się teraz.} few {# zmiany wprowadzone na tym urządzeniu nie dotarły do serwera. Zostaną utracone, jeśli wylogujesz się teraz.} many {# zmian wprowadzonych na tym urządzeniu nie dotarło do serwera. Zostaną utracone, jeśli wylogujesz się teraz.} other {# zmiany wprowadzonej na tym urządzeniu nie dotarło do serwera. Zostaną utracone, jeśli wylogujesz się teraz.}}",
|
||||
"sign_out_pending_title": "Wylogować się mimo to?",
|
||||
"sign_out_unknown_message": "Zmiany wprowadzone na tym urządzeniu mogły nie dotrzeć do serwera. Zostaną utracone, jeśli wylogujesz się teraz.",
|
||||
"signed_out_text": "Konto {account} zostało wylogowane, więc jego kontakty i kalendarze na tym urządzeniu nie są już synchronizowane. Zaloguj się ponownie, aby wznowić synchronizację. Zmiany wprowadzone na tym urządzeniu zostaną zachowane.",
|
||||
"signed_out_title": "Zaloguj się, aby dalej synchronizować",
|
||||
"signing_out": "Usuwanie zsynchronizowanych kontaktów i kalendarzy z tego urządzenia…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Aparat",
|
||||
"attachment_reminder_body": "Twoja wiadomość wspomina o „{keyword}”, ale nie załączono żadnego pliku. Wysłać mimo to?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktów} other {# kontaktu}}",
|
||||
"default_book": "Domyślna książka adresowa",
|
||||
"description_mobile": "Preferencje wyświetlania i narzędzia książek adresowych.",
|
||||
"device_sync_desc": "Pokazuj swoje książki adresowe w aplikacji Kontakty i innych aplikacjach na tym urządzeniu. Zmiany są synchronizowane w obie strony.",
|
||||
"device_sync_title": "Synchronizuj z tym urządzeniem",
|
||||
"export_description_count": "{count, plural, one {Eksportuj # kontakt jako jeden plik vCard.} few {Eksportuj # kontakty jako jeden plik vCard.} many {Eksportuj # kontaktów jako jeden plik vCard.} other {Eksportuj # kontaktu jako jeden plik vCard.}}",
|
||||
"export_empty": "Brak kontaktów do eksportu",
|
||||
"import_description_mobile": "Importuj kontakty z pliku vCard (.vcf) z wykrywaniem duplikatów.",
|
||||
"manage_description_mobile": "Twórz, zmieniaj nazwy i usuwaj książki adresowe, w których porządkujesz kontakty."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Synchronizuje się także dla {accounts}, więc pojawia się na tym urządzeniu dwa razy.",
|
||||
"android_settings": "Ustawienia konta w Androidzie",
|
||||
"blocked_calendar": "Android nie prosi już o dostęp do kalendarzy. Zezwól na niego w ustawieniach aplikacji, a potem ponownie włącz synchronizację.",
|
||||
"blocked_contacts": "Android nie prosi już o dostęp do kontaktów. Zezwól na niego w ustawieniach aplikacji, a potem ponownie włącz synchronizację.",
|
||||
"blocked_title": "Dostęp jest wyłączony",
|
||||
"books_default": "Twoje własne książki adresowe są synchronizowane, a udostępnione tylko wtedy, gdy je wybierzesz.",
|
||||
"books_hint": "Twoje własne książki adresowe są synchronizowane, chyba że je wyłączysz, a udostępnione tylko wtedy, gdy je włączysz.",
|
||||
"books_label": "Książki adresowe",
|
||||
"calendars_default": "Twoje własne kalendarze są synchronizowane, a udostępnione tylko wtedy, gdy je wybierzesz.",
|
||||
"calendars_hint": "Twoje własne kalendarze są synchronizowane, chyba że je wyłączysz, a udostępnione tylko wtedy, gdy je włączysz. Listy zadań pozostają w Bulwark.",
|
||||
"calendars_label": "Kalendarze",
|
||||
"choose": "Wybierz",
|
||||
"choose_books_title": "Książki adresowe do synchronizacji",
|
||||
"choose_calendars_title": "Kalendarze do synchronizacji",
|
||||
"collections_count": "Synchronizowane: {selected} z {total}",
|
||||
"continue": "Dalej",
|
||||
"deletions_delete": "Usuń na serwerze",
|
||||
"deletions_message": "Na tym urządzeniu usunięto naraz wiele elementów, więc nie zostały usunięte na serwerze. Usuń je także tam albo przywróć je na tym urządzeniu.",
|
||||
"deletions_restore": "Przywróć je",
|
||||
"deletions_title": "Usunąć je także na serwerze?",
|
||||
"denied_calendar": "Bez dostępu do kalendarzy Bulwark nie może umieścić Twoich kalendarzy na tym urządzeniu.",
|
||||
"denied_contacts": "Bez dostępu do kontaktów Bulwark nie może umieścić Twoich książek adresowych na tym urządzeniu.",
|
||||
"denied_title": "Synchronizacja pozostaje wyłączona",
|
||||
"failed_title": "Nie udało się włączyć synchronizacji",
|
||||
"grant_access": "Przyznaj dostęp",
|
||||
"interval_15m": "Co 15 minut",
|
||||
"interval_1h": "Co godzinę",
|
||||
"interval_30m": "Co 30 minut",
|
||||
"interval_6h": "Co 6 godzin",
|
||||
"interval_desc": "Jak często Android synchronizuje samodzielnie. Zmiany w Bulwark i zmiany zgłoszone przez push są synchronizowane szybciej.",
|
||||
"interval_label": "Częstotliwość synchronizacji",
|
||||
"interval_manual": "Bez regularnej synchronizacji",
|
||||
"keep_syncing": "Synchronizuj dalej",
|
||||
"list_error_auth": "Serwer nie zaakceptował logowania.",
|
||||
"list_error_network": "Nie udało się połączyć z serwerem.",
|
||||
"list_error_server": "Serwer nie mógł udostępnić listy.",
|
||||
"list_error_signed_out": "To konto nie jest zalogowane na tym urządzeniu.",
|
||||
"master_off": "Autosynchronizacja jest wyłączona na tym urządzeniu, więc synchronizacja odbywa się tylko po stuknięciu „Synchronizuj teraz”.",
|
||||
"new_contacts_default": "Domyślna książka adresowa",
|
||||
"new_contacts_desc": "Książka adresowa dla kontaktów utworzonych w innych aplikacjach na tym urządzeniu.",
|
||||
"new_contacts_label": "Zapisuj nowe kontakty w",
|
||||
"no_books": "To konto nie ma książek adresowych.",
|
||||
"no_calendars": "To konto nie ma kalendarzy do synchronizacji.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Otwórz ustawienia",
|
||||
"own_collections": "Własne",
|
||||
"rationale_calendar": "Aby pokazywać Twoje kalendarze w aplikacji Kalendarz, Bulwark potrzebuje dostępu do kalendarzy na tym urządzeniu. Zmienia tylko kalendarze własnego konta.",
|
||||
"rationale_contacts": "Aby pokazywać Twoje książki adresowe w aplikacji Kontakty, Bulwark potrzebuje dostępu do kontaktów na tym urządzeniu. Zmienia tylko kontakty własnego konta.",
|
||||
"rationale_title_calendar": "Zezwól na dostęp do kalendarzy",
|
||||
"rationale_title_contacts": "Zezwól na dostęp do kontaktów",
|
||||
"read_only": "Tylko do odczytu",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Aplikacja Kalendarz",
|
||||
"reminder_question_message": "Przypomnienia o zsynchronizowanych wydarzeniach mogą pochodzić z aplikacji kalendarza na tym urządzeniu albo z Bulwark. Wybierz jedno źródło, aby nie dostawać przypomnień podwójnie. Możesz to później zmienić.",
|
||||
"reminder_question_title": "Kto ma Ci przypominać?",
|
||||
"reminders_desc": "Kto przypomina Ci o zsynchronizowanych wydarzeniach. Drugie źródło milczy, więc nie dostajesz przypomnień podwójnie.",
|
||||
"reminders_label": "Przypomnienia",
|
||||
"reminders_nobody": "Powiadomienia o wydarzeniach w Bulwark są wyłączone, więc nic nie przypomina Ci o zsynchronizowanych wydarzeniach.",
|
||||
"remove": "Usuń",
|
||||
"remove_from_device": "Usuń z tego urządzenia",
|
||||
"remove_message": "Kontakty i kalendarze tego konta zostaną usunięte z tego urządzenia. Zmiany wprowadzone tutaj, które nie dotarły do serwera, zostaną utracone.",
|
||||
"remove_title": "Usunąć z tego urządzenia?",
|
||||
"review_deletions": "Przejrzyj usunięcia",
|
||||
"shared_by": "Udostępnione przez: {name}",
|
||||
"sign_in_again": "Zaloguj się ponownie",
|
||||
"status_auth": "Serwer nie akceptuje już logowania. Zaloguj się ponownie, aby dalej synchronizować.",
|
||||
"status_cancelled": "Ostatnia synchronizacja zatrzymała się przedwcześnie. Będzie kontynuowana następnym razem.",
|
||||
"status_failed": "Ostatnia synchronizacja nie powiodła się: {message}",
|
||||
"status_failed_plain": "Ostatnia synchronizacja nie powiodła się.",
|
||||
"status_io": "Nie udało się połączyć z serwerem {when}. Android spróbuje ponownie.",
|
||||
"status_item_errors": "{count, plural, one {Nie udało się zsynchronizować # elementu.} few {Nie udało się zsynchronizować # elementów.} many {Nie udało się zsynchronizować # elementów.} other {Nie udało się zsynchronizować # elementu.}}",
|
||||
"status_off": "Wyłączona",
|
||||
"status_paused": "Wstrzymana w ustawieniach Androida",
|
||||
"status_permission_calendar": "Dostęp do kalendarzy został wyłączony, więc nic nie jest synchronizowane.",
|
||||
"status_permission_contacts": "Dostęp do kontaktów został wyłączony, więc nic nie jest synchronizowane.",
|
||||
"status_removed": "Usunięte w ustawieniach Androida. Włącz synchronizację, aby ponownie dodać konto.",
|
||||
"status_safety_abort": "Zatrzymano: serwer nic nie zwrócił, choć na tym urządzeniu wciąż są elementy. Sprawdź serwer, a potem zsynchronizuj ponownie.",
|
||||
"status_synced": "Zsynchronizowano {when}",
|
||||
"status_syncing": "Synchronizowanie…",
|
||||
"status_too_many_deletions": "Na tym urządzeniu usunięto wiele elementów. Nie zostaną usunięte na serwerze, dopóki tego nie potwierdzisz.",
|
||||
"status_unsupported_calendar": "Serwer nie udostępnia kalendarzy dla tego konta.",
|
||||
"status_unsupported_contacts": "Serwer nie udostępnia książek adresowych dla tego konta.",
|
||||
"status_waiting": "Oczekiwanie na pierwszą synchronizację",
|
||||
"status_waiting_next": "Oczekiwanie na następną synchronizację",
|
||||
"suspended_desc": "Wylogowano. Kontakty i kalendarze tego konta pozostają na tym urządzeniu razem ze zmianami wprowadzonymi tutaj i zostaną ponownie zsynchronizowane po zalogowaniu.",
|
||||
"switch_label": "Synchronizuj {account} z tym urządzeniem",
|
||||
"sync_now": "Synchronizuj teraz",
|
||||
"turn_off_anyway": "Wyłącz",
|
||||
"turn_off_failed": "Nie udało się usunąć zsynchronizowanych danych z tego urządzenia. Synchronizacja jest wstrzymana; spróbuj ponownie później.",
|
||||
"turn_off_failed_title": "Nie udało się wyłączyć synchronizacji",
|
||||
"turn_off_pending": "{count, plural, one {# zmiana wprowadzona na tym urządzeniu nie dotarła jeszcze do serwera. Zostanie utracona, jeśli teraz wyłączysz synchronizację.} few {# zmiany wprowadzone na tym urządzeniu nie dotarły jeszcze do serwera. Zostaną utracone, jeśli teraz wyłączysz synchronizację.} many {# zmian wprowadzonych na tym urządzeniu nie dotarło jeszcze do serwera. Zostaną utracone, jeśli teraz wyłączysz synchronizację.} other {# zmiany wprowadzonej na tym urządzeniu nie dotarło jeszcze do serwera. Zostaną utracone, jeśli teraz wyłączysz synchronizację.}}",
|
||||
"turn_off_title": "Wyłączyć mimo to?",
|
||||
"turn_off_unknown": "Bulwark nie mógł sprawdzić, czy zmiany wprowadzone na tym urządzeniu dotarły do serwera. Zostaną utracone, jeśli teraz wyłączysz synchronizację.",
|
||||
"turning_off": "Wysyłanie zmian, a potem usuwanie danych z tego urządzenia…",
|
||||
"turning_on": "Konfigurowanie…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Szablon używany przy zapisywaniu lub udostępnianiu załącznika."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Até {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Mostre seus calendários no app Agenda e em outros apps deste dispositivo. As alterações são sincronizadas nos dois sentidos.",
|
||||
"device_sync_title": "Sincronizar com este dispositivo",
|
||||
"show_week_numbers_mobile_desc": "Exibir números da semana na visualização mensal.",
|
||||
"time_zone": "Fuso horário",
|
||||
"time_zone_auto": "Fuso horário do dispositivo",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Remover etiqueta"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "O servidor não aceita mais o login de {account}, então os contatos e calendários dessa conta neste dispositivo pararam de ser sincronizados. Faça login novamente para retomar. As alterações feitas neste dispositivo são mantidas.",
|
||||
"auth_problem_title": "Faça login novamente para continuar sincronizando",
|
||||
"problem_channel": "Problemas de sincronização",
|
||||
"sign_out_anyway": "Sair",
|
||||
"sign_out_pending_message": "{count, plural, one {# alteração feita neste dispositivo não chegou ao servidor. Ela será perdida se você sair agora.} other {# alterações feitas neste dispositivo não chegaram ao servidor. Elas serão perdidas se você sair agora.}}",
|
||||
"sign_out_pending_title": "Sair mesmo assim?",
|
||||
"sign_out_unknown_message": "As alterações feitas neste dispositivo podem não ter chegado ao servidor. Elas serão perdidas se você sair agora.",
|
||||
"signed_out_text": "A sessão de {account} foi encerrada, então os contatos e calendários dessa conta neste dispositivo não são mais sincronizados. Faça login novamente para retomar. As alterações feitas neste dispositivo são mantidas.",
|
||||
"signed_out_title": "Faça login para continuar sincronizando",
|
||||
"signing_out": "Removendo contatos e calendários sincronizados deste dispositivo…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Câmera",
|
||||
"attachment_reminder_body": "Sua mensagem menciona \"{keyword}\", mas nenhum arquivo está anexado. Enviar mesmo assim?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contato} other {# contatos}}",
|
||||
"default_book": "Catálogo de endereços padrão",
|
||||
"description_mobile": "Preferências de exibição e ferramentas do catálogo de endereços.",
|
||||
"device_sync_desc": "Mostre seus catálogos de endereços no app Contatos e em outros apps deste dispositivo. As alterações são sincronizadas nos dois sentidos.",
|
||||
"device_sync_title": "Sincronizar com este dispositivo",
|
||||
"export_description_count": "{count, plural, one {Exporta # contato como um único arquivo vCard.} other {Exporta # contatos como um único arquivo vCard.}}",
|
||||
"export_empty": "Nenhum contato para exportar",
|
||||
"import_description_mobile": "Importa contatos de um arquivo vCard (.vcf) com detecção de duplicados.",
|
||||
"manage_description_mobile": "Crie, renomeie e remova os catálogos de endereços que organizam seus contatos."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Também é sincronizado para {accounts}, por isso aparece duas vezes neste dispositivo.",
|
||||
"android_settings": "Configurações de conta do Android",
|
||||
"blocked_calendar": "O Android não pede mais acesso aos calendários. Permita o acesso nas configurações do app e ative a sincronização de novo.",
|
||||
"blocked_contacts": "O Android não pede mais acesso aos contatos. Permita o acesso nas configurações do app e ative a sincronização de novo.",
|
||||
"blocked_title": "O acesso está desativado",
|
||||
"books_default": "Seus catálogos de endereços são sincronizados; os compartilhados, só quando você os escolher.",
|
||||
"books_hint": "Seus catálogos de endereços são sincronizados, a menos que você os desative; os compartilhados, só quando você os ativar.",
|
||||
"books_label": "Catálogos de endereços",
|
||||
"calendars_default": "Seus calendários são sincronizados; os compartilhados, só quando você os escolher.",
|
||||
"calendars_hint": "Seus calendários são sincronizados, a menos que você os desative; os compartilhados, só quando você os ativar. As listas de tarefas ficam no Bulwark.",
|
||||
"calendars_label": "Calendários",
|
||||
"choose": "Escolher",
|
||||
"choose_books_title": "Catálogos de endereços para sincronizar",
|
||||
"choose_calendars_title": "Calendários para sincronizar",
|
||||
"collections_count": "{selected} de {total} sincronizados",
|
||||
"continue": "Continuar",
|
||||
"deletions_delete": "Excluir no servidor",
|
||||
"deletions_message": "Muitos itens foram excluídos de uma vez neste dispositivo, por isso não foram excluídos no servidor. Exclua-os lá também ou restaure-os neste dispositivo.",
|
||||
"deletions_restore": "Restaurar",
|
||||
"deletions_title": "Excluir também no servidor?",
|
||||
"denied_calendar": "Sem acesso aos calendários, o Bulwark não consegue colocar seus calendários neste dispositivo.",
|
||||
"denied_contacts": "Sem acesso aos contatos, o Bulwark não consegue colocar seus catálogos de endereços neste dispositivo.",
|
||||
"denied_title": "A sincronização continua desativada",
|
||||
"failed_title": "Não foi possível ativar a sincronização",
|
||||
"grant_access": "Conceder acesso",
|
||||
"interval_15m": "A cada 15 minutos",
|
||||
"interval_1h": "A cada hora",
|
||||
"interval_30m": "A cada 30 minutos",
|
||||
"interval_6h": "A cada 6 horas",
|
||||
"interval_desc": "Com que frequência o Android sincroniza por conta própria. Edições no Bulwark e alterações avisadas por push são sincronizadas antes.",
|
||||
"interval_label": "Intervalo de sincronização",
|
||||
"interval_manual": "Sem sincronização periódica",
|
||||
"keep_syncing": "Continuar sincronizando",
|
||||
"list_error_auth": "O servidor não aceitou o login.",
|
||||
"list_error_network": "Não foi possível acessar o servidor.",
|
||||
"list_error_server": "O servidor não conseguiu listá-los.",
|
||||
"list_error_signed_out": "Esta conta não está conectada neste dispositivo.",
|
||||
"master_off": "A sincronização automática está desativada neste dispositivo, então só há sincronização quando você toca em \"Sincronizar agora\".",
|
||||
"new_contacts_default": "Catálogo de endereços padrão",
|
||||
"new_contacts_desc": "O catálogo de endereços para contatos criados em outros apps deste dispositivo.",
|
||||
"new_contacts_label": "Salvar novos contatos em",
|
||||
"no_books": "Esta conta não tem catálogos de endereços.",
|
||||
"no_calendars": "Esta conta não tem calendários para sincronizar.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Abrir configurações do app",
|
||||
"own_collections": "Seus",
|
||||
"rationale_calendar": "Para mostrar seus calendários no app Agenda, o Bulwark precisa de acesso aos calendários deste dispositivo. Ele só altera os calendários da própria conta.",
|
||||
"rationale_contacts": "Para mostrar seus catálogos de endereços no app Contatos, o Bulwark precisa de acesso aos contatos deste dispositivo. Ele só altera os contatos da própria conta.",
|
||||
"rationale_title_calendar": "Permitir acesso aos calendários",
|
||||
"rationale_title_contacts": "Permitir acesso aos contatos",
|
||||
"read_only": "Somente leitura",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "App Agenda",
|
||||
"reminder_question_message": "Os lembretes de eventos sincronizados podem vir do app de agenda deste dispositivo ou do Bulwark. Escolha um para não receber lembretes em dobro. Você pode mudar isso depois.",
|
||||
"reminder_question_title": "Quem deve lembrar você?",
|
||||
"reminders_desc": "Quem lembra você dos eventos sincronizados. O outro fica em silêncio, para que você não receba lembretes em dobro.",
|
||||
"reminders_label": "Lembretes",
|
||||
"reminders_nobody": "As notificações de eventos do Bulwark estão desativadas, então nada vai lembrar você dos eventos sincronizados.",
|
||||
"remove": "Remover",
|
||||
"remove_from_device": "Remover deste dispositivo",
|
||||
"remove_message": "Os contatos e calendários da conta são removidos deste dispositivo. As alterações feitas aqui que não chegaram ao servidor são perdidas.",
|
||||
"remove_title": "Remover deste dispositivo?",
|
||||
"review_deletions": "Revisar exclusões",
|
||||
"shared_by": "Compartilhado por {name}",
|
||||
"sign_in_again": "Fazer login novamente",
|
||||
"status_auth": "O servidor não aceita mais o login. Faça login novamente para continuar sincronizando.",
|
||||
"status_cancelled": "A última sincronização parou antes do fim. Ela continua na próxima vez.",
|
||||
"status_failed": "Falha na última sincronização: {message}",
|
||||
"status_failed_plain": "Falha na última sincronização.",
|
||||
"status_io": "Não foi possível acessar o servidor {when}. O Android vai tentar de novo.",
|
||||
"status_item_errors": "{count, plural, one {Não foi possível sincronizar # item.} other {Não foi possível sincronizar # itens.}}",
|
||||
"status_off": "Desativada",
|
||||
"status_paused": "Pausada nas configurações do Android",
|
||||
"status_permission_calendar": "O acesso aos calendários foi desativado, então nada é sincronizado.",
|
||||
"status_permission_contacts": "O acesso aos contatos foi desativado, então nada é sincronizado.",
|
||||
"status_removed": "Conta removida nas configurações do Android. Ative a sincronização para adicioná-la de novo.",
|
||||
"status_safety_abort": "Interrompida: o servidor não retornou nada, mas este dispositivo ainda tem itens. Verifique o servidor e sincronize de novo.",
|
||||
"status_synced": "Sincronizado {when}",
|
||||
"status_syncing": "Sincronizando…",
|
||||
"status_too_many_deletions": "Muitos itens foram excluídos neste dispositivo. Eles só serão excluídos no servidor quando você confirmar.",
|
||||
"status_unsupported_calendar": "O servidor não oferece calendários para esta conta.",
|
||||
"status_unsupported_contacts": "O servidor não oferece catálogos de endereços para esta conta.",
|
||||
"status_waiting": "Aguardando a primeira sincronização",
|
||||
"status_waiting_next": "Aguardando a próxima sincronização",
|
||||
"suspended_desc": "Sessão encerrada. Os contatos e calendários da conta continuam neste dispositivo, com as alterações feitas aqui, e voltam a sincronizar quando você fizer login.",
|
||||
"switch_label": "Sincronizar {account} com este dispositivo",
|
||||
"sync_now": "Sincronizar agora",
|
||||
"turn_off_anyway": "Desativar",
|
||||
"turn_off_failed": "Não foi possível remover os dados sincronizados deste dispositivo. A sincronização está pausada; tente novamente mais tarde.",
|
||||
"turn_off_failed_title": "Não foi possível desativar a sincronização",
|
||||
"turn_off_pending": "{count, plural, one {# alteração feita neste dispositivo ainda não chegou ao servidor. Ela será perdida se você desativar a sincronização agora.} other {# alterações feitas neste dispositivo ainda não chegaram ao servidor. Elas serão perdidas se você desativar a sincronização agora.}}",
|
||||
"turn_off_title": "Desativar mesmo assim?",
|
||||
"turn_off_unknown": "O Bulwark não conseguiu verificar se as alterações feitas neste dispositivo chegaram ao servidor. Elas serão perdidas se você desativar a sincronização agora.",
|
||||
"turning_off": "Enviando alterações e depois removendo os dados deste dispositivo…",
|
||||
"turning_on": "Configurando…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Modelo usado ao salvar ou compartilhar um anexo."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Până la {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Afișați calendarele în aplicația Calendar și în alte aplicații de pe acest dispozitiv. Modificările se sincronizează în ambele sensuri.",
|
||||
"device_sync_title": "Sincronizare cu acest dispozitiv",
|
||||
"show_week_numbers_mobile_desc": "Afișați numerele săptămânilor în vizualizarea lunară.",
|
||||
"time_zone": "Fus orar",
|
||||
"time_zone_auto": "Fusul orar al dispozitivului",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etichetă",
|
||||
"remove_color": "Eliminați eticheta"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Serverul nu mai acceptă autentificarea pentru {account}, așa că contactele și calendarele acestui cont de pe acest dispozitiv nu se mai sincronizează. Autentificați-vă din nou pentru a relua sincronizarea. Modificările făcute pe acest dispozitiv se păstrează.",
|
||||
"auth_problem_title": "Autentificați-vă din nou pentru a continua sincronizarea",
|
||||
"problem_channel": "Probleme de sincronizare",
|
||||
"sign_out_anyway": "Deconectare",
|
||||
"sign_out_pending_message": "{count, plural, one {# modificare făcută pe acest dispozitiv nu a ajuns pe server. Se va pierde dacă vă deconectați acum.} few {# modificări făcute pe acest dispozitiv nu au ajuns pe server. Se vor pierde dacă vă deconectați acum.} other {# de modificări făcute pe acest dispozitiv nu au ajuns pe server. Se vor pierde dacă vă deconectați acum.}}",
|
||||
"sign_out_pending_title": "Vă deconectați totuși?",
|
||||
"sign_out_unknown_message": "Este posibil ca modificările făcute pe acest dispozitiv să nu fi ajuns pe server. Se vor pierde dacă vă deconectați acum.",
|
||||
"signed_out_text": "{account} a fost deconectat, așa că contactele și calendarele acestui cont de pe acest dispozitiv nu se mai sincronizează. Autentificați-vă din nou pentru a relua sincronizarea. Modificările făcute pe acest dispozitiv se păstrează.",
|
||||
"signed_out_title": "Autentificați-vă pentru a continua sincronizarea",
|
||||
"signing_out": "Se elimină de pe acest dispozitiv contactele și calendarele sincronizate…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Cameră",
|
||||
"attachment_reminder_body": "Mesajul dvs. menționează „{keyword}”, dar nu este atașat niciun fișier. Doriți să trimiteți oricum?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# contact} few {# contacte} other {# de contacte}}",
|
||||
"default_book": "Agendă implicită",
|
||||
"description_mobile": "Preferințe de afișare și instrumente pentru agende.",
|
||||
"device_sync_desc": "Afișați agendele de adrese în aplicația Contacte și în alte aplicații de pe acest dispozitiv. Modificările se sincronizează în ambele sensuri.",
|
||||
"device_sync_title": "Sincronizare cu acest dispozitiv",
|
||||
"export_description_count": "{count, plural, one {Exportă # contact ca un singur fișier vCard.} few {Exportă # contacte ca un singur fișier vCard.} other {Exportă # de contacte ca un singur fișier vCard.}}",
|
||||
"export_empty": "Nu există contacte de exportat",
|
||||
"import_description_mobile": "Importă contacte dintr-un fișier vCard (.vcf), cu detectarea duplicatelor.",
|
||||
"manage_description_mobile": "Creați, redenumiți și eliminați agendele care vă organizează contactele."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Se sincronizează și pentru {accounts}, așa că apare de două ori pe acest dispozitiv.",
|
||||
"android_settings": "Setările contului Android",
|
||||
"blocked_calendar": "Android nu mai solicită accesul la calendare. Permiteți-l din setările aplicației, apoi activați din nou sincronizarea.",
|
||||
"blocked_contacts": "Android nu mai solicită accesul la contacte. Permiteți-l din setările aplicației, apoi activați din nou sincronizarea.",
|
||||
"blocked_title": "Accesul este dezactivat",
|
||||
"books_default": "Agendele dvs. de adrese se sincronizează; cele partajate doar dacă le alegeți.",
|
||||
"books_hint": "Agendele dvs. de adrese se sincronizează dacă nu le dezactivați; cele partajate doar dacă le activați.",
|
||||
"books_label": "Agende de adrese",
|
||||
"calendars_default": "Calendarele dvs. se sincronizează; cele partajate doar dacă le alegeți.",
|
||||
"calendars_hint": "Calendarele dvs. se sincronizează dacă nu le dezactivați; cele partajate doar dacă le activați. Listele de sarcini rămân în Bulwark.",
|
||||
"calendars_label": "Calendare",
|
||||
"choose": "Alegeți",
|
||||
"choose_books_title": "Agende de adrese de sincronizat",
|
||||
"choose_calendars_title": "Calendare de sincronizat",
|
||||
"collections_count": "Sincronizate: {selected} din {total}",
|
||||
"continue": "Continuați",
|
||||
"deletions_delete": "Ștergeți de pe server",
|
||||
"deletions_message": "Multe elemente au fost șterse deodată pe acest dispozitiv, așa că nu au fost șterse de pe server. Ștergeți-le și acolo sau readuceți-le pe acest dispozitiv.",
|
||||
"deletions_restore": "Readuceți-le",
|
||||
"deletions_title": "Le ștergeți și de pe server?",
|
||||
"denied_calendar": "Fără acces la calendare, Bulwark nu poate aduce calendarele dvs. pe acest dispozitiv.",
|
||||
"denied_contacts": "Fără acces la contacte, Bulwark nu poate aduce agendele dvs. de adrese pe acest dispozitiv.",
|
||||
"denied_title": "Sincronizarea rămâne dezactivată",
|
||||
"failed_title": "Sincronizarea nu a putut fi activată",
|
||||
"grant_access": "Acordați acces",
|
||||
"interval_15m": "La fiecare 15 minute",
|
||||
"interval_1h": "La fiecare oră",
|
||||
"interval_30m": "La fiecare 30 de minute",
|
||||
"interval_6h": "La fiecare 6 ore",
|
||||
"interval_desc": "Cât de des sincronizează Android de la sine. Modificările din Bulwark și cele anunțate prin push se sincronizează mai repede.",
|
||||
"interval_label": "Interval de sincronizare",
|
||||
"interval_manual": "Fără sincronizare periodică",
|
||||
"keep_syncing": "Continuați sincronizarea",
|
||||
"list_error_auth": "Serverul nu a acceptat autentificarea.",
|
||||
"list_error_network": "Serverul nu a putut fi contactat.",
|
||||
"list_error_server": "Serverul nu a putut furniza lista.",
|
||||
"list_error_signed_out": "Acest cont nu este autentificat pe acest dispozitiv.",
|
||||
"master_off": "Sincronizarea automată este dezactivată pe acest dispozitiv, așa că sincronizarea are loc doar când atingeți „Sincronizați acum”.",
|
||||
"new_contacts_default": "Agendă implicită",
|
||||
"new_contacts_desc": "Agenda de adrese pentru contactele create în alte aplicații de pe acest dispozitiv.",
|
||||
"new_contacts_label": "Contactele noi se salvează în",
|
||||
"no_books": "Acest cont nu are agende de adrese.",
|
||||
"no_calendars": "Acest cont nu are calendare de sincronizat.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Deschideți setările",
|
||||
"own_collections": "Ale dvs.",
|
||||
"rationale_calendar": "Pentru a afișa calendarele dvs. în aplicația Calendar, Bulwark are nevoie de acces la calendarele de pe acest dispozitiv. Modifică doar calendarele propriului cont.",
|
||||
"rationale_contacts": "Pentru a afișa agendele dvs. de adrese în aplicația Contacte, Bulwark are nevoie de acces la contactele de pe acest dispozitiv. Modifică doar contactele propriului cont.",
|
||||
"rationale_title_calendar": "Permiteți accesul la calendare",
|
||||
"rationale_title_contacts": "Permiteți accesul la contacte",
|
||||
"read_only": "Doar citire",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Aplicația Calendar",
|
||||
"reminder_question_message": "Mementourile pentru evenimentele sincronizate pot veni din aplicația de calendar de pe acest dispozitiv sau din Bulwark. Alegeți una dintre ele ca să nu primiți mementouri de două ori. Puteți schimba ulterior.",
|
||||
"reminder_question_title": "Cine să vă trimită mementouri?",
|
||||
"reminders_desc": "Cine vă amintește de evenimentele sincronizate. Cealaltă aplicație rămâne silențioasă, ca să nu primiți mementouri de două ori.",
|
||||
"reminders_label": "Mementouri",
|
||||
"reminders_nobody": "Notificările de evenimente din Bulwark sunt dezactivate, așa că nimic nu vă amintește de evenimentele sincronizate.",
|
||||
"remove": "Eliminați",
|
||||
"remove_from_device": "Eliminați de pe acest dispozitiv",
|
||||
"remove_message": "Contactele și calendarele contului vor fi eliminate de pe acest dispozitiv. Modificările făcute aici care nu au ajuns pe server se vor pierde.",
|
||||
"remove_title": "Eliminați de pe acest dispozitiv?",
|
||||
"review_deletions": "Verificați ștergerile",
|
||||
"shared_by": "Distribuit de {name}",
|
||||
"sign_in_again": "Autentificați-vă din nou",
|
||||
"status_auth": "Serverul nu mai acceptă autentificarea. Autentificați-vă din nou pentru a continua sincronizarea.",
|
||||
"status_cancelled": "Ultima sincronizare s-a oprit prea devreme. Va continua data viitoare.",
|
||||
"status_failed": "Ultima sincronizare a eșuat: {message}",
|
||||
"status_failed_plain": "Ultima sincronizare a eșuat.",
|
||||
"status_io": "Serverul nu a putut fi contactat {when}. Android va încerca din nou.",
|
||||
"status_item_errors": "{count, plural, one {# element nu a putut fi sincronizat.} few {# elemente nu au putut fi sincronizate.} other {# de elemente nu au putut fi sincronizate.}}",
|
||||
"status_off": "Dezactivată",
|
||||
"status_paused": "Întreruptă în setările Android",
|
||||
"status_permission_calendar": "Accesul la calendare a fost dezactivat, așa că nu se sincronizează nimic.",
|
||||
"status_permission_contacts": "Accesul la contacte a fost dezactivat, așa că nu se sincronizează nimic.",
|
||||
"status_removed": "Eliminat în setările Android. Activați sincronizarea pentru a adăuga din nou contul.",
|
||||
"status_safety_abort": "Oprită: serverul nu a returnat nimic, deși acest dispozitiv încă are elemente. Verificați serverul, apoi sincronizați din nou.",
|
||||
"status_synced": "Sincronizat {when}",
|
||||
"status_syncing": "Se sincronizează…",
|
||||
"status_too_many_deletions": "Multe elemente au fost șterse pe acest dispozitiv. Nu vor fi șterse de pe server până nu confirmați.",
|
||||
"status_unsupported_calendar": "Serverul nu oferă calendare pentru acest cont.",
|
||||
"status_unsupported_contacts": "Serverul nu oferă agende de adrese pentru acest cont.",
|
||||
"status_waiting": "Se așteaptă prima sincronizare",
|
||||
"status_waiting_next": "Se așteaptă următoarea sincronizare",
|
||||
"suspended_desc": "Deconectat. Contactele și calendarele contului rămân pe acest dispozitiv, împreună cu modificările făcute aici, și se vor sincroniza din nou după ce vă autentificați.",
|
||||
"switch_label": "Sincronizați {account} cu acest dispozitiv",
|
||||
"sync_now": "Sincronizați acum",
|
||||
"turn_off_anyway": "Dezactivați",
|
||||
"turn_off_failed": "Datele sincronizate nu au putut fi eliminate de pe acest dispozitiv. Sincronizarea este întreruptă; încercați din nou mai târziu.",
|
||||
"turn_off_failed_title": "Sincronizarea nu a putut fi dezactivată",
|
||||
"turn_off_pending": "{count, plural, one {# modificare făcută pe acest dispozitiv nu a ajuns încă pe server. Se va pierde dacă dezactivați acum sincronizarea.} few {# modificări făcute pe acest dispozitiv nu au ajuns încă pe server. Se vor pierde dacă dezactivați acum sincronizarea.} other {# de modificări făcute pe acest dispozitiv nu au ajuns încă pe server. Se vor pierde dacă dezactivați acum sincronizarea.}}",
|
||||
"turn_off_title": "Dezactivați totuși?",
|
||||
"turn_off_unknown": "Bulwark nu a putut verifica dacă modificările făcute pe acest dispozitiv au ajuns pe server. Se vor pierde dacă dezactivați acum sincronizarea.",
|
||||
"turning_off": "Se încarcă modificările, apoi se elimină datele de pe acest dispozitiv…",
|
||||
"turning_on": "Se configurează…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Șablon utilizat la salvarea sau partajarea unui atașament."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "До {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Ваши календари будут видны в приложении «Календарь» и других приложениях на этом устройстве. Изменения синхронизируются в обе стороны.",
|
||||
"device_sync_title": "Синхронизация с этим устройством",
|
||||
"show_week_numbers_mobile_desc": "Показывать номера недель в представлении «Месяц».",
|
||||
"time_zone": "Часовой пояс",
|
||||
"time_zone_auto": "Часовой пояс устройства",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Убрать тег"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Сервер больше не принимает вход в аккаунт {account}, поэтому его контакты и календари на этом устройстве больше не синхронизируются. Войдите снова, чтобы возобновить синхронизацию. Изменения, сделанные на этом устройстве, сохранятся.",
|
||||
"auth_problem_title": "Войдите снова, чтобы продолжить синхронизацию",
|
||||
"problem_channel": "Проблемы синхронизации",
|
||||
"sign_out_anyway": "Выйти",
|
||||
"sign_out_pending_message": "{count, plural, one {# изменение, сделанное на этом устройстве, не дошло до сервера. Оно будет потеряно, если вы сейчас выйдете.} few {# изменения, сделанные на этом устройстве, не дошли до сервера. Они будут потеряны, если вы сейчас выйдете.} many {# изменений, сделанных на этом устройстве, не дошли до сервера. Они будут потеряны, если вы сейчас выйдете.} other {# изменения, сделанного на этом устройстве, не дошли до сервера. Они будут потеряны, если вы сейчас выйдете.}}",
|
||||
"sign_out_pending_title": "Всё равно выйти?",
|
||||
"sign_out_unknown_message": "Изменения, сделанные на этом устройстве, могли не дойти до сервера. Они будут потеряны, если вы сейчас выйдете.",
|
||||
"signed_out_text": "Выполнен выход из аккаунта {account}, поэтому его контакты и календари на этом устройстве больше не синхронизируются. Войдите снова, чтобы возобновить синхронизацию. Изменения, сделанные на этом устройстве, сохранятся.",
|
||||
"signed_out_title": "Войдите, чтобы продолжить синхронизацию",
|
||||
"signing_out": "Удаление синхронизированных контактов и календарей с этого устройства…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Камера",
|
||||
"attachment_reminder_body": "В письме упоминается «{keyword}», но файл не прикреплён. Всё равно отправить?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# контакт} few {# контакта} many {# контактов} other {# контакта}}",
|
||||
"default_book": "Адресная книга по умолчанию",
|
||||
"description_mobile": "Параметры отображения и инструменты адресных книг.",
|
||||
"device_sync_desc": "Ваши адресные книги будут видны в приложении «Контакты» и других приложениях на этом устройстве. Изменения синхронизируются в обе стороны.",
|
||||
"device_sync_title": "Синхронизация с этим устройством",
|
||||
"export_description_count": "{count, plural, one {Экспортировать # контакт в один файл vCard.} few {Экспортировать # контакта в один файл vCard.} many {Экспортировать # контактов в один файл vCard.} other {Экспортировать # контакта в один файл vCard.}}",
|
||||
"export_empty": "Нет контактов для экспорта",
|
||||
"import_description_mobile": "Импорт контактов из файла vCard (.vcf) с поиском дубликатов.",
|
||||
"manage_description_mobile": "Создавайте, переименовывайте и удаляйте адресные книги, в которых хранятся ваши контакты."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Также синхронизируется для {accounts}, поэтому отображается на этом устройстве дважды.",
|
||||
"android_settings": "Настройки аккаунта Android",
|
||||
"blocked_calendar": "Android больше не запрашивает доступ к календарям. Разрешите его в настройках приложения, а затем снова включите синхронизацию.",
|
||||
"blocked_contacts": "Android больше не запрашивает доступ к контактам. Разрешите его в настройках приложения, а затем снова включите синхронизацию.",
|
||||
"blocked_title": "Доступ отключён",
|
||||
"books_default": "Ваши собственные адресные книги синхронизируются, а общие — только если вы их выберете.",
|
||||
"books_hint": "Ваши собственные адресные книги синхронизируются, если вы их не отключите, а общие — только если вы их включите.",
|
||||
"books_label": "Адресные книги",
|
||||
"calendars_default": "Ваши собственные календари синхронизируются, а общие — только если вы их выберете.",
|
||||
"calendars_hint": "Ваши собственные календари синхронизируются, если вы их не отключите, а общие — только если вы их включите. Списки задач остаются в Bulwark.",
|
||||
"calendars_label": "Календари",
|
||||
"choose": "Выбрать",
|
||||
"choose_books_title": "Адресные книги для синхронизации",
|
||||
"choose_calendars_title": "Календари для синхронизации",
|
||||
"collections_count": "Синхронизируется: {selected} из {total}",
|
||||
"continue": "Продолжить",
|
||||
"deletions_delete": "Удалить на сервере",
|
||||
"deletions_message": "На этом устройстве сразу было удалено много элементов, поэтому на сервере они не удалены. Удалите их и там или верните их на это устройство.",
|
||||
"deletions_restore": "Вернуть",
|
||||
"deletions_title": "Удалить их и на сервере?",
|
||||
"denied_calendar": "Без доступа к календарям Bulwark не сможет перенести ваши календари на это устройство.",
|
||||
"denied_contacts": "Без доступа к контактам Bulwark не сможет перенести ваши адресные книги на это устройство.",
|
||||
"denied_title": "Синхронизация остаётся выключенной",
|
||||
"failed_title": "Не удалось включить синхронизацию",
|
||||
"grant_access": "Предоставить доступ",
|
||||
"interval_15m": "Каждые 15 минут",
|
||||
"interval_1h": "Каждый час",
|
||||
"interval_30m": "Каждые 30 минут",
|
||||
"interval_6h": "Каждые 6 часов",
|
||||
"interval_desc": "Как часто Android синхронизирует данные самостоятельно. Изменения в Bulwark и изменения, о которых сообщает push, синхронизируются раньше.",
|
||||
"interval_label": "Интервал синхронизации",
|
||||
"interval_manual": "Без регулярной синхронизации",
|
||||
"keep_syncing": "Продолжить синхронизацию",
|
||||
"list_error_auth": "Сервер не принял вход.",
|
||||
"list_error_network": "Не удалось связаться с сервером.",
|
||||
"list_error_server": "Сервер не смог вернуть список.",
|
||||
"list_error_signed_out": "На этом устройстве не выполнен вход в этот аккаунт.",
|
||||
"master_off": "Автосинхронизация на этом устройстве выключена, поэтому синхронизация выполняется только при нажатии «Синхронизировать».",
|
||||
"new_contacts_default": "Адресная книга по умолчанию",
|
||||
"new_contacts_desc": "Адресная книга для контактов, созданных в других приложениях на этом устройстве.",
|
||||
"new_contacts_label": "Сохранять новые контакты в",
|
||||
"no_books": "В этом аккаунте нет адресных книг.",
|
||||
"no_calendars": "В этом аккаунте нет календарей для синхронизации.",
|
||||
"ok": "ОК",
|
||||
"open_app_settings": "Открыть настройки",
|
||||
"own_collections": "Ваши",
|
||||
"rationale_calendar": "Чтобы показывать ваши календари в приложении «Календарь», Bulwark нужен доступ к календарям на этом устройстве. Он изменяет только календари своего аккаунта.",
|
||||
"rationale_contacts": "Чтобы показывать ваши адресные книги в приложении «Контакты», Bulwark нужен доступ к контактам на этом устройстве. Он изменяет только контакты своего аккаунта.",
|
||||
"rationale_title_calendar": "Разрешить доступ к календарям",
|
||||
"rationale_title_contacts": "Разрешить доступ к контактам",
|
||||
"read_only": "Только для чтения",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Приложение «Календарь»",
|
||||
"reminder_question_message": "Напоминания о синхронизированных событиях может показывать приложение календаря на этом устройстве или Bulwark. Выберите что-то одно, чтобы не получать напоминания дважды. Это можно изменить позже.",
|
||||
"reminder_question_title": "Кто будет напоминать о событиях?",
|
||||
"reminders_desc": "Кто напоминает о синхронизированных событиях. Второе приложение молчит, чтобы напоминания не приходили дважды.",
|
||||
"reminders_label": "Напоминания",
|
||||
"reminders_nobody": "Уведомления о событиях в Bulwark выключены, поэтому о синхронизированных событиях ничто не напомнит.",
|
||||
"remove": "Удалить",
|
||||
"remove_from_device": "Удалить с этого устройства",
|
||||
"remove_message": "Контакты и календари аккаунта будут удалены с этого устройства. Сделанные здесь изменения, которые не дошли до сервера, будут потеряны.",
|
||||
"remove_title": "Удалить с этого устройства?",
|
||||
"review_deletions": "Проверить удаления",
|
||||
"shared_by": "Владелец: {name}",
|
||||
"sign_in_again": "Войти снова",
|
||||
"status_auth": "Сервер больше не принимает вход. Войдите снова, чтобы продолжить синхронизацию.",
|
||||
"status_cancelled": "Последняя синхронизация прервалась. Она продолжится в следующий раз.",
|
||||
"status_failed": "Ошибка последней синхронизации: {message}",
|
||||
"status_failed_plain": "Последняя синхронизация не удалась.",
|
||||
"status_io": "Не удалось связаться с сервером {when}. Android повторит попытку.",
|
||||
"status_item_errors": "{count, plural, one {Не удалось синхронизировать # элемент.} few {Не удалось синхронизировать # элемента.} many {Не удалось синхронизировать # элементов.} other {Не удалось синхронизировать # элемента.}}",
|
||||
"status_off": "Выкл.",
|
||||
"status_paused": "Приостановлено в настройках Android",
|
||||
"status_permission_calendar": "Доступ к календарям отключён, поэтому ничего не синхронизируется.",
|
||||
"status_permission_contacts": "Доступ к контактам отключён, поэтому ничего не синхронизируется.",
|
||||
"status_removed": "Удалено в настройках Android. Включите синхронизацию, чтобы снова добавить аккаунт.",
|
||||
"status_safety_abort": "Остановлено: сервер ничего не вернул, хотя на этом устройстве ещё есть элементы. Проверьте сервер и повторите синхронизацию.",
|
||||
"status_synced": "Синхронизировано {when}",
|
||||
"status_syncing": "Синхронизация…",
|
||||
"status_too_many_deletions": "На этом устройстве удалено много элементов. На сервере они будут удалены только после вашего подтверждения.",
|
||||
"status_unsupported_calendar": "Сервер не предоставляет календари для этого аккаунта.",
|
||||
"status_unsupported_contacts": "Сервер не предоставляет адресные книги для этого аккаунта.",
|
||||
"status_waiting": "Ожидание первой синхронизации",
|
||||
"status_waiting_next": "Ожидание следующей синхронизации",
|
||||
"suspended_desc": "Выполнен выход. Контакты и календари аккаунта остаются на этом устройстве вместе со сделанными здесь изменениями и снова синхронизируются после входа.",
|
||||
"switch_label": "Синхронизировать {account} с этим устройством",
|
||||
"sync_now": "Синхронизировать",
|
||||
"turn_off_anyway": "Выключить",
|
||||
"turn_off_failed": "Не удалось удалить синхронизированные данные с этого устройства. Синхронизация приостановлена; повторите попытку позже.",
|
||||
"turn_off_failed_title": "Не удалось выключить синхронизацию",
|
||||
"turn_off_pending": "{count, plural, one {# изменение, сделанное на этом устройстве, ещё не дошло до сервера. Оно будет потеряно, если сейчас выключить синхронизацию.} few {# изменения, сделанные на этом устройстве, ещё не дошли до сервера. Они будут потеряны, если сейчас выключить синхронизацию.} many {# изменений, сделанных на этом устройстве, ещё не дошли до сервера. Они будут потеряны, если сейчас выключить синхронизацию.} other {# изменения, сделанного на этом устройстве, ещё не дошли до сервера. Они будут потеряны, если сейчас выключить синхронизацию.}}",
|
||||
"turn_off_title": "Всё равно выключить?",
|
||||
"turn_off_unknown": "Bulwark не удалось проверить, дошли ли изменения, сделанные на этом устройстве, до сервера. Они будут потеряны, если сейчас выключить синхронизацию.",
|
||||
"turning_off": "Отправка изменений, затем удаление данных с этого устройства…",
|
||||
"turning_on": "Настройка…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Шаблон имени при сохранении вложения или при отправке его в другое приложение."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "Do {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Zobrazí vaše kalendáre v aplikácii Kalendár a ďalších aplikáciách v tomto zariadení. Zmeny sa synchronizujú oboma smermi.",
|
||||
"device_sync_title": "Synchronizovať s týmto zariadením",
|
||||
"show_week_numbers_mobile_desc": "Zobrazovať čísla týždňov v mesačnom zobrazení.",
|
||||
"time_zone": "Časové pásmo",
|
||||
"time_zone_auto": "Časové pásmo zariadenia",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Štítok",
|
||||
"remove_color": "Odstrániť štítok"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Server už neprijíma prihlásenie účtu {account}, preto sa jeho kontakty a kalendáre v tomto zariadení prestali synchronizovať. Ak chcete pokračovať, znova sa prihláste. Zmeny vykonané v tomto zariadení zostanú zachované.",
|
||||
"auth_problem_title": "Ak chcete ďalej synchronizovať, znova sa prihláste",
|
||||
"problem_channel": "Problémy so synchronizáciou",
|
||||
"sign_out_anyway": "Odhlásiť sa",
|
||||
"sign_out_pending_message": "{count, plural, one {# zmena vykonaná v tomto zariadení sa nedostala na server. Ak sa teraz odhlásite, stratí sa.} few {# zmeny vykonané v tomto zariadení sa nedostali na server. Ak sa teraz odhlásite, stratia sa.} many {# zmeny vykonanej v tomto zariadení sa nedostalo na server. Ak sa teraz odhlásite, stratia sa.} other {# zmien vykonaných v tomto zariadení sa nedostalo na server. Ak sa teraz odhlásite, stratia sa.}}",
|
||||
"sign_out_pending_title": "Napriek tomu sa odhlásiť?",
|
||||
"sign_out_unknown_message": "Zmeny vykonané v tomto zariadení sa možno nedostali na server. Ak sa teraz odhlásite, stratia sa.",
|
||||
"signed_out_text": "Účet {account} bol odhlásený, preto sa jeho kontakty a kalendáre v tomto zariadení už nesynchronizujú. Ak chcete pokračovať, znova sa prihláste. Zmeny vykonané v tomto zariadení zostanú zachované.",
|
||||
"signed_out_title": "Ak chcete ďalej synchronizovať, prihláste sa",
|
||||
"signing_out": "Odstraňujú sa synchronizované kontakty a kalendáre z tohto zariadenia…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Fotoaparát",
|
||||
"attachment_reminder_body": "Vaša správa spomína „{keyword}“, ale nie je priložený žiadny súbor. Odoslať aj tak?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktu} other {# kontaktov}}",
|
||||
"default_book": "Predvolený adresár",
|
||||
"description_mobile": "Predvoľby zobrazenia a nástroje pre adresáre.",
|
||||
"device_sync_desc": "Zobrazí vaše adresáre v aplikácii Kontakty a ďalších aplikáciách v tomto zariadení. Zmeny sa synchronizujú oboma smermi.",
|
||||
"device_sync_title": "Synchronizovať s týmto zariadením",
|
||||
"export_description_count": "{count, plural, one {Exportovať # kontakt do jedného súboru vCard.} few {Exportovať # kontakty do jedného súboru vCard.} many {Exportovať # kontaktu do jedného súboru vCard.} other {Exportovať # kontaktov do jedného súboru vCard.}}",
|
||||
"export_empty": "Žiadne kontakty na export",
|
||||
"import_description_mobile": "Importovať kontakty zo súboru vCard (.vcf) s rozpoznaním duplicít.",
|
||||
"manage_description_mobile": "Vytvárajte, premenúvajte a odstraňujte adresáre, v ktorých máte kontakty usporiadané."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Synchronizuje sa aj pre {accounts}, preto sa v tomto zariadení zobrazuje dvakrát.",
|
||||
"android_settings": "Nastavenia účtu v Androide",
|
||||
"blocked_calendar": "Android už o prístup ku kalendárom nežiada. Povoľte ho v nastaveniach aplikácie a potom synchronizáciu znova zapnite.",
|
||||
"blocked_contacts": "Android už o prístup ku kontaktom nežiada. Povoľte ho v nastaveniach aplikácie a potom synchronizáciu znova zapnite.",
|
||||
"blocked_title": "Prístup je vypnutý",
|
||||
"books_default": "Vaše vlastné adresáre sa synchronizujú, zdieľané iba vtedy, keď ich vyberiete.",
|
||||
"books_hint": "Vaše vlastné adresáre sa synchronizujú, ak ich nevypnete; zdieľané iba vtedy, keď ich zapnete.",
|
||||
"books_label": "Adresáre",
|
||||
"calendars_default": "Vaše vlastné kalendáre sa synchronizujú, zdieľané iba vtedy, keď ich vyberiete.",
|
||||
"calendars_hint": "Vaše vlastné kalendáre sa synchronizujú, ak ich nevypnete; zdieľané iba vtedy, keď ich zapnete. Zoznamy úloh zostávajú v Bulwarku.",
|
||||
"calendars_label": "Kalendáre",
|
||||
"choose": "Vybrať",
|
||||
"choose_books_title": "Adresáre na synchronizáciu",
|
||||
"choose_calendars_title": "Kalendáre na synchronizáciu",
|
||||
"collections_count": "Synchronizované: {selected} z {total}",
|
||||
"continue": "Pokračovať",
|
||||
"deletions_delete": "Odstrániť na serveri",
|
||||
"deletions_message": "V tomto zariadení sa naraz odstránilo veľa položiek, preto sa neodstránili na serveri. Odstráňte ich aj tam, alebo ich v tomto zariadení obnovte.",
|
||||
"deletions_restore": "Obnoviť",
|
||||
"deletions_title": "Odstrániť ich aj na serveri?",
|
||||
"denied_calendar": "Bez prístupu ku kalendárom nemôže Bulwark preniesť vaše kalendáre do tohto zariadenia.",
|
||||
"denied_contacts": "Bez prístupu ku kontaktom nemôže Bulwark preniesť vaše adresáre do tohto zariadenia.",
|
||||
"denied_title": "Synchronizácia zostáva vypnutá",
|
||||
"failed_title": "Synchronizáciu sa nepodarilo zapnúť",
|
||||
"grant_access": "Udeliť prístup",
|
||||
"interval_15m": "Každých 15 minút",
|
||||
"interval_1h": "Každú hodinu",
|
||||
"interval_30m": "Každých 30 minút",
|
||||
"interval_6h": "Každých 6 hodín",
|
||||
"interval_desc": "Ako často Android synchronizuje sám. Úpravy v Bulwarku a zmeny oznámené cez push sa synchronizujú skôr.",
|
||||
"interval_label": "Interval synchronizácie",
|
||||
"interval_manual": "Bez pravidelnej synchronizácie",
|
||||
"keep_syncing": "Ďalej synchronizovať",
|
||||
"list_error_auth": "Server neprijal prihlásenie.",
|
||||
"list_error_network": "Nepodarilo sa spojiť so serverom.",
|
||||
"list_error_server": "Server nedokázal poskytnúť zoznam.",
|
||||
"list_error_signed_out": "Tento účet nie je v tomto zariadení prihlásený.",
|
||||
"master_off": "Automatická synchronizácia je v tomto zariadení vypnutá, preto sa synchronizuje iba po klepnutí na „Synchronizovať teraz“.",
|
||||
"new_contacts_default": "Predvolený adresár",
|
||||
"new_contacts_desc": "Adresár pre kontakty vytvorené v iných aplikáciách v tomto zariadení.",
|
||||
"new_contacts_label": "Nové kontakty ukladať do",
|
||||
"no_books": "Tento účet nemá žiadne adresáre.",
|
||||
"no_calendars": "Tento účet nemá žiadne kalendáre na synchronizáciu.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Otvoriť nastavenia",
|
||||
"own_collections": "Vlastné",
|
||||
"rationale_calendar": "Aby mohol Bulwark zobraziť vaše kalendáre v aplikácii Kalendár, potrebuje prístup ku kalendárom v tomto zariadení. Mení iba kalendáre svojho vlastného účtu.",
|
||||
"rationale_contacts": "Aby mohol Bulwark zobraziť vaše adresáre v aplikácii Kontakty, potrebuje prístup ku kontaktom v tomto zariadení. Mení iba kontakty svojho vlastného účtu.",
|
||||
"rationale_title_calendar": "Povoliť prístup ku kalendárom",
|
||||
"rationale_title_contacts": "Povoliť prístup ku kontaktom",
|
||||
"read_only": "Iba na čítanie",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Aplikácia Kalendár",
|
||||
"reminder_question_message": "Pripomenutia synchronizovaných udalostí môže zobrazovať aplikácia kalendára v tomto zariadení alebo Bulwark. Vyberte jednu možnosť, aby ste nedostávali pripomenutia dvakrát. Neskôr to môžete zmeniť.",
|
||||
"reminder_question_title": "Kto vám má pripomínať udalosti?",
|
||||
"reminders_desc": "Kto vám pripomína synchronizované udalosti. Druhá možnosť mlčí, aby ste nedostávali pripomenutia dvakrát.",
|
||||
"reminders_label": "Pripomenutia",
|
||||
"reminders_nobody": "Oznámenia udalostí v Bulwarku sú vypnuté, takže vám synchronizované udalosti nič nepripomenie.",
|
||||
"remove": "Odstrániť",
|
||||
"remove_from_device": "Odstrániť z tohto zariadenia",
|
||||
"remove_message": "Kontakty a kalendáre účtu sa z tohto zariadenia odstránia. Zmeny vykonané tu, ktoré sa nedostali na server, sa stratia.",
|
||||
"remove_title": "Odstrániť z tohto zariadenia?",
|
||||
"review_deletions": "Skontrolovať odstránenia",
|
||||
"shared_by": "Zdieľané od {name}",
|
||||
"sign_in_again": "Znova sa prihlásiť",
|
||||
"status_auth": "Server už neprijíma prihlásenie. Ak chcete ďalej synchronizovať, znova sa prihláste.",
|
||||
"status_cancelled": "Posledná synchronizácia sa skončila predčasne. Nabudúce bude pokračovať.",
|
||||
"status_failed": "Posledná synchronizácia zlyhala: {message}",
|
||||
"status_failed_plain": "Posledná synchronizácia zlyhala.",
|
||||
"status_io": "Server bol {when} nedostupný. Android to skúsi znova.",
|
||||
"status_item_errors": "{count, plural, one {# položku sa nepodarilo synchronizovať.} few {# položky sa nepodarilo synchronizovať.} many {# položky sa nepodarilo synchronizovať.} other {# položiek sa nepodarilo synchronizovať.}}",
|
||||
"status_off": "Vypnuté",
|
||||
"status_paused": "Pozastavené v nastaveniach Androidu",
|
||||
"status_permission_calendar": "Prístup ku kalendárom bol vypnutý, preto sa nič nesynchronizuje.",
|
||||
"status_permission_contacts": "Prístup ku kontaktom bol vypnutý, preto sa nič nesynchronizuje.",
|
||||
"status_removed": "Odstránené v nastaveniach Androidu. Ak chcete účet znova pridať, zapnite synchronizáciu.",
|
||||
"status_safety_abort": "Zastavené: server nič nevrátil, hoci toto zariadenie stále obsahuje položky. Skontrolujte server a potom synchronizujte znova.",
|
||||
"status_synced": "Synchronizované {when}",
|
||||
"status_syncing": "Synchronizuje sa…",
|
||||
"status_too_many_deletions": "V tomto zariadení sa odstránilo veľa položiek. Na serveri sa odstránia až po vašom potvrdení.",
|
||||
"status_unsupported_calendar": "Server pre tento účet neponúka žiadne kalendáre.",
|
||||
"status_unsupported_contacts": "Server pre tento účet neponúka žiadne adresáre.",
|
||||
"status_waiting": "Čaká sa na prvú synchronizáciu",
|
||||
"status_waiting_next": "Čaká sa na ďalšiu synchronizáciu",
|
||||
"suspended_desc": "Odhlásené. Kontakty a kalendáre účtu zostanú v tomto zariadení aj so zmenami vykonanými tu a po prihlásení sa znova synchronizujú.",
|
||||
"switch_label": "Synchronizovať {account} s týmto zariadením",
|
||||
"sync_now": "Synchronizovať teraz",
|
||||
"turn_off_anyway": "Vypnúť",
|
||||
"turn_off_failed": "Synchronizované údaje sa nepodarilo z tohto zariadenia odstrániť. Synchronizácia je pozastavená; skúste to neskôr.",
|
||||
"turn_off_failed_title": "Synchronizáciu sa nepodarilo vypnúť",
|
||||
"turn_off_pending": "{count, plural, one {# zmena vykonaná v tomto zariadení sa ešte nedostala na server. Ak teraz synchronizáciu vypnete, stratí sa.} few {# zmeny vykonané v tomto zariadení sa ešte nedostali na server. Ak teraz synchronizáciu vypnete, stratia sa.} many {# zmeny vykonanej v tomto zariadení sa ešte nedostalo na server. Ak teraz synchronizáciu vypnete, stratia sa.} other {# zmien vykonaných v tomto zariadení sa ešte nedostalo na server. Ak teraz synchronizáciu vypnete, stratia sa.}}",
|
||||
"turn_off_title": "Napriek tomu vypnúť?",
|
||||
"turn_off_unknown": "Bulwark nemohol overiť, či sa zmeny vykonané v tomto zariadení dostali na server. Ak teraz synchronizáciu vypnete, stratia sa.",
|
||||
"turning_off": "Nahrávajú sa zmeny a potom sa z tohto zariadenia odstránia údaje…",
|
||||
"turning_on": "Nastavuje sa…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Šablóna použitá pri ukladaní alebo zdieľaní prílohy."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "{date} tarihine kadar"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Takvimlerinizi bu cihazdaki Takvim uygulamasında ve diğer uygulamalarda gösterin. Değişiklikler iki yönlü senkronize edilir.",
|
||||
"device_sync_title": "Bu cihazla senkronize et",
|
||||
"show_week_numbers_mobile_desc": "Ay görünümünde hafta numaralarını göster.",
|
||||
"time_zone": "Saat dilimi",
|
||||
"time_zone_auto": "Cihaz saat dilimi",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Etiket",
|
||||
"remove_color": "Etiketi kaldır"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Sunucu artık {account} hesabının girişini kabul etmiyor, bu nedenle hesabın bu cihazdaki kişileri ve takvimleri artık senkronize edilmiyor. Devam etmek için tekrar giriş yapın. Bu cihazda yapılan değişiklikler korunur.",
|
||||
"auth_problem_title": "Senkronizasyona devam etmek için tekrar giriş yapın",
|
||||
"problem_channel": "Senkronizasyon sorunları",
|
||||
"sign_out_anyway": "Çıkış yap",
|
||||
"sign_out_pending_message": "{count, plural, one {Bu cihazda yapılan # değişiklik sunucuya ulaşmadı. Şimdi çıkış yaparsanız kaybolur.} other {Bu cihazda yapılan # değişiklik sunucuya ulaşmadı. Şimdi çıkış yaparsanız kaybolur.}}",
|
||||
"sign_out_pending_title": "Yine de çıkış yapılsın mı?",
|
||||
"sign_out_unknown_message": "Bu cihazda yapılan değişiklikler sunucuya ulaşmamış olabilir. Şimdi çıkış yaparsanız kaybolur.",
|
||||
"signed_out_text": "{account} hesabından çıkış yapıldı, bu nedenle hesabın bu cihazdaki kişileri ve takvimleri artık senkronize edilmiyor. Devam etmek için tekrar giriş yapın. Bu cihazda yapılan değişiklikler korunur.",
|
||||
"signed_out_title": "Senkronizasyona devam etmek için giriş yapın",
|
||||
"signing_out": "Senkronize edilen kişiler ve takvimler bu cihazdan kaldırılıyor…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Kamera",
|
||||
"attachment_reminder_body": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# kişi} other {# kişi}}",
|
||||
"default_book": "Varsayılan adres defteri",
|
||||
"description_mobile": "Görüntüleme tercihleri ve adres defteri araçları.",
|
||||
"device_sync_desc": "Adres defterlerinizi bu cihazdaki Kişiler uygulamasında ve diğer uygulamalarda gösterin. Değişiklikler iki yönlü senkronize edilir.",
|
||||
"device_sync_title": "Bu cihazla senkronize et",
|
||||
"export_description_count": "{count, plural, one {# kişiyi tek bir vCard dosyası olarak dışa aktarın.} other {# kişiyi tek bir vCard dosyası olarak dışa aktarın.}}",
|
||||
"export_empty": "Dışa aktarılacak kişi yok",
|
||||
"import_description_mobile": "Kişileri, yinelenenleri algılayarak bir vCard (.vcf) dosyasından içe aktarın.",
|
||||
"manage_description_mobile": "Kişilerinizi düzenleyen adres defterlerini oluşturun, yeniden adlandırın ve kaldırın."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "{accounts} için de senkronize ediliyor, bu nedenle bu cihazda iki kez görünüyor.",
|
||||
"android_settings": "Android hesap ayarları",
|
||||
"blocked_calendar": "Android artık takvimlere erişim istemiyor. Uygulama ayarlarından erişime izin verin, ardından senkronizasyonu tekrar açın.",
|
||||
"blocked_contacts": "Android artık kişilere erişim istemiyor. Uygulama ayarlarından erişime izin verin, ardından senkronizasyonu tekrar açın.",
|
||||
"blocked_title": "Erişim kapalı",
|
||||
"books_default": "Kendi adres defterleriniz senkronize edilir; paylaşılanlar yalnızca siz seçtiğinizde.",
|
||||
"books_hint": "Kendi adres defterleriniz siz kapatmadığınız sürece senkronize edilir; paylaşılanlar yalnızca siz açtığınızda.",
|
||||
"books_label": "Adres defterleri",
|
||||
"calendars_default": "Kendi takvimleriniz senkronize edilir; paylaşılanlar yalnızca siz seçtiğinizde.",
|
||||
"calendars_hint": "Kendi takvimleriniz siz kapatmadığınız sürece senkronize edilir; paylaşılanlar yalnızca siz açtığınızda. Görev listeleri Bulwark'ta kalır.",
|
||||
"calendars_label": "Takvimler",
|
||||
"choose": "Seç",
|
||||
"choose_books_title": "Senkronize edilecek adres defterleri",
|
||||
"choose_calendars_title": "Senkronize edilecek takvimler",
|
||||
"collections_count": "Senkronize edilen: {selected}/{total}",
|
||||
"continue": "Devam",
|
||||
"deletions_delete": "Sunucuda sil",
|
||||
"deletions_message": "Bu cihazda aynı anda çok sayıda öğe silindi, bu nedenle sunucuda silinmediler. Bunları sunucuda da silin veya bu cihaza geri getirin.",
|
||||
"deletions_restore": "Geri getir",
|
||||
"deletions_title": "Sunucuda da silinsin mi?",
|
||||
"denied_calendar": "Takvimlere erişim olmadan Bulwark takvimlerinizi bu cihaza getiremez.",
|
||||
"denied_contacts": "Kişilere erişim olmadan Bulwark adres defterlerinizi bu cihaza getiremez.",
|
||||
"denied_title": "Senkronizasyon kapalı kalıyor",
|
||||
"failed_title": "Senkronizasyon açılamadı",
|
||||
"grant_access": "Erişim izni ver",
|
||||
"interval_15m": "Her 15 dakikada bir",
|
||||
"interval_1h": "Her saat",
|
||||
"interval_30m": "Her 30 dakikada bir",
|
||||
"interval_6h": "Her 6 saatte bir",
|
||||
"interval_desc": "Android'in kendi kendine ne sıklıkla senkronize edeceği. Bulwark'taki düzenlemeler ve push ile bildirilen değişiklikler daha erken senkronize edilir.",
|
||||
"interval_label": "Senkronizasyon aralığı",
|
||||
"interval_manual": "Düzenli senkronizasyon yok",
|
||||
"keep_syncing": "Senkronizasyona devam et",
|
||||
"list_error_auth": "Sunucu girişi kabul etmedi.",
|
||||
"list_error_network": "Sunucuya ulaşılamadı.",
|
||||
"list_error_server": "Sunucu listeyi sağlayamadı.",
|
||||
"list_error_signed_out": "Bu cihazda bu hesapla giriş yapılmamış.",
|
||||
"master_off": "Bu cihazda otomatik senkronizasyon kapalı, bu nedenle senkronizasyon yalnızca \"Şimdi senkronize et\" düğmesine dokunduğunuzda yapılır.",
|
||||
"new_contacts_default": "Varsayılan adres defteri",
|
||||
"new_contacts_desc": "Bu cihazdaki diğer uygulamalarda oluşturulan kişilerin kaydedileceği adres defteri.",
|
||||
"new_contacts_label": "Yeni kişilerin kaydedileceği yer",
|
||||
"no_books": "Bu hesabın adres defteri yok.",
|
||||
"no_calendars": "Bu hesabın senkronize edilecek takvimi yok.",
|
||||
"ok": "Tamam",
|
||||
"open_app_settings": "Uygulama ayarlarını aç",
|
||||
"own_collections": "Size ait",
|
||||
"rationale_calendar": "Takvimlerinizi Takvim uygulamasında göstermek için Bulwark'ın bu cihazdaki takvimlere erişmesi gerekir. Yalnızca kendi hesabının takvimlerini değiştirir.",
|
||||
"rationale_contacts": "Adres defterlerinizi Kişiler uygulamasında göstermek için Bulwark'ın bu cihazdaki kişilere erişmesi gerekir. Yalnızca kendi hesabının kişilerini değiştirir.",
|
||||
"rationale_title_calendar": "Takvimlere erişim izni verin",
|
||||
"rationale_title_contacts": "Kişilere erişim izni verin",
|
||||
"read_only": "Salt okunur",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Takvim uygulaması",
|
||||
"reminder_question_message": "Senkronize edilen etkinlikler için hatırlatmalar bu cihazdaki takvim uygulamasından veya Bulwark'tan gelebilir. İki kez hatırlatılmamak için birini seçin. Bunu daha sonra değiştirebilirsiniz.",
|
||||
"reminder_question_title": "Size kim hatırlatsın?",
|
||||
"reminders_desc": "Senkronize edilen etkinlikleri size kimin hatırlatacağı. Diğeri sessiz kalır, böylece iki kez hatırlatılmazsınız.",
|
||||
"reminders_label": "Hatırlatıcılar",
|
||||
"reminders_nobody": "Bulwark'ın etkinlik bildirimleri kapalı, bu nedenle senkronize edilen etkinlikleri size hiçbir şey hatırlatmaz.",
|
||||
"remove": "Kaldır",
|
||||
"remove_from_device": "Bu cihazdan kaldır",
|
||||
"remove_message": "Hesabın kişileri ve takvimleri bu cihazdan kaldırılır. Burada yapılan ve sunucuya ulaşmamış değişiklikler kaybolur.",
|
||||
"remove_title": "Bu cihazdan kaldırılsın mı?",
|
||||
"review_deletions": "Silmeleri incele",
|
||||
"shared_by": "Paylaşan: {name}",
|
||||
"sign_in_again": "Tekrar giriş yap",
|
||||
"status_auth": "Sunucu artık girişi kabul etmiyor. Senkronizasyona devam etmek için tekrar giriş yapın.",
|
||||
"status_cancelled": "Son senkronizasyon erken durdu. Bir dahaki sefere devam edecek.",
|
||||
"status_failed": "Son senkronizasyon başarısız: {message}",
|
||||
"status_failed_plain": "Son senkronizasyon başarısız oldu.",
|
||||
"status_io": "Sunucuya {when} ulaşılamadı. Android tekrar deneyecek.",
|
||||
"status_item_errors": "{count, plural, one {# öğe senkronize edilemedi.} other {# öğe senkronize edilemedi.}}",
|
||||
"status_off": "Kapalı",
|
||||
"status_paused": "Android ayarlarında duraklatıldı",
|
||||
"status_permission_calendar": "Takvimlere erişim kapatıldı, bu nedenle hiçbir şey senkronize edilmiyor.",
|
||||
"status_permission_contacts": "Kişilere erişim kapatıldı, bu nedenle hiçbir şey senkronize edilmiyor.",
|
||||
"status_removed": "Android ayarlarında kaldırıldı. Hesabı yeniden eklemek için senkronizasyonu açın.",
|
||||
"status_safety_abort": "Durduruldu: bu cihazda hâlâ öğeler varken sunucu hiçbir şey döndürmedi. Sunucuyu kontrol edin, ardından yeniden senkronize edin.",
|
||||
"status_synced": "Son senkronizasyon: {when}",
|
||||
"status_syncing": "Senkronize ediliyor…",
|
||||
"status_too_many_deletions": "Bu cihazda çok sayıda öğe silindi. Siz onaylayana kadar sunucuda silinmezler.",
|
||||
"status_unsupported_calendar": "Sunucu bu hesap için takvim sunmuyor.",
|
||||
"status_unsupported_contacts": "Sunucu bu hesap için adres defteri sunmuyor.",
|
||||
"status_waiting": "İlk senkronizasyon bekleniyor",
|
||||
"status_waiting_next": "Sonraki senkronizasyon bekleniyor",
|
||||
"suspended_desc": "Çıkış yapıldı. Hesabın kişileri ve takvimleri, burada yapılan değişikliklerle birlikte bu cihazda kalır ve giriş yaptığınızda yeniden senkronize edilir.",
|
||||
"switch_label": "{account} hesabını bu cihazla senkronize et",
|
||||
"sync_now": "Şimdi senkronize et",
|
||||
"turn_off_anyway": "Kapat",
|
||||
"turn_off_failed": "Senkronize edilen veriler bu cihazdan kaldırılamadı. Senkronizasyon duraklatıldı; daha sonra tekrar deneyin.",
|
||||
"turn_off_failed_title": "Senkronizasyon kapatılamadı",
|
||||
"turn_off_pending": "{count, plural, one {Bu cihazda yapılan # değişiklik henüz sunucuya ulaşmadı. Senkronizasyonu şimdi kapatırsanız kaybolur.} other {Bu cihazda yapılan # değişiklik henüz sunucuya ulaşmadı. Senkronizasyonu şimdi kapatırsanız kaybolur.}}",
|
||||
"turn_off_title": "Yine de kapatılsın mı?",
|
||||
"turn_off_unknown": "Bulwark, bu cihazda yapılan değişikliklerin sunucuya ulaşıp ulaşmadığını kontrol edemedi. Senkronizasyonu şimdi kapatırsanız kaybolur.",
|
||||
"turning_off": "Değişiklikler yükleniyor, ardından veriler bu cihazdan kaldırılıyor…",
|
||||
"turning_on": "Ayarlanıyor…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Bir eki kaydederken veya paylaşırken kullanılan şablon."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "До {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "Ваші календарі відображатимуться в застосунку «Календар» та інших застосунках на цьому пристрої. Зміни синхронізуються в обидва боки.",
|
||||
"device_sync_title": "Синхронізація з цим пристроєм",
|
||||
"show_week_numbers_mobile_desc": "Показувати номери тижнів у поданні «Місяць».",
|
||||
"time_zone": "Часовий пояс",
|
||||
"time_zone_auto": "Часовий пояс пристрою",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Прибрати тег"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "Сервер більше не приймає вхід в обліковий запис {account}, тому його контакти й календарі на цьому пристрої більше не синхронізуються. Увійдіть знову, щоб відновити синхронізацію. Зміни, внесені на цьому пристрої, збережуться.",
|
||||
"auth_problem_title": "Увійдіть знову, щоб продовжити синхронізацію",
|
||||
"problem_channel": "Проблеми із синхронізацією",
|
||||
"sign_out_anyway": "Вийти",
|
||||
"sign_out_pending_message": "{count, plural, one {# зміна, внесена на цьому пристрої, не дійшла до сервера. Її буде втрачено, якщо ви зараз вийдете.} few {# зміни, внесені на цьому пристрої, не дійшли до сервера. Їх буде втрачено, якщо ви зараз вийдете.} many {# змін, внесених на цьому пристрої, не дійшли до сервера. Їх буде втрачено, якщо ви зараз вийдете.} other {# зміни, внесеної на цьому пристрої, не дійшли до сервера. Їх буде втрачено, якщо ви зараз вийдете.}}",
|
||||
"sign_out_pending_title": "Усе одно вийти?",
|
||||
"sign_out_unknown_message": "Зміни, внесені на цьому пристрої, могли не дійти до сервера. Їх буде втрачено, якщо ви зараз вийдете.",
|
||||
"signed_out_text": "Виконано вихід з облікового запису {account}, тому його контакти й календарі на цьому пристрої більше не синхронізуються. Увійдіть знову, щоб відновити синхронізацію. Зміни, внесені на цьому пристрої, збережуться.",
|
||||
"signed_out_title": "Увійдіть, щоб продовжити синхронізацію",
|
||||
"signing_out": "Видалення синхронізованих контактів і календарів із цього пристрою…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Камера",
|
||||
"attachment_reminder_body": "У листі згадано «{keyword}», але файл не вкладено. Усе одно надіслати?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, one {# контакт} few {# контакти} many {# контактів} other {# контакту}}",
|
||||
"default_book": "Адресна книга за замовчуванням",
|
||||
"description_mobile": "Параметри відображення та інструменти адресних книг.",
|
||||
"device_sync_desc": "Ваші адресні книги відображатимуться в застосунку «Контакти» та інших застосунках на цьому пристрої. Зміни синхронізуються в обидва боки.",
|
||||
"device_sync_title": "Синхронізація з цим пристроєм",
|
||||
"export_description_count": "{count, plural, one {Експортувати # контакт в один файл vCard.} few {Експортувати # контакти в один файл vCard.} many {Експортувати # контактів в один файл vCard.} other {Експортувати # контакту в один файл vCard.}}",
|
||||
"export_empty": "Немає контактів для експорту",
|
||||
"import_description_mobile": "Імпорт контактів із файлу vCard (.vcf) з виявленням дублікатів.",
|
||||
"manage_description_mobile": "Створюйте, перейменовуйте та видаляйте адресні книги, у яких зберігаються ваші контакти."
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "Також синхронізується для {accounts}, тому відображається на цьому пристрої двічі.",
|
||||
"android_settings": "Налаштування облікового запису Android",
|
||||
"blocked_calendar": "Android більше не запитує доступ до календарів. Дозвольте його в налаштуваннях застосунку, а потім знову ввімкніть синхронізацію.",
|
||||
"blocked_contacts": "Android більше не запитує доступ до контактів. Дозвольте його в налаштуваннях застосунку, а потім знову ввімкніть синхронізацію.",
|
||||
"blocked_title": "Доступ вимкнено",
|
||||
"books_default": "Ваші власні адресні книги синхронізуються, а спільні — лише якщо ви їх виберете.",
|
||||
"books_hint": "Ваші власні адресні книги синхронізуються, якщо ви їх не вимкнете, а спільні — лише якщо ви їх увімкнете.",
|
||||
"books_label": "Адресні книги",
|
||||
"calendars_default": "Ваші власні календарі синхронізуються, а спільні — лише якщо ви їх виберете.",
|
||||
"calendars_hint": "Ваші власні календарі синхронізуються, якщо ви їх не вимкнете, а спільні — лише якщо ви їх увімкнете. Списки завдань залишаються в Bulwark.",
|
||||
"calendars_label": "Календарі",
|
||||
"choose": "Вибрати",
|
||||
"choose_books_title": "Адресні книги для синхронізації",
|
||||
"choose_calendars_title": "Календарі для синхронізації",
|
||||
"collections_count": "Синхронізується: {selected} з {total}",
|
||||
"continue": "Продовжити",
|
||||
"deletions_delete": "Видалити на сервері",
|
||||
"deletions_message": "На цьому пристрої одночасно видалено багато елементів, тому на сервері їх не видалено. Видаліть їх і там або поверніть їх на цей пристрій.",
|
||||
"deletions_restore": "Повернути",
|
||||
"deletions_title": "Видалити їх і на сервері?",
|
||||
"denied_calendar": "Без доступу до календарів Bulwark не зможе перенести ваші календарі на цей пристрій.",
|
||||
"denied_contacts": "Без доступу до контактів Bulwark не зможе перенести ваші адресні книги на цей пристрій.",
|
||||
"denied_title": "Синхронізація залишається вимкненою",
|
||||
"failed_title": "Не вдалося ввімкнути синхронізацію",
|
||||
"grant_access": "Надати доступ",
|
||||
"interval_15m": "Кожні 15 хвилин",
|
||||
"interval_1h": "Щогодини",
|
||||
"interval_30m": "Кожні 30 хвилин",
|
||||
"interval_6h": "Кожні 6 годин",
|
||||
"interval_desc": "Як часто Android синхронізує дані самостійно. Зміни в Bulwark і зміни, про які повідомляє push, синхронізуються швидше.",
|
||||
"interval_label": "Інтервал синхронізації",
|
||||
"interval_manual": "Без регулярної синхронізації",
|
||||
"keep_syncing": "Продовжити синхронізацію",
|
||||
"list_error_auth": "Сервер не прийняв вхід.",
|
||||
"list_error_network": "Не вдалося зв'язатися із сервером.",
|
||||
"list_error_server": "Сервер не зміг повернути список.",
|
||||
"list_error_signed_out": "На цьому пристрої не виконано вхід у цей обліковий запис.",
|
||||
"master_off": "Автосинхронізацію на цьому пристрої вимкнено, тому синхронізація виконується лише після натискання «Синхронізувати».",
|
||||
"new_contacts_default": "Адресна книга за замовчуванням",
|
||||
"new_contacts_desc": "Адресна книга для контактів, створених в інших застосунках на цьому пристрої.",
|
||||
"new_contacts_label": "Зберігати нові контакти в",
|
||||
"no_books": "У цьому обліковому записі немає адресних книг.",
|
||||
"no_calendars": "У цьому обліковому записі немає календарів для синхронізації.",
|
||||
"ok": "OK",
|
||||
"open_app_settings": "Відкрити налаштування",
|
||||
"own_collections": "Ваші",
|
||||
"rationale_calendar": "Щоб показувати ваші календарі в застосунку «Календар», Bulwark потрібен доступ до календарів на цьому пристрої. Він змінює лише календарі свого облікового запису.",
|
||||
"rationale_contacts": "Щоб показувати ваші адресні книги в застосунку «Контакти», Bulwark потрібен доступ до контактів на цьому пристрої. Він змінює лише контакти свого облікового запису.",
|
||||
"rationale_title_calendar": "Дозволити доступ до календарів",
|
||||
"rationale_title_contacts": "Дозволити доступ до контактів",
|
||||
"read_only": "Лише для читання",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "Застосунок «Календар»",
|
||||
"reminder_question_message": "Нагадування про синхронізовані події може показувати застосунок календаря на цьому пристрої або Bulwark. Виберіть щось одне, щоб не отримувати нагадування двічі. Це можна змінити пізніше.",
|
||||
"reminder_question_title": "Хто нагадуватиме про події?",
|
||||
"reminders_desc": "Хто нагадує про синхронізовані події. Інший застосунок мовчить, щоб нагадування не надходили двічі.",
|
||||
"reminders_label": "Нагадування",
|
||||
"reminders_nobody": "Сповіщення про події в Bulwark вимкнено, тому про синхронізовані події ніщо не нагадає.",
|
||||
"remove": "Видалити",
|
||||
"remove_from_device": "Видалити з цього пристрою",
|
||||
"remove_message": "Контакти й календарі облікового запису буде видалено з цього пристрою. Внесені тут зміни, які не дійшли до сервера, буде втрачено.",
|
||||
"remove_title": "Видалити з цього пристрою?",
|
||||
"review_deletions": "Переглянути видалення",
|
||||
"shared_by": "Власник: {name}",
|
||||
"sign_in_again": "Увійти знову",
|
||||
"status_auth": "Сервер більше не приймає вхід. Увійдіть знову, щоб продовжити синхронізацію.",
|
||||
"status_cancelled": "Остання синхронізація перервалася. Вона продовжиться наступного разу.",
|
||||
"status_failed": "Помилка останньої синхронізації: {message}",
|
||||
"status_failed_plain": "Остання синхронізація не вдалася.",
|
||||
"status_io": "Не вдалося зв'язатися із сервером {when}. Android повторить спробу.",
|
||||
"status_item_errors": "{count, plural, one {Не вдалося синхронізувати # елемент.} few {Не вдалося синхронізувати # елементи.} many {Не вдалося синхронізувати # елементів.} other {Не вдалося синхронізувати # елемента.}}",
|
||||
"status_off": "Вимкнено",
|
||||
"status_paused": "Призупинено в налаштуваннях Android",
|
||||
"status_permission_calendar": "Доступ до календарів вимкнено, тому нічого не синхронізується.",
|
||||
"status_permission_contacts": "Доступ до контактів вимкнено, тому нічого не синхронізується.",
|
||||
"status_removed": "Видалено в налаштуваннях Android. Увімкніть синхронізацію, щоб знову додати обліковий запис.",
|
||||
"status_safety_abort": "Зупинено: сервер нічого не повернув, хоча на цьому пристрої ще є елементи. Перевірте сервер і повторіть синхронізацію.",
|
||||
"status_synced": "Синхронізовано {when}",
|
||||
"status_syncing": "Синхронізація…",
|
||||
"status_too_many_deletions": "На цьому пристрої видалено багато елементів. На сервері їх буде видалено лише після вашого підтвердження.",
|
||||
"status_unsupported_calendar": "Сервер не надає календарів для цього облікового запису.",
|
||||
"status_unsupported_contacts": "Сервер не надає адресних книг для цього облікового запису.",
|
||||
"status_waiting": "Очікування першої синхронізації",
|
||||
"status_waiting_next": "Очікування наступної синхронізації",
|
||||
"suspended_desc": "Виконано вихід. Контакти й календарі облікового запису залишаються на цьому пристрої разом із внесеними тут змінами й знову синхронізуються після входу.",
|
||||
"switch_label": "Синхронізувати {account} з цим пристроєм",
|
||||
"sync_now": "Синхронізувати",
|
||||
"turn_off_anyway": "Вимкнути",
|
||||
"turn_off_failed": "Не вдалося видалити синхронізовані дані з цього пристрою. Синхронізацію призупинено; спробуйте пізніше.",
|
||||
"turn_off_failed_title": "Не вдалося вимкнути синхронізацію",
|
||||
"turn_off_pending": "{count, plural, one {# зміна, внесена на цьому пристрої, ще не дійшла до сервера. Її буде втрачено, якщо зараз вимкнути синхронізацію.} few {# зміни, внесені на цьому пристрої, ще не дійшли до сервера. Їх буде втрачено, якщо зараз вимкнути синхронізацію.} many {# змін, внесених на цьому пристрої, ще не дійшли до сервера. Їх буде втрачено, якщо зараз вимкнути синхронізацію.} other {# зміни, внесеної на цьому пристрої, ще не дійшли до сервера. Їх буде втрачено, якщо зараз вимкнути синхронізацію.}}",
|
||||
"turn_off_title": "Усе одно вимкнути?",
|
||||
"turn_off_unknown": "Bulwark не вдалося перевірити, чи дійшли зміни, внесені на цьому пристрої, до сервера. Їх буде втрачено, якщо зараз вимкнути синхронізацію.",
|
||||
"turning_off": "Надсилання змін, потім видалення даних із цього пристрою…",
|
||||
"turning_on": "Налаштовування…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "Шаблон назви під час збереження вкладення або передавання його в інший застосунок."
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "直到 {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "在此裝置上的日曆應用程式和其他應用程式中顯示您的行事曆。變更會雙向同步。",
|
||||
"device_sync_title": "同步到此裝置",
|
||||
"show_week_numbers_mobile_desc": "在月檢視中顯示週數",
|
||||
"time_zone": "時區",
|
||||
"time_zone_auto": "裝置時區",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "標籤",
|
||||
"remove_color": "移除標籤"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "伺服器不再接受 {account} 的登入,因此此帳號在這部裝置上的聯絡人和行事曆已停止同步。請重新登入以繼續同步。在此裝置上所做的變更會保留。",
|
||||
"auth_problem_title": "請重新登入以繼續同步",
|
||||
"problem_channel": "同步問題",
|
||||
"sign_out_anyway": "登出",
|
||||
"sign_out_pending_message": "{count, plural, other {在此裝置上所做的 # 項變更尚未傳送到伺服器。如果現在登出,這些變更將會遺失。}}",
|
||||
"sign_out_pending_title": "仍要登出嗎?",
|
||||
"sign_out_unknown_message": "在此裝置上所做的變更可能尚未傳送到伺服器。如果現在登出,這些變更將會遺失。",
|
||||
"signed_out_text": "{account} 已登出,因此此帳號在這部裝置上的聯絡人和行事曆不再同步。請重新登入以繼續同步。在此裝置上所做的變更會保留。",
|
||||
"signed_out_title": "請登入以繼續同步",
|
||||
"signing_out": "正在從此裝置移除已同步的聯絡人和行事曆…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "相機",
|
||||
"attachment_reminder_body": "您的郵件提到「{keyword}」,但沒有附加檔案。仍要傳送嗎?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, other {# 位聯絡人}}",
|
||||
"default_book": "預設通訊錄",
|
||||
"description_mobile": "顯示偏好設定與通訊錄工具。",
|
||||
"device_sync_desc": "在此裝置上的聯絡人應用程式和其他應用程式中顯示您的通訊錄。變更會雙向同步。",
|
||||
"device_sync_title": "同步到此裝置",
|
||||
"export_description_count": "{count, plural, other {將 # 位聯絡人匯出為單一 vCard 檔案。}}",
|
||||
"export_empty": "沒有可匯出的聯絡人",
|
||||
"import_description_mobile": "從 vCard(.vcf)檔案匯入聯絡人,並偵測重複項目。",
|
||||
"manage_description_mobile": "建立、重新命名及移除用來整理聯絡人的通訊錄。"
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "也為 {accounts} 同步,因此會在此裝置上顯示兩次。",
|
||||
"android_settings": "Android 帳號設定",
|
||||
"blocked_calendar": "Android 不會再要求行事曆存取權。請在應用程式設定中允許,然後重新開啟同步。",
|
||||
"blocked_contacts": "Android 不會再要求聯絡人存取權。請在應用程式設定中允許,然後重新開啟同步。",
|
||||
"blocked_title": "存取權已關閉",
|
||||
"books_default": "您自己的通訊錄會同步;共用的通訊錄只有在您選擇後才會同步。",
|
||||
"books_hint": "您自己的通訊錄會同步,除非您將其關閉;共用的通訊錄只有在您開啟後才會同步。",
|
||||
"books_label": "通訊錄",
|
||||
"calendars_default": "您自己的行事曆會同步;共用的行事曆只有在您選擇後才會同步。",
|
||||
"calendars_hint": "您自己的行事曆會同步,除非您將其關閉;共用的行事曆只有在您開啟後才會同步。任務清單會保留在 Bulwark 中。",
|
||||
"calendars_label": "行事曆",
|
||||
"choose": "選擇",
|
||||
"choose_books_title": "要同步的通訊錄",
|
||||
"choose_calendars_title": "要同步的行事曆",
|
||||
"collections_count": "同步 {selected} 個,共 {total} 個",
|
||||
"continue": "繼續",
|
||||
"deletions_delete": "在伺服器上刪除",
|
||||
"deletions_message": "此裝置上一次刪除了許多項目,因此未在伺服器上刪除。請同時在伺服器上刪除,或在此裝置上還原這些項目。",
|
||||
"deletions_restore": "還原",
|
||||
"deletions_title": "也要在伺服器上刪除嗎?",
|
||||
"denied_calendar": "沒有行事曆存取權,Bulwark 就無法將您的行事曆放到此裝置上。",
|
||||
"denied_contacts": "沒有聯絡人存取權,Bulwark 就無法將您的通訊錄放到此裝置上。",
|
||||
"denied_title": "同步維持關閉",
|
||||
"failed_title": "無法開啟同步",
|
||||
"grant_access": "授予存取權",
|
||||
"interval_15m": "每 15 分鐘",
|
||||
"interval_1h": "每小時",
|
||||
"interval_30m": "每 30 分鐘",
|
||||
"interval_6h": "每 6 小時",
|
||||
"interval_desc": "Android 自動同步的頻率。在 Bulwark 中的編輯和透過推送通知的變更會更快同步。",
|
||||
"interval_label": "同步間隔",
|
||||
"interval_manual": "不進行定期同步",
|
||||
"keep_syncing": "繼續同步",
|
||||
"list_error_auth": "伺服器未接受登入。",
|
||||
"list_error_network": "無法連線到伺服器。",
|
||||
"list_error_server": "伺服器無法提供清單。",
|
||||
"list_error_signed_out": "此帳號未在此裝置上登入。",
|
||||
"master_off": "此裝置的自動同步已關閉,因此只有在您輕觸「立即同步」時才會同步。",
|
||||
"new_contacts_default": "預設通訊錄",
|
||||
"new_contacts_desc": "用來儲存在此裝置上其他應用程式中建立的聯絡人的通訊錄。",
|
||||
"new_contacts_label": "新聯絡人儲存位置",
|
||||
"no_books": "此帳號沒有通訊錄。",
|
||||
"no_calendars": "此帳號沒有可同步的行事曆。",
|
||||
"ok": "確定",
|
||||
"open_app_settings": "開啟應用程式設定",
|
||||
"own_collections": "您自己的",
|
||||
"rationale_calendar": "如要在日曆應用程式中顯示您的行事曆,Bulwark 需要存取此裝置上的行事曆。它只會變更自己帳號的行事曆。",
|
||||
"rationale_contacts": "如要在聯絡人應用程式中顯示您的通訊錄,Bulwark 需要存取此裝置上的聯絡人。它只會變更自己帳號的聯絡人。",
|
||||
"rationale_title_calendar": "允許存取行事曆",
|
||||
"rationale_title_contacts": "允許存取聯絡人",
|
||||
"read_only": "唯讀",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "日曆應用程式",
|
||||
"reminder_question_message": "已同步活動的提醒可以來自此裝置上的日曆應用程式或 Bulwark。請擇一使用,以免收到兩次提醒。您之後可以變更。",
|
||||
"reminder_question_title": "要由誰提醒您?",
|
||||
"reminders_desc": "由誰提醒您已同步的活動。另一方會保持靜音,您就不會收到兩次提醒。",
|
||||
"reminders_label": "提醒",
|
||||
"reminders_nobody": "Bulwark 的活動通知已關閉,因此已同步的活動不會有任何提醒。",
|
||||
"remove": "移除",
|
||||
"remove_from_device": "從此裝置移除",
|
||||
"remove_message": "此帳號的聯絡人和行事曆將從此裝置移除。在此處所做但尚未傳送到伺服器的變更將會遺失。",
|
||||
"remove_title": "要從此裝置移除嗎?",
|
||||
"review_deletions": "檢查刪除項目",
|
||||
"shared_by": "由 {name} 分享",
|
||||
"sign_in_again": "重新登入",
|
||||
"status_auth": "伺服器不再接受登入。請重新登入以繼續同步。",
|
||||
"status_cancelled": "上次同步提前停止。下次會繼續。",
|
||||
"status_failed": "上次同步失敗:{message}",
|
||||
"status_failed_plain": "上次同步失敗。",
|
||||
"status_io": "無法連線到伺服器({when})。Android 會重試。",
|
||||
"status_item_errors": "{count, plural, other {有 # 個項目無法同步。}}",
|
||||
"status_off": "關閉",
|
||||
"status_paused": "已在 Android 設定中暫停",
|
||||
"status_permission_calendar": "行事曆存取權已關閉,因此不會同步任何內容。",
|
||||
"status_permission_contacts": "聯絡人存取權已關閉,因此不會同步任何內容。",
|
||||
"status_removed": "已在 Android 設定中移除。開啟同步即可重新新增此帳號。",
|
||||
"status_safety_abort": "已停止:此裝置上仍有項目,但伺服器未傳回任何內容。請檢查伺服器,然後重新同步。",
|
||||
"status_synced": "上次同步:{when}",
|
||||
"status_syncing": "正在同步…",
|
||||
"status_too_many_deletions": "此裝置上刪除了許多項目。在您確認之前,這些項目不會在伺服器上刪除。",
|
||||
"status_unsupported_calendar": "伺服器未為此帳號提供行事曆。",
|
||||
"status_unsupported_contacts": "伺服器未為此帳號提供通訊錄。",
|
||||
"status_waiting": "正在等待首次同步",
|
||||
"status_waiting_next": "正在等待下次同步",
|
||||
"suspended_desc": "已登出。此帳號的聯絡人和行事曆會連同在此處所做的變更一起保留在此裝置上,並在您登入後重新同步。",
|
||||
"switch_label": "將 {account} 同步到此裝置",
|
||||
"sync_now": "立即同步",
|
||||
"turn_off_anyway": "關閉",
|
||||
"turn_off_failed": "無法從此裝置移除已同步的資料。同步已暫停;請稍後再試。",
|
||||
"turn_off_failed_title": "無法關閉同步",
|
||||
"turn_off_pending": "{count, plural, other {在此裝置上所做的 # 項變更尚未傳送到伺服器。如果現在關閉同步,這些變更將會遺失。}}",
|
||||
"turn_off_title": "仍要關閉嗎?",
|
||||
"turn_off_unknown": "Bulwark 無法確認在此裝置上所做的變更是否已傳送到伺服器。如果現在關閉同步,這些變更將會遺失。",
|
||||
"turning_off": "正在上傳變更,然後從此裝置移除資料…",
|
||||
"turning_on": "正在設定…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "儲存或分享附件時使用的範本。"
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"until_date": "直到 {date}"
|
||||
},
|
||||
"settings": {
|
||||
"device_sync_desc": "在此设备上的日历应用和其他应用中显示您的日历。更改会双向同步。",
|
||||
"device_sync_title": "同步到此设备",
|
||||
"show_week_numbers_mobile_desc": "在月视图中显示周数",
|
||||
"time_zone": "时区",
|
||||
"time_zone_auto": "设备时区",
|
||||
@@ -249,6 +251,18 @@
|
||||
"color_tag": "标签",
|
||||
"remove_color": "删除标签"
|
||||
},
|
||||
"device_sync": {
|
||||
"auth_problem_text": "服务器不再接受 {account} 的登录,因此该账户在此设备上的联系人和日历已停止同步。请重新登录以恢复同步。在此设备上所做的更改会保留。",
|
||||
"auth_problem_title": "请重新登录以继续同步",
|
||||
"problem_channel": "同步问题",
|
||||
"sign_out_anyway": "退出登录",
|
||||
"sign_out_pending_message": "{count, plural, other {在此设备上所做的 # 项更改尚未到达服务器。如果现在退出登录,这些更改将丢失。}}",
|
||||
"sign_out_pending_title": "仍要退出登录?",
|
||||
"sign_out_unknown_message": "在此设备上所做的更改可能尚未到达服务器。如果现在退出登录,这些更改将丢失。",
|
||||
"signed_out_text": "{account} 已退出登录,因此该账户在此设备上的联系人和日历不再同步。请重新登录以恢复同步。在此设备上所做的更改会保留。",
|
||||
"signed_out_title": "请登录以继续同步",
|
||||
"signing_out": "正在从此设备移除已同步的联系人和日历…"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "相机",
|
||||
"attachment_reminder_body": "您的邮件中提到了“{keyword}”,但未附加任何文件。仍然发送吗?",
|
||||
@@ -629,11 +643,110 @@
|
||||
"book_count": "{count, plural, other {# 位联系人}}",
|
||||
"default_book": "默认通讯录",
|
||||
"description_mobile": "显示偏好设置和通讯录工具。",
|
||||
"device_sync_desc": "在此设备上的联系人应用和其他应用中显示您的通讯录。更改会双向同步。",
|
||||
"device_sync_title": "同步到此设备",
|
||||
"export_description_count": "{count, plural, other {将 # 位联系人导出为单个 vCard 文件。}}",
|
||||
"export_empty": "没有可导出的联系人",
|
||||
"import_description_mobile": "从 vCard (.vcf) 文件导入联系人,并检测重复项。",
|
||||
"manage_description_mobile": "创建、重命名和删除用于整理联系人的通讯录。"
|
||||
},
|
||||
"device_sync": {
|
||||
"also_synced_by": "也为 {accounts} 同步,因此会在此设备上显示两次。",
|
||||
"android_settings": "Android 账户设置",
|
||||
"blocked_calendar": "Android 不再请求日历访问权限。请在应用设置中允许,然后重新开启同步。",
|
||||
"blocked_contacts": "Android 不再请求联系人访问权限。请在应用设置中允许,然后重新开启同步。",
|
||||
"blocked_title": "访问权限已关闭",
|
||||
"books_default": "您自己的通讯录会同步;共享的通讯录仅在您选择后同步。",
|
||||
"books_hint": "您自己的通讯录会同步,除非您将其关闭;共享的通讯录仅在您开启后同步。",
|
||||
"books_label": "通讯录",
|
||||
"calendars_default": "您自己的日历会同步;共享的日历仅在您选择后同步。",
|
||||
"calendars_hint": "您自己的日历会同步,除非您将其关闭;共享的日历仅在您开启后同步。任务列表保留在 Bulwark 中。",
|
||||
"calendars_label": "日历",
|
||||
"choose": "选择",
|
||||
"choose_books_title": "要同步的通讯录",
|
||||
"choose_calendars_title": "要同步的日历",
|
||||
"collections_count": "同步 {selected} 个,共 {total} 个",
|
||||
"continue": "继续",
|
||||
"deletions_delete": "在服务器上删除",
|
||||
"deletions_message": "此设备上一次删除了许多项目,因此未在服务器上删除。请同时在服务器上删除它们,或在此设备上恢复它们。",
|
||||
"deletions_restore": "恢复",
|
||||
"deletions_title": "也在服务器上删除?",
|
||||
"denied_calendar": "没有日历访问权限,Bulwark 无法将您的日历放到此设备上。",
|
||||
"denied_contacts": "没有联系人访问权限,Bulwark 无法将您的通讯录放到此设备上。",
|
||||
"denied_title": "同步保持关闭",
|
||||
"failed_title": "无法开启同步",
|
||||
"grant_access": "授予访问权限",
|
||||
"interval_15m": "每 15 分钟",
|
||||
"interval_1h": "每小时",
|
||||
"interval_30m": "每 30 分钟",
|
||||
"interval_6h": "每 6 小时",
|
||||
"interval_desc": "Android 自动同步的频率。在 Bulwark 中的编辑和通过推送通知的更改会更快同步。",
|
||||
"interval_label": "同步间隔",
|
||||
"interval_manual": "不进行定期同步",
|
||||
"keep_syncing": "继续同步",
|
||||
"list_error_auth": "服务器未接受登录。",
|
||||
"list_error_network": "无法连接到服务器。",
|
||||
"list_error_server": "服务器无法提供列表。",
|
||||
"list_error_signed_out": "此账户未在此设备上登录。",
|
||||
"master_off": "此设备的自动同步已关闭,因此只有在您点按“立即同步”时才会同步。",
|
||||
"new_contacts_default": "默认通讯录",
|
||||
"new_contacts_desc": "用于保存在此设备上其他应用中创建的联系人的通讯录。",
|
||||
"new_contacts_label": "新联系人保存到",
|
||||
"no_books": "此账户没有通讯录。",
|
||||
"no_calendars": "此账户没有可同步的日历。",
|
||||
"ok": "确定",
|
||||
"open_app_settings": "打开应用设置",
|
||||
"own_collections": "您自己的",
|
||||
"rationale_calendar": "要在日历应用中显示您的日历,Bulwark 需要访问此设备上的日历。它只会更改自己账户的日历。",
|
||||
"rationale_contacts": "要在联系人应用中显示您的通讯录,Bulwark 需要访问此设备上的联系人。它只会更改自己账户的联系人。",
|
||||
"rationale_title_calendar": "允许访问日历",
|
||||
"rationale_title_contacts": "允许访问联系人",
|
||||
"read_only": "只读",
|
||||
"reminder_owner_bulwark": "Bulwark",
|
||||
"reminder_owner_device": "日历应用",
|
||||
"reminder_question_message": "已同步活动的提醒可以来自此设备上的日历应用或 Bulwark。请选择其一,以免收到两次提醒。您之后可以更改。",
|
||||
"reminder_question_title": "由谁来提醒您?",
|
||||
"reminders_desc": "由谁提醒您已同步的活动。另一方保持静默,这样您就不会收到两次提醒。",
|
||||
"reminders_label": "提醒",
|
||||
"reminders_nobody": "Bulwark 的活动通知已关闭,因此已同步的活动不会有任何提醒。",
|
||||
"remove": "移除",
|
||||
"remove_from_device": "从此设备移除",
|
||||
"remove_message": "该账户的联系人和日历将从此设备移除。在此处所做但尚未到达服务器的更改将丢失。",
|
||||
"remove_title": "从此设备移除?",
|
||||
"review_deletions": "查看删除项",
|
||||
"shared_by": "由 {name} 共享",
|
||||
"sign_in_again": "重新登录",
|
||||
"status_auth": "服务器不再接受登录。请重新登录以继续同步。",
|
||||
"status_cancelled": "上次同步提前停止。下次会继续。",
|
||||
"status_failed": "上次同步失败:{message}",
|
||||
"status_failed_plain": "上次同步失败。",
|
||||
"status_io": "无法连接到服务器({when})。Android 会重试。",
|
||||
"status_item_errors": "{count, plural, other {有 # 个项目无法同步。}}",
|
||||
"status_off": "关闭",
|
||||
"status_paused": "已在 Android 设置中暂停",
|
||||
"status_permission_calendar": "日历访问权限已关闭,因此不会同步任何内容。",
|
||||
"status_permission_contacts": "联系人访问权限已关闭,因此不会同步任何内容。",
|
||||
"status_removed": "已在 Android 设置中移除。开启同步即可重新添加该账户。",
|
||||
"status_safety_abort": "已停止:此设备上仍有项目,但服务器未返回任何内容。请检查服务器,然后重新同步。",
|
||||
"status_synced": "上次同步:{when}",
|
||||
"status_syncing": "正在同步…",
|
||||
"status_too_many_deletions": "此设备上删除了许多项目。在您确认之前,它们不会在服务器上被删除。",
|
||||
"status_unsupported_calendar": "服务器没有为此账户提供日历。",
|
||||
"status_unsupported_contacts": "服务器没有为此账户提供通讯录。",
|
||||
"status_waiting": "正在等待首次同步",
|
||||
"status_waiting_next": "正在等待下次同步",
|
||||
"suspended_desc": "已退出登录。该账户的联系人和日历会连同在此处所做的更改一起保留在此设备上,并在您登录后重新同步。",
|
||||
"switch_label": "将 {account} 同步到此设备",
|
||||
"sync_now": "立即同步",
|
||||
"turn_off_anyway": "关闭",
|
||||
"turn_off_failed": "无法从此设备移除已同步的数据。同步已暂停;请稍后重试。",
|
||||
"turn_off_failed_title": "无法关闭同步",
|
||||
"turn_off_pending": "{count, plural, other {在此设备上所做的 # 项更改尚未到达服务器。如果现在关闭同步,这些更改将丢失。}}",
|
||||
"turn_off_title": "仍要关闭?",
|
||||
"turn_off_unknown": "Bulwark 无法确认在此设备上所做的更改是否已到达服务器。如果现在关闭同步,这些更改将丢失。",
|
||||
"turning_off": "正在上传更改,然后从此设备移除数据…",
|
||||
"turning_on": "正在设置…"
|
||||
},
|
||||
"downloads": {
|
||||
"attachment_template": {
|
||||
"description_mobile": "保存或分享附件时使用的模板。"
|
||||
|
||||
@@ -40,6 +40,11 @@ const TRANSLATED_PANES: Record<string, string[]> = {
|
||||
'settings/UpdatesSettings.tsx': [],
|
||||
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
|
||||
'settings/settings-section.tsx': [],
|
||||
// Device sync (Android, #34).
|
||||
'settings/device-sync/DeviceSyncSection.tsx': [],
|
||||
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
|
||||
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
|
||||
'settings/device-sync/status.ts': [],
|
||||
'filters/FilterRuleModal.tsx': [],
|
||||
'filters/SieveEditorSheet.tsx': [],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
|
||||
import { deviceSyncAvailable } from '../../device-sync/app/available';
|
||||
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
|
||||
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
|
||||
|
||||
// A compact list of IANA zones for the picker (#755). The device zone and
|
||||
// any previously stored value are always offered too.
|
||||
@@ -46,6 +49,9 @@ export function CalendarSettings() {
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
|
||||
// Android with the native module only (#34).
|
||||
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
@@ -64,130 +70,143 @@ export function CalendarSettings() {
|
||||
}, [deviceZone, timeZone, t]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'day', label: t('calendar.views.day', 'Day') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<>
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'day', label: t('calendar.views.day', 'Day') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
|
||||
<Select
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: t('calendar.days.monday', 'Monday') },
|
||||
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
|
||||
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
|
||||
<Select
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: t('calendar.days.monday', 'Monday') },
|
||||
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
|
||||
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
|
||||
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
|
||||
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.time_zone', 'Time zone')}
|
||||
description={t(
|
||||
'calendar.settings.time_zone_desc',
|
||||
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={timeZone || AUTO_TIME_ZONE}
|
||||
onChange={(v) => update('calendarTimeZone', v)}
|
||||
options={timeZoneOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
|
||||
description={t(
|
||||
'calendar.settings.show_time_in_month_view_desc',
|
||||
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||
description={t(
|
||||
'calendar.settings.calendar_free_scroll_desc',
|
||||
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={freeScroll}
|
||||
onChange={(v) => update('calendarFreeScroll', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_birthday_calendar_desc',
|
||||
'Show a virtual calendar with birthdays from your contacts',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
label={t('calendar.settings.time_zone', 'Time zone')}
|
||||
description={t(
|
||||
'calendar.settings.show_tasks_on_calendar_desc',
|
||||
'Display task chips on the day and week calendar views',
|
||||
'calendar.settings.time_zone_desc',
|
||||
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={timeZone || AUTO_TIME_ZONE}
|
||||
onChange={(v) => update('calendarTimeZone', v)}
|
||||
options={timeZoneOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
|
||||
description={t(
|
||||
'calendar.settings.show_time_in_month_view_desc',
|
||||
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||
description={t(
|
||||
'calendar.settings.calendar_free_scroll_desc',
|
||||
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={freeScroll}
|
||||
onChange={(v) => update('calendarFreeScroll', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_birthday_calendar_desc',
|
||||
'Show a virtual calendar with birthdays from your contacts',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_tasks_on_calendar_desc',
|
||||
'Display task chips on the day and week calendar views',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{deviceSync && (
|
||||
<DeviceSyncSection
|
||||
authority={CALENDAR_AUTHORITY}
|
||||
title={t('calendar.settings.device_sync_title', 'Sync to this device')}
|
||||
description={t(
|
||||
'calendar.settings.device_sync_desc',
|
||||
'Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
|
||||
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
|
||||
import { contactsToVCard } from '../../lib/vcard';
|
||||
import { isGroup } from '../../lib/contact-utils';
|
||||
import { deviceSyncAvailable } from '../../device-sync/app/available';
|
||||
import { CONTACTS_AUTHORITY } from '../../device-sync/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -53,6 +56,8 @@ export function ContactsSettings() {
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
|
||||
// Android with the native module only (#34).
|
||||
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
|
||||
const exportLabel =
|
||||
@@ -356,6 +361,17 @@ export function ContactsSettings() {
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{deviceSync && (
|
||||
<DeviceSyncSection
|
||||
authority={CONTACTS_AUTHORITY}
|
||||
title={t('settings.contacts.device_sync_title', 'Sync to this device')}
|
||||
description={t(
|
||||
'settings.contacts.device_sync_desc',
|
||||
'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
// One signed-in account in "Sync to this device" (docs/device-sync.md,
|
||||
// "Settings sections"): the switch (Android's automatic sync for the
|
||||
// authority), the status or the error with what to do about it, and, while
|
||||
// sync is on, which collections sync, where new contacts go or who reminds of
|
||||
// events, the interval, "Sync now" and Android's account settings.
|
||||
import React from 'react';
|
||||
import { Alert, StyleSheet, Text, View, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AlertTriangle, RefreshCw, Settings as SettingsIcon, XCircle } from 'lucide-react-native';
|
||||
import { SettingItem, Select, RadioGroup, ToggleSwitch } from '../settings-section';
|
||||
import Button from '../../Button';
|
||||
import Dialog from '../../Dialog';
|
||||
import { spacing, typography, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../../stores/settings-store';
|
||||
import type { AccountEntry } from '../../../stores/account-store';
|
||||
import {
|
||||
effectiveSelection,
|
||||
emptyAccountDeviceSync,
|
||||
intervalFor,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import {
|
||||
changeReminderOwner,
|
||||
changeSyncInterval,
|
||||
disableDeviceSync,
|
||||
enableDeviceSync,
|
||||
openAndroidAccountSettings,
|
||||
resolveDeletions,
|
||||
resumeDeviceSync,
|
||||
syncNow,
|
||||
} from '../../../device-sync/app/lifecycle';
|
||||
import { hasSyncPermissions, openAppSystemSettings, requestSyncPermissions } from '../../../device-sync/app/permissions';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority, type ReminderOwner } from '../../../device-sync/types';
|
||||
import type { RootStackParamList } from '../../../navigation/types';
|
||||
import { DeviceSyncCollectionsSheet } from './DeviceSyncCollectionsSheet';
|
||||
import { describeSyncStatus, type StatusAction } from './status';
|
||||
import { useAndroidSync } from './use-android-sync';
|
||||
import { useSyncCollections } from './use-sync-collections';
|
||||
|
||||
interface Props {
|
||||
account: AccountEntry;
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
type Busy = 'on' | 'off' | 'sync' | null;
|
||||
|
||||
type Prompt =
|
||||
| { kind: 'rationale' }
|
||||
| { kind: 'blocked' }
|
||||
| { kind: 'pending'; pending: number };
|
||||
|
||||
/** Asks who reminds of synced events; null when the user backed out. */
|
||||
function askReminderOwner(t: TranslateFn): Promise<ReminderOwner | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const answer = (owner: ReminderOwner | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(owner);
|
||||
};
|
||||
Alert.alert(
|
||||
t('settings.device_sync.reminder_question_title', 'Who should remind you?'),
|
||||
t(
|
||||
'settings.device_sync.reminder_question_message',
|
||||
'Synced events can remind you from the calendar app on this device or from Bulwark. Pick one so you are not reminded twice. You can change it later.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(null) },
|
||||
{ text: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark'), onPress: () => answer('bulwark') },
|
||||
{ text: t('settings.device_sync.reminder_owner_device', 'Calendar app'), onPress: () => answer('device') },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => answer(null) },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** How often the status line's "… ago" is brought up to date. */
|
||||
const RELATIVE_TIME_REFRESH_MS = 30_000;
|
||||
|
||||
export function DeviceSyncAccountRow({ account, authority }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const storedEntry = useDeviceSyncStore((s) => s.accounts[account.id]);
|
||||
const entry = React.useMemo(() => storedEntry ?? emptyAccountDeviceSync(), [storedEntry]);
|
||||
const calendarReminders = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||
const isContacts = authority === CONTACTS_AUTHORITY;
|
||||
|
||||
const onInApp = syncOnInApp(entry, authority);
|
||||
const { view, permitted, refresh } = useAndroidSync(entry.androidAccountName, authority);
|
||||
const [busy, setBusy] = React.useState<Busy>(null);
|
||||
// "Synced 2 min ago" keeps counting while the settings stay open.
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const [prompt, setPrompt] = React.useState<Prompt | null>(null);
|
||||
const [chooserOpen, setChooserOpen] = React.useState(false);
|
||||
|
||||
// The switch follows Android; until Android answered, what the app knows.
|
||||
const androidOn = view ? view.exists && view.automatic : onInApp;
|
||||
const checked = busy === 'on' ? true : busy === 'off' ? false : androidOn;
|
||||
const paused = onInApp && !!view?.exists && !view.automatic;
|
||||
const showDetails = (checked || paused) && busy !== 'off';
|
||||
|
||||
const { state: collectionsState } = useSyncCollections(account.id, authority, showDetails);
|
||||
const collections = collectionsState.kind === 'loaded' ? collectionsState.collections : null;
|
||||
const selectedKeys = React.useMemo(
|
||||
() => (collections ? effectiveSelection(entry, authority, collections) : null),
|
||||
[collections, entry, authority],
|
||||
);
|
||||
|
||||
const accountLabel = account.email || account.username;
|
||||
const status = describeSyncStatus(
|
||||
{
|
||||
authority,
|
||||
on: checked,
|
||||
paused,
|
||||
removed: !!entry.removedInAndroidSettings,
|
||||
permitted,
|
||||
syncing: !!view?.active,
|
||||
lastRun: entry.lastRun[authority],
|
||||
now,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
const alertFailed = (message: string) => {
|
||||
Alert.alert(
|
||||
t('settings.device_sync.failed_title', 'Sync could not be turned on'),
|
||||
message,
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
};
|
||||
|
||||
const alertDenied = () => {
|
||||
Alert.alert(
|
||||
t('settings.device_sync.denied_title', 'Sync stays off'),
|
||||
isContacts
|
||||
? t('settings.device_sync.denied_contacts', 'Without access to contacts, Bulwark cannot put your address books on this device.')
|
||||
: t('settings.device_sync.denied_calendar', 'Without access to calendars, Bulwark cannot put your calendars on this device.'),
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
};
|
||||
|
||||
const turnOn = async () => {
|
||||
setPrompt(null);
|
||||
setBusy('on');
|
||||
try {
|
||||
const permission = await requestSyncPermissions(authority);
|
||||
if (permission === 'blocked') {
|
||||
setPrompt({ kind: 'blocked' });
|
||||
return;
|
||||
}
|
||||
if (permission === 'denied') {
|
||||
alertDenied();
|
||||
return;
|
||||
}
|
||||
if (authority === CALENDAR_AUTHORITY && entry.reminderOwner == null) {
|
||||
// Bulwark reminds nobody while its event notifications are off: the
|
||||
// calendar app does, without asking.
|
||||
const owner = calendarReminders ? await askReminderOwner(t) : 'device';
|
||||
if (!owner) return;
|
||||
await changeReminderOwner(account.id, owner);
|
||||
}
|
||||
const outcome = await enableDeviceSync(account.id, authority);
|
||||
if (outcome.kind === 'permission') alertDenied();
|
||||
else if (outcome.kind === 'failed') alertFailed(outcome.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const startTurnOn = async () => {
|
||||
// Explain first, unless access was granted before (another account).
|
||||
if (await hasSyncPermissions(authority)) void turnOn();
|
||||
else setPrompt({ kind: 'rationale' });
|
||||
};
|
||||
|
||||
const turnOff = async (force: boolean) => {
|
||||
setPrompt(null);
|
||||
setBusy('off');
|
||||
try {
|
||||
const result = await disableDeviceSync(account.id, authority, { force });
|
||||
if (result.done) return;
|
||||
if (!force) {
|
||||
setPrompt({ kind: 'pending', pending: result.pending });
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
t('settings.device_sync.turn_off_failed_title', 'Sync could not be turned off'),
|
||||
t(
|
||||
'settings.device_sync.turn_off_failed',
|
||||
'The synced data could not be removed from this device. Sync is paused; try again later.',
|
||||
),
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const keepSyncing = async () => {
|
||||
setPrompt(null);
|
||||
await resumeDeviceSync(account.id, authority);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const handleSyncNow = async () => {
|
||||
setBusy('sync');
|
||||
try {
|
||||
await syncNow(account.id, authority);
|
||||
} catch (err) {
|
||||
alertFailed(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const grantAccess = async () => {
|
||||
const permission = await requestSyncPermissions(authority);
|
||||
if (permission === 'blocked') setPrompt({ kind: 'blocked' });
|
||||
else if (permission === 'denied') alertDenied();
|
||||
else if (checked) await syncNow(account.id, authority).catch(() => false);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const reviewDeletions = () => {
|
||||
const resolve = (choice: 'delete' | 'restore') => {
|
||||
void resolveDeletions(account.id, authority, choice)
|
||||
.catch((err: unknown) => alertFailed(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => { void refresh(); });
|
||||
};
|
||||
Alert.alert(
|
||||
t('settings.device_sync.deletions_title', 'Delete them on the server too?'),
|
||||
t(
|
||||
'settings.device_sync.deletions_message',
|
||||
'Many items were deleted on this device at once, so they were not deleted on the server. Delete them there as well, or bring them back on this device.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('settings.device_sync.deletions_restore', 'Bring them back'), onPress: () => resolve('restore') },
|
||||
{ text: t('settings.device_sync.deletions_delete', 'Delete on server'), style: 'destructive', onPress: () => resolve('delete') },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const runAction = (action: StatusAction) => {
|
||||
if (action === 'signIn') navigation.navigate('AddAccount');
|
||||
else if (action === 'grantAccess') void grantAccess();
|
||||
else reviewDeletions();
|
||||
};
|
||||
|
||||
const closeChooser = (changed: boolean) => {
|
||||
setChooserOpen(false);
|
||||
// A new selection is loaded (or dropped) by the next sync: run it now.
|
||||
// (A sync Android paused would only report that it is off.)
|
||||
if (changed && checked) void syncNow(account.id, authority).catch(() => false);
|
||||
};
|
||||
|
||||
const intervalOptions = [
|
||||
{ value: '900', label: t('settings.device_sync.interval_15m', 'Every 15 minutes') },
|
||||
{ value: '1800', label: t('settings.device_sync.interval_30m', 'Every 30 minutes') },
|
||||
{ value: '3600', label: t('settings.device_sync.interval_1h', 'Every hour') },
|
||||
{ value: '21600', label: t('settings.device_sync.interval_6h', 'Every 6 hours') },
|
||||
{ value: '0', label: t('settings.device_sync.interval_manual', 'No regular sync') },
|
||||
];
|
||||
|
||||
const writableSelected = (collections ?? []).filter((col) => !col.readOnly && selectedKeys?.includes(col.key));
|
||||
const newContactsOptions = [
|
||||
{ value: '', label: t('settings.device_sync.new_contacts_default', 'Default address book') },
|
||||
...writableSelected.map((col) => ({
|
||||
value: col.key,
|
||||
label: col.isPersonal ? col.name : `${col.name} (${col.accountName})`,
|
||||
})),
|
||||
];
|
||||
const newContactsValue = entry.newContactsAddressBook && newContactsOptions.some((o) => o.value === entry.newContactsAddressBook)
|
||||
? entry.newContactsAddressBook
|
||||
: '';
|
||||
|
||||
const collectionsDescription = collections && selectedKeys
|
||||
? t('settings.device_sync.collections_count', '{selected, plural, one {# of {total} syncs} other {# of {total} sync}}', {
|
||||
selected: selectedKeys.length,
|
||||
total: collections.length,
|
||||
})
|
||||
: isContacts
|
||||
? t('settings.device_sync.books_default', 'Your own address books sync; shared ones only when you pick them.')
|
||||
: t('settings.device_sync.calendars_default', 'Your own calendars sync; shared ones only when you pick them.');
|
||||
|
||||
const statusStyle = status.tone === 'error' ? styles.statusError : status.tone === 'warning' ? styles.statusWarning : styles.statusMuted;
|
||||
const busyText = busy === 'on'
|
||||
? t('settings.device_sync.turning_on', 'Setting up…')
|
||||
: busy === 'off'
|
||||
? t('settings.device_sync.turning_off', 'Uploading changes, then removing the data from this device…')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<SettingItem label={accountLabel} description={busyText ?? undefined} noBorder>
|
||||
<View style={styles.row}>
|
||||
{(busy === 'on' || busy === 'off') && <ActivityIndicator size="small" color={c.primary} />}
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onChange={(next) => {
|
||||
if (next) void startTurnOn();
|
||||
else void turnOff(false);
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
accessibilityLabel={t('settings.device_sync.switch_label', 'Sync {account} to this device', { account: accountLabel })}
|
||||
/>
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{!busyText && (
|
||||
<View style={styles.statusRow} accessibilityRole={status.tone === 'error' ? 'alert' : undefined}>
|
||||
{status.tone === 'error' && <XCircle size={14} color={c.error} />}
|
||||
{status.tone === 'warning' && <AlertTriangle size={14} color={c.warning} />}
|
||||
<Text style={[styles.statusText, statusStyle]}>{status.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!busyText && status.action && (
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" size="sm" onPress={() => runAction(status.action!)}>
|
||||
{status.action === 'signIn'
|
||||
? t('settings.device_sync.sign_in_again', 'Sign in again')
|
||||
: status.action === 'grantAccess'
|
||||
? t('settings.device_sync.grant_access', 'Grant access')
|
||||
: t('settings.device_sync.review_deletions', 'Review deletions')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{showDetails && view && !view.masterAutomatic && (
|
||||
<Text style={[styles.statusText, styles.statusWarning]}>
|
||||
{t(
|
||||
'settings.device_sync.master_off',
|
||||
'Auto-sync is off on this device, so syncs only run when you tap Sync now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showDetails && (
|
||||
<View style={styles.details}>
|
||||
<SettingItem
|
||||
label={isContacts
|
||||
? t('settings.device_sync.books_label', 'Address books')
|
||||
: t('settings.device_sync.calendars_label', 'Calendars')}
|
||||
description={collectionsDescription}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => setChooserOpen(true)}>
|
||||
{t('settings.device_sync.choose', 'Choose')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{isContacts ? (
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.new_contacts_label', 'New contacts go to')}
|
||||
description={t(
|
||||
'settings.device_sync.new_contacts_desc',
|
||||
'The address book for contacts created in other apps on this device.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={newContactsValue}
|
||||
onChange={(v) => useDeviceSyncStore.getState().setNewContactsAddressBook(account.id, v || null)}
|
||||
options={newContactsOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
) : (
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.reminders_label', 'Reminders')}
|
||||
description={entry.reminderOwner === 'bulwark' && !calendarReminders
|
||||
? t(
|
||||
'settings.device_sync.reminders_nobody',
|
||||
"Bulwark's event notifications are off, so nothing reminds you of synced events.",
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.reminders_desc',
|
||||
'Who reminds you of synced events. The other stays quiet, so you are not reminded twice.',
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={entry.reminderOwner ?? 'device'}
|
||||
onChange={(v) => { void changeReminderOwner(account.id, v === 'bulwark' ? 'bulwark' : 'device'); }}
|
||||
options={[
|
||||
{ value: 'device', label: t('settings.device_sync.reminder_owner_device', 'Calendar app') },
|
||||
{ value: 'bulwark', label: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.interval_label', 'Sync interval')}
|
||||
description={t(
|
||||
'settings.device_sync.interval_desc',
|
||||
'How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.',
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<Select
|
||||
value={String(intervalFor(entry, authority))}
|
||||
onChange={(v) => { void changeSyncInterval(account.id, authority, Number(v)); }}
|
||||
options={intervalOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => { void handleSyncNow(); }}
|
||||
disabled={busy !== null || !checked}
|
||||
loading={busy === 'sync'}
|
||||
icon={<RefreshCw size={14} color={c.text} />}
|
||||
>
|
||||
{t('settings.device_sync.sync_now', 'Sync now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => { void openAndroidAccountSettings(account.id); }}
|
||||
disabled={!view?.exists}
|
||||
icon={<SettingsIcon size={14} color={c.text} />}
|
||||
>
|
||||
{t('settings.device_sync.android_settings', 'Android account settings')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DeviceSyncCollectionsSheet
|
||||
visible={chooserOpen}
|
||||
onClose={closeChooser}
|
||||
registryId={account.id}
|
||||
authority={authority}
|
||||
accountLabel={accountLabel}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'rationale'}
|
||||
title={isContacts
|
||||
? t('settings.device_sync.rationale_title_contacts', 'Allow access to contacts')
|
||||
: t('settings.device_sync.rationale_title_calendar', 'Allow access to calendars')}
|
||||
message={isContacts
|
||||
? t(
|
||||
'settings.device_sync.rationale_contacts',
|
||||
'To show your address books in the Contacts app, Bulwark needs access to the contacts on this device. It only changes the contacts of its own account.',
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.rationale_calendar',
|
||||
'To show your calendars in the Calendar app, Bulwark needs access to the calendars on this device. It only changes the calendars of its own account.',
|
||||
)}
|
||||
confirmText={t('settings.device_sync.continue', 'Continue')}
|
||||
onConfirm={() => { void turnOn(); }}
|
||||
onCancel={() => setPrompt(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'blocked'}
|
||||
title={t('settings.device_sync.blocked_title', 'Access is turned off')}
|
||||
message={isContacts
|
||||
? t(
|
||||
'settings.device_sync.blocked_contacts',
|
||||
"Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.",
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.blocked_calendar',
|
||||
"Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.",
|
||||
)}
|
||||
confirmText={t('settings.device_sync.open_app_settings', 'Open app settings')}
|
||||
onConfirm={() => {
|
||||
setPrompt(null);
|
||||
void openAppSystemSettings();
|
||||
}}
|
||||
onCancel={() => setPrompt(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'pending'}
|
||||
variant="destructive"
|
||||
title={t('settings.device_sync.turn_off_title', 'Turn off anyway?')}
|
||||
message={prompt?.kind === 'pending' && prompt.pending > 0
|
||||
? t(
|
||||
'settings.device_sync.turn_off_pending',
|
||||
"{count, plural, one {# change made on this device hasn't reached the server yet. It is lost if you turn sync off now.} other {# changes made on this device haven't reached the server yet. They are lost if you turn sync off now.}}",
|
||||
{ count: prompt.pending },
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.turn_off_unknown',
|
||||
'Bulwark could not check whether changes made on this device reached the server. They are lost if you turn sync off now.',
|
||||
)}
|
||||
confirmText={t('settings.device_sync.turn_off_anyway', 'Turn off')}
|
||||
cancelText={t('settings.device_sync.keep_syncing', 'Keep syncing')}
|
||||
onConfirm={() => { void turnOff(true); }}
|
||||
onCancel={() => { void keepSyncing(); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
block: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
|
||||
statusText: { ...typography.caption, flexShrink: 1 },
|
||||
statusMuted: { color: c.mutedForeground },
|
||||
statusWarning: { color: c.warning },
|
||||
statusError: { color: c.error },
|
||||
details: {
|
||||
marginTop: spacing.sm,
|
||||
marginStart: spacing.sm,
|
||||
paddingStart: spacing.md,
|
||||
borderStartWidth: 2,
|
||||
borderStartColor: c.muted,
|
||||
},
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Which address books or calendars of one account sync to this device
|
||||
// (docs/device-sync.md, "Settings sections"). The account's own collections
|
||||
// are on unless turned off, shared ones off unless turned on; read-only ones
|
||||
// are marked, and a collection another signed-in account already syncs gets
|
||||
// a warning (it would show twice on the phone).
|
||||
import React from 'react';
|
||||
import {
|
||||
ActivityIndicator, Animated, Easing, Modal, Pressable, ScrollView, StyleSheet, Text, View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AlertTriangle, Check, X } from 'lucide-react-native';
|
||||
import Button from '../../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useSheetDrag } from '../../../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../../../stores/locale-store';
|
||||
import { useAccountStore } from '../../../stores/account-store';
|
||||
import {
|
||||
isCollectionSelected,
|
||||
selectionFor,
|
||||
useDeviceSyncStore,
|
||||
emptyAccountDeviceSync,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
|
||||
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
|
||||
import { useSyncCollections } from './use-sync-collections';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
/** `changed`: the selection was edited while the sheet was open. */
|
||||
onClose: (changed: boolean) => void;
|
||||
registryId: string;
|
||||
authority: Authority;
|
||||
/** The account's address, under the title. */
|
||||
accountLabel: string;
|
||||
}
|
||||
|
||||
export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, authority, accountLabel }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { state, reload } = useSyncCollections(registryId, authority, visible);
|
||||
const entry = useDeviceSyncStore((s) => s.accounts[registryId]);
|
||||
const allEntries = useDeviceSyncStore((s) => s.accounts);
|
||||
const setCollectionSelected = useDeviceSyncStore((s) => s.setCollectionSelected);
|
||||
const registry = useAccountStore((s) => s.accounts);
|
||||
const changed = React.useRef(false);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const close = React.useCallback(() => {
|
||||
const wasChanged = changed.current;
|
||||
changed.current = false;
|
||||
onClose(wasChanged);
|
||||
}, [onClose]);
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose: close });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
changed.current = false;
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const selection = selectionFor(entry ?? emptyAccountDeviceSync(), authority);
|
||||
const collections = state.kind === 'loaded' || state.kind === 'loading' ? state.collections ?? [] : [];
|
||||
const groups = React.useMemo(() => {
|
||||
const out: Array<{ jmapAccountId: string; isPersonal: boolean; accountName: string; items: SyncCollection[] }> = [];
|
||||
for (const collection of collections) {
|
||||
let group = out.find((g) => g.jmapAccountId === collection.jmapAccountId);
|
||||
if (!group) {
|
||||
group = {
|
||||
jmapAccountId: collection.jmapAccountId,
|
||||
isPersonal: collection.isPersonal,
|
||||
accountName: collection.accountName,
|
||||
items: [],
|
||||
};
|
||||
out.push(group);
|
||||
}
|
||||
group.items.push(collection);
|
||||
}
|
||||
return out;
|
||||
}, [collections]);
|
||||
|
||||
const toggle = (collection: SyncCollection) => {
|
||||
const on = isCollectionSelected(selection, authority, collection);
|
||||
setCollectionSelected(registryId, authority, collection.key, !on);
|
||||
changed.current = true;
|
||||
};
|
||||
|
||||
const errorText = state.kind !== 'error'
|
||||
? ''
|
||||
: state.error === 'signedOut'
|
||||
? t('settings.device_sync.list_error_signed_out', 'This account is not signed in on this device.')
|
||||
: state.error === 'auth'
|
||||
? t('settings.device_sync.list_error_auth', 'The server did not accept the sign-in.')
|
||||
: state.error === 'network'
|
||||
? t('settings.device_sync.list_error_network', 'Could not reach the server.')
|
||||
: t('settings.device_sync.list_error_server', 'The server could not list them.');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={close} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.title}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.choose_books_title', 'Address books to sync')
|
||||
: t('settings.device_sync.choose_calendars_title', 'Calendars to sync')}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>{accountLabel}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={close}
|
||||
hitSlop={8}
|
||||
style={styles.close}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
{state.kind === 'error' && (
|
||||
<View style={styles.message} accessibilityRole="alert">
|
||||
<Text style={styles.errorText}>{errorText}</Text>
|
||||
<Button variant="outline" size="sm" onPress={reload}>
|
||||
{t('common.retry', 'Retry')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{(state.kind === 'loading' || state.kind === 'idle') && collections.length === 0 && (
|
||||
<View style={styles.message}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
{state.kind === 'loaded' && collections.length === 0 && (
|
||||
<View style={styles.message}>
|
||||
<Text style={styles.muted}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.no_books', 'This account has no address books.')
|
||||
: t('settings.device_sync.no_calendars', 'This account has no calendars to sync.')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<View key={group.jmapAccountId} style={styles.group}>
|
||||
<Text style={styles.groupTitle}>
|
||||
{group.isPersonal
|
||||
? t('settings.device_sync.own_collections', 'Your own')
|
||||
: t('settings.device_sync.shared_by', 'Shared by {name}', { name: group.accountName })}
|
||||
</Text>
|
||||
{group.items.map((collection) => {
|
||||
const on = isCollectionSelected(selection, authority, collection);
|
||||
const others = on ? otherAccountsSyncing(registryId, authority, collection, registry, allEntries) : [];
|
||||
const tint = collection.color ?? c.primary;
|
||||
return (
|
||||
<Pressable
|
||||
key={collection.key}
|
||||
onPress={() => toggle(collection)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: on }}
|
||||
accessibilityLabel={collection.name}
|
||||
>
|
||||
<View style={[styles.box, { borderColor: tint, backgroundColor: on ? tint : 'transparent' }]}>
|
||||
{on && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={[styles.rowName, !on && styles.rowNameOff]} numberOfLines={1}>
|
||||
{collection.name}
|
||||
</Text>
|
||||
{(collection.readOnly || collection.isDefault) && (
|
||||
<Text style={styles.muted}>
|
||||
{[
|
||||
collection.isDefault ? t('contacts.address_books.default', 'Default') : null,
|
||||
collection.readOnly ? t('settings.device_sync.read_only', 'Read-only') : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<View style={styles.warningRow}>
|
||||
<AlertTriangle size={12} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t(
|
||||
'settings.device_sync.also_synced_by',
|
||||
'Also synced for {accounts}, so it shows twice on this device.',
|
||||
{ accounts: others.join(', ') },
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.hint}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t(
|
||||
'settings.device_sync.books_hint',
|
||||
'Your own address books sync unless you turn them off; shared ones only when you turn them on.',
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.calendars_hint',
|
||||
'Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.',
|
||||
)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '80%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.mutedForeground },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
body: { paddingBottom: spacing.md },
|
||||
message: { alignItems: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||
errorText: { ...typography.caption, color: c.error, textAlign: 'center' },
|
||||
muted: { ...typography.caption, color: c.mutedForeground },
|
||||
group: { paddingTop: spacing.sm },
|
||||
groupTitle: {
|
||||
...typography.captionMedium,
|
||||
color: c.mutedForeground,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
box: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowNameOff: { color: c.textSecondary },
|
||||
warningRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 4, marginTop: 2 },
|
||||
warningText: { ...typography.caption, color: c.warning, flex: 1 },
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// "Sync to this device" in Settings → Contacts and Settings → Calendar
|
||||
// (Android only, #34; docs/device-sync.md "Settings sections"): one block per
|
||||
// signed-in account, then the accounts the app signed out while they synced
|
||||
// (their data stays until the user signs in again or removes it). The panes
|
||||
// pass the title and description, so the settings search indexes them where
|
||||
// they are rendered.
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { SettingsSection, SettingItem } from '../settings-section';
|
||||
import Button from '../../Button';
|
||||
import Dialog from '../../Dialog';
|
||||
import { spacing, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useAccountStore } from '../../../stores/account-store';
|
||||
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
|
||||
import { useLocaleStore } from '../../../stores/locale-store';
|
||||
import { removeSuspendedAccount } from '../../../device-sync/app/lifecycle';
|
||||
import type { Authority } from '../../../device-sync/types';
|
||||
import type { RootStackParamList } from '../../../navigation/types';
|
||||
import { DeviceSyncAccountRow } from './DeviceSyncAccountRow';
|
||||
|
||||
interface Props {
|
||||
authority: Authority;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function DeviceSyncSection({ authority, title, description }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const syncEntries = useDeviceSyncStore((s) => s.accounts);
|
||||
const suspended = React.useMemo(
|
||||
() => Object.keys(syncEntries)
|
||||
.filter((id) => syncEntries[id].suspended && !accounts.some((a) => a.id === id))
|
||||
.sort(),
|
||||
[syncEntries, accounts],
|
||||
);
|
||||
if (accounts.length === 0 && suspended.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<SettingsSection title={title} description={description}>
|
||||
{accounts.map((account) => (
|
||||
<DeviceSyncAccountRow key={account.id} account={account} authority={authority} />
|
||||
))}
|
||||
{suspended.map((registryId) => (
|
||||
<SuspendedAccountRow key={registryId} registryId={registryId} styles={styles} />
|
||||
))}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// An account the app signed out (its sign-in failed) while it synced: sync is
|
||||
// off, the data stays. Signing in again resumes it; removing drops it.
|
||||
function SuspendedAccountRow({ registryId, styles }: { registryId: string; styles: ReturnType<typeof makeStyles> }) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
|
||||
const remove = async () => {
|
||||
setConfirming(false);
|
||||
setRemoving(true);
|
||||
try {
|
||||
await removeSuspendedAccount(registryId);
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<SettingItem
|
||||
label={registryId}
|
||||
description={t(
|
||||
'settings.device_sync.suspended_desc',
|
||||
'Signed out. Its contacts and calendars stay on this device, with the changes made here, and sync again once you sign in.',
|
||||
)}
|
||||
noBorder
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" size="sm" onPress={() => navigation.navigate('AddAccount')}>
|
||||
{t('settings.device_sync.sign_in_again', 'Sign in again')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={() => setConfirming(true)} loading={removing} disabled={removing}>
|
||||
{t('settings.device_sync.remove_from_device', 'Remove from this device')}
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog
|
||||
visible={confirming}
|
||||
variant="destructive"
|
||||
title={t('settings.device_sync.remove_title', 'Remove from this device?')}
|
||||
message={t(
|
||||
'settings.device_sync.remove_message',
|
||||
"The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.",
|
||||
)}
|
||||
confirmText={t('settings.device_sync.remove', 'Remove')}
|
||||
onConfirm={() => { void remove(); }}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
section: { marginTop: spacing.xxxl },
|
||||
block: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { translate, type MessageParams } from '../../../../i18n';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type RunStatus } from '../../../../device-sync/types';
|
||||
import { describeSyncStatus, relativeTime, type StatusInput } from '../status';
|
||||
|
||||
const t = (key: string, fallback?: string, params?: MessageParams) => translate('en', key, fallback, params);
|
||||
|
||||
const NOW = Date.UTC(2026, 8, 27, 12, 0, 0);
|
||||
|
||||
const run = (outcome: RunStatus['outcome'], extra: Partial<RunStatus> = {}): RunStatus => ({
|
||||
at: NOW - 5 * 60_000,
|
||||
outcome,
|
||||
durationMs: 100,
|
||||
conflicts: 0,
|
||||
itemErrors: 0,
|
||||
stats: {
|
||||
downloaded: { created: 0, updated: 0, deleted: 0 },
|
||||
uploaded: { created: 0, updated: 0, deleted: 0 },
|
||||
entries: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const base: StatusInput = {
|
||||
authority: CONTACTS_AUTHORITY,
|
||||
on: true,
|
||||
paused: false,
|
||||
removed: false,
|
||||
permitted: true,
|
||||
syncing: false,
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
describe('describeSyncStatus', () => {
|
||||
it('says off, or that the account was removed in Android settings', () => {
|
||||
expect(describeSyncStatus({ ...base, on: false }, t)).toEqual({ text: 'Off', tone: 'muted' });
|
||||
expect(describeSyncStatus({ ...base, on: false, removed: true }, t).tone).toBe('warning');
|
||||
});
|
||||
|
||||
it('puts a revoked permission first, with a way to grant it again', () => {
|
||||
const line = describeSyncStatus({ ...base, permitted: false, lastRun: run('ok') }, t);
|
||||
expect(line).toMatchObject({ tone: 'error', action: 'grantAccess' });
|
||||
expect(line.text).toContain('contacts');
|
||||
expect(describeSyncStatus({ ...base, authority: CALENDAR_AUTHORITY, permitted: false }, t).text).toContain('calendars');
|
||||
});
|
||||
|
||||
it('says when Android paused the account', () => {
|
||||
expect(describeSyncStatus({ ...base, on: false, paused: true }, t))
|
||||
.toEqual({ text: 'Paused in Android settings', tone: 'warning' });
|
||||
});
|
||||
|
||||
it('shows a running sync, then when it last synced', () => {
|
||||
expect(describeSyncStatus({ ...base, syncing: true, lastRun: run('ok') }, t).text).toBe('Syncing…');
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('ok') }, t)).toEqual({ text: 'Synced 5 min ago', tone: 'muted' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('ok', { itemErrors: 2 }) }, t)).toEqual({
|
||||
text: 'Synced 5 min ago · 2 items could not be synced.',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(describeSyncStatus(base, t).text).toBe('Waiting for the first sync');
|
||||
});
|
||||
|
||||
it('offers what fixes a failed run', () => {
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('auth') }, t)).toMatchObject({ tone: 'error', action: 'signIn' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('tooManyDeletions') }, t)).toMatchObject({ action: 'reviewDeletions' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('permission') }, t)).toMatchObject({ action: 'grantAccess' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('io') }, t).text).toBe('Could not reach the server 5 min ago. Android tries again.');
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('internal', { message: 'Boom' }) }, t).text)
|
||||
.toBe('The last sync failed: Boom');
|
||||
// A run that found sync off says nothing new.
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('disabled') }, t).text).toBe('Waiting for the next sync');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('counts minutes, hours and days', () => {
|
||||
expect(relativeTime(NOW - 10_000, NOW, t)).toBe('just now');
|
||||
expect(relativeTime(NOW - 3 * 3600_000, NOW, t)).toBe('3 h ago');
|
||||
expect(relativeTime(NOW - 2 * 86_400_000, NOW, t)).toBe('2 d ago');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// The status line of one account's device sync in Settings: off, paused in
|
||||
// Android settings, syncing, when it last synced, or what went wrong and what
|
||||
// the user can do about it (docs/device-sync.md, "Settings sections",
|
||||
// "Failure matrix").
|
||||
import { CONTACTS_AUTHORITY, type Authority, type RunStatus } from '../../../device-sync/types';
|
||||
import type { TranslateFn } from '../../../stores/locale-store';
|
||||
|
||||
/** What the status line offers to do. */
|
||||
export type StatusAction = 'signIn' | 'grantAccess' | 'reviewDeletions';
|
||||
|
||||
export interface StatusLine {
|
||||
text: string;
|
||||
tone: 'muted' | 'warning' | 'error';
|
||||
action?: StatusAction;
|
||||
}
|
||||
|
||||
export interface StatusInput {
|
||||
authority: Authority;
|
||||
/** The switch: Android syncs this authority automatically. */
|
||||
on: boolean;
|
||||
/** On in the app but off in Android's settings for the account. */
|
||||
paused: boolean;
|
||||
/** The Android account was removed in Android Settings. */
|
||||
removed: boolean;
|
||||
/** The runtime permissions; null while unknown. */
|
||||
permitted: boolean | null;
|
||||
syncing: boolean;
|
||||
lastRun?: RunStatus;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", … */
|
||||
export function relativeTime(at: number, now: number, t: TranslateFn): string {
|
||||
const diff = Math.max(0, now - at);
|
||||
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
|
||||
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
|
||||
if (diff < 24 * 60 * 60_000) {
|
||||
return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
|
||||
}
|
||||
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
|
||||
}
|
||||
|
||||
function permissionLine(authority: Authority, t: TranslateFn): StatusLine {
|
||||
return {
|
||||
text: authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.status_permission_contacts', 'Access to contacts was turned off, so nothing syncs.')
|
||||
: t('settings.device_sync.status_permission_calendar', 'Access to calendars was turned off, so nothing syncs.'),
|
||||
tone: 'error',
|
||||
action: 'grantAccess',
|
||||
};
|
||||
}
|
||||
|
||||
function runLine(run: RunStatus, input: StatusInput, t: TranslateFn): StatusLine | null {
|
||||
const when = relativeTime(run.at, input.now, t);
|
||||
switch (run.outcome) {
|
||||
case 'ok': {
|
||||
const synced = t('settings.device_sync.status_synced', 'Synced {when}', { when });
|
||||
if (run.itemErrors <= 0) return { text: synced, tone: 'muted' };
|
||||
const skipped = t(
|
||||
'settings.device_sync.status_item_errors',
|
||||
'{count, plural, one {# item could not be synced.} other {# items could not be synced.}}',
|
||||
{ count: run.itemErrors },
|
||||
);
|
||||
return { text: `${synced} · ${skipped}`, tone: 'warning' };
|
||||
}
|
||||
case 'auth':
|
||||
return {
|
||||
text: t('settings.device_sync.status_auth', 'The server no longer accepts the sign-in. Sign in again to keep syncing.'),
|
||||
tone: 'error',
|
||||
action: 'signIn',
|
||||
};
|
||||
case 'permission':
|
||||
return permissionLine(input.authority, t);
|
||||
case 'tooManyDeletions':
|
||||
return {
|
||||
text: t(
|
||||
'settings.device_sync.status_too_many_deletions',
|
||||
'Many items were deleted on this device. They are not deleted on the server until you confirm.',
|
||||
),
|
||||
tone: 'warning',
|
||||
action: 'reviewDeletions',
|
||||
};
|
||||
case 'io':
|
||||
return {
|
||||
text: t('settings.device_sync.status_io', 'Could not reach the server {when}. Android tries again.', { when }),
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'unsupported':
|
||||
return {
|
||||
text: input.authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.status_unsupported_contacts', 'The server offers no address books for this account.')
|
||||
: t('settings.device_sync.status_unsupported_calendar', 'The server offers no calendars for this account.'),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'safetyAbort':
|
||||
return {
|
||||
text: t(
|
||||
'settings.device_sync.status_safety_abort',
|
||||
'Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.',
|
||||
),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'cancelled':
|
||||
return { text: t('settings.device_sync.status_cancelled', 'The last sync stopped early. It continues next time.'), tone: 'muted' };
|
||||
case 'internal':
|
||||
return {
|
||||
text: run.message
|
||||
? t('settings.device_sync.status_failed', 'The last sync failed: {message}', { message: run.message })
|
||||
: t('settings.device_sync.status_failed_plain', 'The last sync failed.'),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'disabled':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSyncStatus(input: StatusInput, t: TranslateFn): StatusLine {
|
||||
if (!input.on && !input.paused) {
|
||||
return input.removed
|
||||
? {
|
||||
text: t('settings.device_sync.status_removed', 'Removed in Android settings. Turn on to add the account again.'),
|
||||
tone: 'warning',
|
||||
}
|
||||
: { text: t('settings.device_sync.status_off', 'Off'), tone: 'muted' };
|
||||
}
|
||||
if (input.permitted === false) return permissionLine(input.authority, t);
|
||||
if (input.paused) return { text: t('settings.device_sync.status_paused', 'Paused in Android settings'), tone: 'warning' };
|
||||
if (input.syncing) return { text: t('settings.device_sync.status_syncing', 'Syncing…'), tone: 'muted' };
|
||||
if (!input.lastRun) return { text: t('settings.device_sync.status_waiting', 'Waiting for the first sync'), tone: 'muted' };
|
||||
return runLine(input.lastRun, input, t)
|
||||
?? { text: t('settings.device_sync.status_waiting_next', 'Waiting for the next sync'), tone: 'muted' };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Android's side of one account's device sync for the settings: whether the
|
||||
// Android account exists, its automatic sync for the authority (the switch),
|
||||
// the device's master auto-sync, whether a sync runs or waits, and whether
|
||||
// the runtime permissions are still granted. Re-read on mount, when the app
|
||||
// returns to the foreground, when accounts of our type change, and every few
|
||||
// seconds while a sync runs or waits.
|
||||
import React from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { getSyncSettings, listAndroidAccounts, onAccountsChanged } from '../../../device-sync/native';
|
||||
import { hasSyncPermissions } from '../../../device-sync/app/permissions';
|
||||
import type { Authority } from '../../../device-sync/types';
|
||||
|
||||
export interface AndroidSyncView {
|
||||
/** The Android account exists. */
|
||||
exists: boolean;
|
||||
/** Automatic sync for the authority: the toggle in Settings → Accounts. */
|
||||
automatic: boolean;
|
||||
/** The device's "Auto-sync data". */
|
||||
masterAutomatic: boolean;
|
||||
active: boolean;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
export function useAndroidSync(accountName: string | undefined, authority: Authority): {
|
||||
view: AndroidSyncView | null;
|
||||
permitted: boolean | null;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [view, setView] = React.useState<AndroidSyncView | null>(null);
|
||||
const [permitted, setPermitted] = React.useState<boolean | null>(null);
|
||||
const alive = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => { alive.current = false; };
|
||||
}, []);
|
||||
// Read at call time: turning sync on creates the account while the
|
||||
// caller still holds the refresh of the render before.
|
||||
const nameRef = React.useRef(accountName);
|
||||
nameRef.current = accountName;
|
||||
// Only the latest read may land; an older one resolving late is stale.
|
||||
const reads = React.useRef(0);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
const read = ++reads.current;
|
||||
const accountName = nameRef.current;
|
||||
const granted = await hasSyncPermissions(authority);
|
||||
let next: AndroidSyncView = { exists: false, automatic: false, masterAutomatic: true, active: false, pending: false };
|
||||
if (accountName) {
|
||||
try {
|
||||
const accounts = await listAndroidAccounts();
|
||||
if (accounts.some((a) => a.name === accountName)) {
|
||||
const settings = await getSyncSettings(accountName);
|
||||
const own = settings.authorities?.[authority];
|
||||
next = {
|
||||
exists: true,
|
||||
automatic: !!own?.automatic,
|
||||
masterAutomatic: settings.masterAutomatic !== false,
|
||||
active: !!own?.active,
|
||||
pending: !!own?.pending,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Shown as off until the next read.
|
||||
}
|
||||
}
|
||||
if (!alive.current || read !== reads.current) return;
|
||||
setPermitted(granted);
|
||||
setView(next);
|
||||
}, [authority]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, accountName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void refresh();
|
||||
});
|
||||
let unsubscribe: () => void = () => undefined;
|
||||
try {
|
||||
unsubscribe = onAccountsChanged(() => { void refresh(); });
|
||||
} catch {
|
||||
// Foreground reads still catch up.
|
||||
}
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const busy = !!view?.active || !!view?.pending;
|
||||
React.useEffect(() => {
|
||||
if (!busy) return undefined;
|
||||
const timer = setInterval(() => { void refresh(); }, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [busy, refresh]);
|
||||
|
||||
return { view, permitted, refresh };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// The address books or calendars of one account for the device sync settings,
|
||||
// listed through the account's own client (device-sync/app/collections) and
|
||||
// cached for a little while, so the chooser, the counts and the "new contacts
|
||||
// go to" picker share one request.
|
||||
import React from 'react';
|
||||
import {
|
||||
cachedSyncCollections,
|
||||
CollectionsError,
|
||||
listSyncCollections,
|
||||
onSyncCollectionsLoaded,
|
||||
type CollectionsErrorKind,
|
||||
type SyncCollection,
|
||||
} from '../../../device-sync/app/collections';
|
||||
import { CALENDAR_AUTHORITY, type Authority } from '../../../device-sync/types';
|
||||
import { useAuthStore } from '../../../stores/auth-store';
|
||||
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
|
||||
|
||||
export type CollectionsState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; collections: SyncCollection[] | null }
|
||||
| { kind: 'loaded'; collections: SyncCollection[] }
|
||||
| { kind: 'error'; error: CollectionsErrorKind };
|
||||
|
||||
export function useSyncCollections(registryId: string, authority: Authority, active: boolean): {
|
||||
state: CollectionsState;
|
||||
reload: () => void;
|
||||
} {
|
||||
const [state, setState] = React.useState<CollectionsState>(() => {
|
||||
const cached = cachedSyncCollections(registryId, authority);
|
||||
return cached ? { kind: 'loaded', collections: cached } : { kind: 'idle' };
|
||||
});
|
||||
const [attempt, setAttempt] = React.useState(0);
|
||||
// Calendars mirroring subscribed feeds are read-only on the device. Only
|
||||
// the active account's subscriptions are known here.
|
||||
const isActive = useAuthStore((s) => s.activeAccountId === registryId);
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const feeds = React.useMemo(
|
||||
() => (authority === CALENDAR_AUTHORITY && isActive
|
||||
? subscriptions.map((sub) => ({ calendarId: sub.calendarId, accountId: sub.accountId }))
|
||||
: []),
|
||||
[authority, isActive, subscriptions],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
let cancelled = false;
|
||||
const force = attempt > 0;
|
||||
setState((prev) => ({ kind: 'loading', collections: prev.kind === 'loaded' ? prev.collections : null }));
|
||||
listSyncCollections(registryId, authority, { force, feeds })
|
||||
.then((collections) => {
|
||||
if (!cancelled) setState({ kind: 'loaded', collections });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: 'error', error: err instanceof CollectionsError ? err.kind : 'server' });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [registryId, authority, active, attempt, feeds]);
|
||||
|
||||
// The chooser loads its own, fresher list: the row's counts follow it.
|
||||
React.useEffect(
|
||||
() => onSyncCollectionsLoaded((id, auth, collections) => {
|
||||
if (id === registryId && auth === authority) setState({ kind: 'loaded', collections });
|
||||
}),
|
||||
[registryId, authority],
|
||||
);
|
||||
|
||||
const reload = React.useCallback(() => setAttempt((n) => n + 1), []);
|
||||
return { state, reload };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
CollectionsError,
|
||||
forgetSyncCollections,
|
||||
listSyncCollections,
|
||||
onSyncCollectionsLoaded,
|
||||
otherAccountsSyncing,
|
||||
type CollectionsClient,
|
||||
} from '../../app/collections';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, JMAP_CALENDARS, JMAP_CONTACTS } from '../../types';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import type { JMAPMethodCall } from '../../../api/types';
|
||||
|
||||
type Handler = (args: Record<string, any>, calls: JMAPMethodCall[]) => any;
|
||||
|
||||
// A client for one account with the personal JMAP account `c` and a shared
|
||||
// account `team`, answering the calls the listing makes.
|
||||
function fakeClient(handlers: Record<string, Handler>, options: { loaded?: boolean | Error; maxCalls?: number } = {}) {
|
||||
const requests: JMAPMethodCall[][] = [];
|
||||
const client: CollectionsClient = {
|
||||
async loadAccount() {
|
||||
if (options.loaded instanceof Error) throw options.loaded;
|
||||
return options.loaded ?? true;
|
||||
},
|
||||
get currentSession() {
|
||||
return {
|
||||
primaryAccounts: { [JMAP_CONTACTS]: 'c', [JMAP_CALENDARS]: 'c' },
|
||||
accounts: {
|
||||
c: { name: 'alice@example.org', isPersonal: true, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
|
||||
team: { name: 'Team', isPersonal: false, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
|
||||
mailonly: { name: 'Archive', isPersonal: false, accountCapabilities: {} },
|
||||
},
|
||||
};
|
||||
},
|
||||
async request(calls) {
|
||||
requests.push(calls);
|
||||
const responses: Array<[string, any, string]> = [];
|
||||
for (const [name, args, id] of calls) {
|
||||
const handler = handlers[`${name} ${args.accountId}`] ?? handlers[name];
|
||||
if (!handler) {
|
||||
responses.push(['error', { type: 'unknownMethod' }, id]);
|
||||
continue;
|
||||
}
|
||||
const body = handler(args, calls);
|
||||
responses.push(body === null ? ['error', { type: 'forbidden' }, id] : [name, body, id]);
|
||||
}
|
||||
return { methodResponses: responses };
|
||||
},
|
||||
getMaxCallsInRequest: () => options.maxCalls ?? 16,
|
||||
};
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await waitForDeviceSyncHydration();
|
||||
useDeviceSyncStore.setState({ accounts: {} });
|
||||
forgetSyncCollections();
|
||||
});
|
||||
|
||||
describe('listSyncCollections', () => {
|
||||
it('lists the personal address books first, then the shared ones, and marks read-only ones', async () => {
|
||||
const { client, requests } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [
|
||||
{ id: 'b2', name: 'Work', sortOrder: 2, myRights: { mayWrite: true } },
|
||||
{ id: 'b1', name: 'Personal', isDefault: true, sortOrder: 1, myRights: { mayWrite: true } },
|
||||
] }),
|
||||
'AddressBook/get team': () => ({ list: [{ id: 'b1', name: 'Team contacts', myRights: { mayWrite: false } }] }),
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(list.map((c) => [c.key, c.name, c.isPersonal, c.readOnly, c.isDefault])).toEqual([
|
||||
['c/b1', 'Personal', true, false, true],
|
||||
['c/b2', 'Work', true, false, false],
|
||||
['team/b1', 'Team contacts', false, true, false],
|
||||
]);
|
||||
expect(list[2].accountName).toBe('Team');
|
||||
// Explicit properties, the contacts capability, and never the mail-only account.
|
||||
const [calls] = requests;
|
||||
expect(calls.map(([name, args]) => `${name} ${args.accountId}`)).toEqual(['AddressBook/get c', 'AddressBook/get team']);
|
||||
expect(calls[0][1].properties).toEqual(expect.arrayContaining(['id', 'name', 'myRights']));
|
||||
});
|
||||
|
||||
it('tells every view of the account when a fresh list arrived', async () => {
|
||||
const { client } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
|
||||
'AddressBook/get team': () => ({ list: [] }),
|
||||
});
|
||||
const seen: string[][] = [];
|
||||
const stop = onSyncCollectionsLoaded((id, authority, list) => seen.push([id, authority, ...list.map((c) => c.key)]));
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
stop();
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client, force: true });
|
||||
expect(seen).toEqual([['alice@x', CONTACTS_AUTHORITY, 'c/b1']]);
|
||||
});
|
||||
|
||||
it('remembers the personal JMAP account for the push routes', async () => {
|
||||
const { client } = fakeClient({ 'AddressBook/get': () => ({ list: [] }) });
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(useDeviceSyncStore.getState().accounts['alice@x']?.primaryJmapAccounts).toEqual({ [CONTACTS_AUTHORITY]: 'c' });
|
||||
});
|
||||
|
||||
it('keeps the list when a shared account refuses', async () => {
|
||||
const { client } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
|
||||
'AddressBook/get team': () => null,
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(list.map((c) => c.key)).toEqual(['c/b1']);
|
||||
});
|
||||
|
||||
it('leaves out calendars it may not read and calendars holding only tasks', async () => {
|
||||
// What each calendar's scan finds: `todo` holds only tasks, `empty` nothing.
|
||||
const scans: Record<string, any[]> = {
|
||||
cal1: [{ id: 'e', '@type': 'Event' }],
|
||||
todo: [{ id: 't', '@type': 'Task' }, { id: 'u', due: '2026-10-01T00:00:00' }],
|
||||
empty: [],
|
||||
};
|
||||
const { client } = fakeClient({
|
||||
'Calendar/get c': () => ({ list: [
|
||||
{ id: 'cal1', name: 'Personal', color: '#ff0000', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'todo', name: 'Tasks', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'empty', name: 'New', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'hidden', name: 'Free/busy only', myRights: { mayReadItems: false } },
|
||||
] }),
|
||||
'Calendar/get team': () => ({ list: [
|
||||
{ id: 'cal1', name: 'Team', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false } },
|
||||
] }),
|
||||
'CalendarEvent/query': (args) => ({ ids: (scans[args.filter.inCalendar] ?? []).map((o) => o.id) }),
|
||||
'CalendarEvent/get': (args, calls) => {
|
||||
// The get reads the ids of the query it references.
|
||||
const query = calls.find(([, , id]) => id === args['#ids'].resultOf)!;
|
||||
return { list: scans[(query[1].filter as { inCalendar: string }).inCalendar] ?? [] };
|
||||
},
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
|
||||
// By name within an account (no sort order given).
|
||||
expect(list.map((c) => [c.key, c.readOnly])).toEqual([
|
||||
['c/empty', false],
|
||||
['c/cal1', false],
|
||||
['team/cal1', true],
|
||||
]);
|
||||
expect(list.find((c) => c.key === 'c/cal1')?.color).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('marks calendars mirroring a subscribed feed read-only', async () => {
|
||||
const { client } = fakeClient({
|
||||
'Calendar/get c': () => ({ list: [
|
||||
{ id: 'feed', name: 'Holidays', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
] }),
|
||||
'Calendar/get team': () => ({ list: [] }),
|
||||
'CalendarEvent/query': () => ({ ids: [] }),
|
||||
'CalendarEvent/get': () => ({ list: [] }),
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client, feeds: [{ calendarId: 'feed' }] });
|
||||
expect(list.map((c) => [c.key, c.readOnly])).toEqual([['c/feed', true]]);
|
||||
});
|
||||
|
||||
it('keeps a query and the get that reads it in one request', async () => {
|
||||
const { client, requests } = fakeClient({
|
||||
'Calendar/get': () => ({ list: [
|
||||
{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' },
|
||||
] }),
|
||||
'CalendarEvent/query': () => ({ ids: [] }),
|
||||
'CalendarEvent/get': () => ({ list: [] }),
|
||||
}, { maxCalls: 5 });
|
||||
await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
|
||||
const scanRequests = requests.filter((calls) => calls[0][0] === 'CalendarEvent/query');
|
||||
for (const calls of scanRequests) {
|
||||
expect(calls.length % 2).toBe(0);
|
||||
expect(calls.length).toBeLessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
|
||||
it('says why the list could not be loaded', async () => {
|
||||
const signedOut = fakeClient({}, { loaded: false }).client;
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: signedOut }))
|
||||
.rejects.toMatchObject({ kind: 'signedOut' });
|
||||
const auth = Object.assign(new Error('Invalid credentials'), { name: 'AuthenticationError' });
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
|
||||
.rejects.toBeInstanceOf(CollectionsError);
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
|
||||
.rejects.toMatchObject({ kind: 'auth' });
|
||||
const offline = Object.assign(new Error('Server unreachable'), { name: 'NetworkError' });
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: offline }).client }))
|
||||
.rejects.toMatchObject({ kind: 'network' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('otherAccountsSyncing', () => {
|
||||
const registry = [
|
||||
{ id: 'alice@mail.example.org', serverUrl: 'https://mail.example.org', email: 'alice@example.org', username: 'alice' },
|
||||
{ id: 'bob@mail.example.org', serverUrl: 'https://mail.example.org/', email: 'bob@example.org', username: 'bob' },
|
||||
{ id: 'bob@other.example.net', serverUrl: 'https://other.example.net', email: 'bob@example.net', username: 'bob' },
|
||||
];
|
||||
|
||||
it('names the other accounts on the same server that sync the same collection', () => {
|
||||
const accounts = {
|
||||
'bob@mail.example.org': {
|
||||
contactsSelection: { 'team/b1': true },
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
},
|
||||
// Another server: the same JMAP ids mean other data.
|
||||
'bob@other.example.net': {
|
||||
contactsSelection: { 'team/b1': true },
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
},
|
||||
};
|
||||
const collection = { key: 'team/b1', jmapAccountId: 'team' };
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts))
|
||||
.toEqual(['bob@example.org']);
|
||||
// Not for another authority, nor once Bob's contacts sync is off.
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CALENDAR_AUTHORITY, collection, registry, accounts)).toEqual([]);
|
||||
accounts['bob@mail.example.org'].enabled = { [CONTACTS_AUTHORITY]: false };
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts)).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts the other account's own collections, which sync by default", () => {
|
||||
const accounts = {
|
||||
'bob@mail.example.org': {
|
||||
contactsSelection: {},
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
primaryJmapAccounts: { [CALENDAR_AUTHORITY]: 'bobacct' },
|
||||
},
|
||||
};
|
||||
expect(otherAccountsSyncing(
|
||||
'alice@mail.example.org',
|
||||
CALENDAR_AUTHORITY,
|
||||
{ key: 'bobacct/cal1', jmapAccountId: 'bobacct' },
|
||||
registry,
|
||||
accounts,
|
||||
)).toEqual(['bob@example.org']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type Authority,
|
||||
} from '../../types';
|
||||
|
||||
// A fake of the native module: Android's accounts of our type and their sync
|
||||
// settings, with every call logged in order.
|
||||
const h = vi.hoisted(() => {
|
||||
type Settings = { automatic: boolean; periodicSeconds: number };
|
||||
const state = {
|
||||
available: true,
|
||||
permitted: true,
|
||||
master: true,
|
||||
accounts: [] as Array<{ name: string; registryId: string | null; sync: Record<string, Settings> }>,
|
||||
log: [] as string[],
|
||||
failSetSyncEnabled: false,
|
||||
};
|
||||
const find = (name: string) => state.accounts.find((a) => a.name === name);
|
||||
const settingsOf = (name: string, authority: string) => {
|
||||
const account = find(name);
|
||||
if (!account) return { automatic: false, periodicSeconds: 0 };
|
||||
account.sync[authority] ??= { automatic: false, periodicSeconds: 0 };
|
||||
return account.sync[authority];
|
||||
};
|
||||
const teardown = async (
|
||||
_registryId: string,
|
||||
name: string,
|
||||
authority: string,
|
||||
options?: { uploadFirst?: boolean; timeoutMs?: number; force?: boolean },
|
||||
) => {
|
||||
state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
return { pending: 0 };
|
||||
};
|
||||
return { state, find, settingsOf, teardown };
|
||||
});
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.state.available,
|
||||
listAndroidAccounts: vi.fn(async () => h.state.accounts.map((a) => ({ name: a.name, registryId: a.registryId }))),
|
||||
ensureAndroidAccount: vi.fn(async (name: string, registryId: string) => {
|
||||
h.state.log.push(`ensure ${name}`);
|
||||
const existing = h.find(name);
|
||||
// As the native module rejects (DeviceSyncAccounts.ensure → AccountConflictException).
|
||||
if (existing && existing.registryId !== registryId) throw Object.assign(new Error(`${name} belongs to another app account`), { code: 'conflict' });
|
||||
if (existing) return false;
|
||||
h.state.accounts.push({ name, registryId, sync: {} });
|
||||
return true;
|
||||
}),
|
||||
removeAndroidAccount: vi.fn(async (name: string) => {
|
||||
h.state.log.push(`remove ${name}`);
|
||||
h.state.accounts = h.state.accounts.filter((a) => a.name !== name);
|
||||
return true;
|
||||
}),
|
||||
getSyncSettings: vi.fn(async (name: string) => ({
|
||||
masterAutomatic: h.state.master,
|
||||
authorities: {
|
||||
'com.android.contacts': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.contacts') },
|
||||
'com.android.calendar': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.calendar') },
|
||||
},
|
||||
})),
|
||||
setSyncEnabled: vi.fn(async (name: string, authority: string, enabled: boolean) => {
|
||||
if (h.state.failSetSyncEnabled) throw new Error('boom');
|
||||
h.state.log.push(`auto ${name} ${authority} ${enabled}`);
|
||||
h.settingsOf(name, authority).automatic = enabled;
|
||||
}),
|
||||
setPeriodicSync: vi.fn(async (name: string, authority: string, seconds: number) => {
|
||||
h.state.log.push(`periodic ${name} ${authority} ${seconds}`);
|
||||
h.settingsOf(name, authority).periodicSeconds = seconds;
|
||||
}),
|
||||
requestSync: vi.fn(async (name: string, authority: string, options?: Record<string, unknown>) => {
|
||||
h.state.log.push(`sync ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
}),
|
||||
openAccountSettings: vi.fn(async () => undefined),
|
||||
setPushRoutes: vi.fn(async () => undefined),
|
||||
showSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`problem ${name}`); }),
|
||||
clearSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`clear ${name}`); }),
|
||||
onAccountsChanged: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../task', () => ({ teardownAuthority: vi.fn(h.teardown) }));
|
||||
|
||||
vi.mock('../../app/permissions', () => ({
|
||||
hasSyncPermissions: vi.fn(async () => h.state.permitted),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/push-notifications', () => ({
|
||||
refreshPushSubscriptionTypes: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as native from '../../native';
|
||||
import * as task from '../../task';
|
||||
import { refreshPushSubscriptionTypes } from '../../../lib/push-notifications';
|
||||
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
|
||||
import {
|
||||
accountDeviceSync,
|
||||
useDeviceSyncStore,
|
||||
waitForDeviceSyncHydration,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import {
|
||||
changeReminderOwner,
|
||||
changeSyncInterval,
|
||||
deviceSyncSignedIn,
|
||||
disableDeviceSync,
|
||||
enableDeviceSync,
|
||||
preferredAndroidAccountName,
|
||||
reconcileDeviceAccounts,
|
||||
releaseDeviceSyncBeforeSignOut,
|
||||
removeSuspendedAccount,
|
||||
resolveDeletions,
|
||||
resumeDeviceSync,
|
||||
TEARDOWN_TIMEOUT_MS,
|
||||
} from '../../app/lifecycle';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
const ALICE2 = 'alice@other.example.net';
|
||||
const BOB = 'bob@mail.example.org';
|
||||
|
||||
function entry(id: string, username: string): AccountEntry {
|
||||
return {
|
||||
id,
|
||||
serverUrl: `https://${id.split('@').pop()}`,
|
||||
username,
|
||||
displayName: username,
|
||||
email: `${username}@example.org`,
|
||||
avatarColor: '#000',
|
||||
lastLoginAt: 0,
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function ready(registry: AccountEntry[]): Promise<void> {
|
||||
await waitForDeviceSyncHydration();
|
||||
if (!useAccountStore.persist.hasHydrated()) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const off = useAccountStore.persist.onFinishHydration(() => { off(); resolve(); });
|
||||
});
|
||||
}
|
||||
useAccountStore.setState({ accounts: registry, activeAccountId: registry[0]?.id ?? null });
|
||||
}
|
||||
|
||||
const teardown = vi.mocked(task.teardownAuthority);
|
||||
const setSyncEnabled = vi.mocked(native.setSyncEnabled);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
h.state.available = true;
|
||||
h.state.permitted = true;
|
||||
h.state.master = true;
|
||||
h.state.accounts = [];
|
||||
h.state.log = [];
|
||||
h.state.failSetSyncEnabled = false;
|
||||
teardown.mockImplementation(h.teardown);
|
||||
await AsyncStorage.clear();
|
||||
useDeviceSyncStore.setState({ accounts: {} });
|
||||
await ready([entry(ALICE, 'alice'), entry(BOB, 'bob')]);
|
||||
});
|
||||
|
||||
describe('enable', () => {
|
||||
it('creates the Android account under the login, turns automatic and periodic sync on and syncs', async () => {
|
||||
useDeviceSyncStore.getState().setIntervalSeconds(ALICE, CONTACTS_AUTHORITY, 1800);
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: 'alice' });
|
||||
expect(h.state.log).toEqual([
|
||||
'ensure alice',
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`periodic alice ${CONTACTS_AUTHORITY} 1800`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
const stored = accountDeviceSync(ALICE);
|
||||
expect(stored.androidAccountName).toBe('alice');
|
||||
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
|
||||
await vi.waitFor(() => expect(refreshPushSubscriptionTypes).toHaveBeenCalledWith(ALICE));
|
||||
});
|
||||
|
||||
it('uses the default hourly interval', async () => {
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(h.state.log).toContain(`periodic alice ${CALENDAR_AUTHORITY} 3600`);
|
||||
});
|
||||
|
||||
it('does nothing without the runtime permissions', async () => {
|
||||
h.state.permitted = false;
|
||||
expect(await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ kind: 'permission' });
|
||||
expect(h.state.log).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reuses the Android account of the app account', async () => {
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(h.state.log.filter((l) => l.startsWith('ensure'))).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('names the account after its registry id when another app account has the login', async () => {
|
||||
await ready([entry(ALICE, 'alice'), entry(ALICE2, 'alice')]);
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
|
||||
expect(preferredAndroidAccountName(ALICE2, await native.listAndroidAccounts())).toBe(ALICE2);
|
||||
const outcome = await enableDeviceSync(ALICE2, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE2 });
|
||||
});
|
||||
|
||||
it('falls back to the registry id when the login turns out to be taken', async () => {
|
||||
const list = vi.mocked(native.listAndroidAccounts);
|
||||
// A stale list: the clash only shows when the account is added.
|
||||
list.mockResolvedValueOnce([]);
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE2, sync: {} });
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE });
|
||||
});
|
||||
|
||||
it('adds no second account named after the registry id when adding the account fails for another reason', async () => {
|
||||
for (const code of ['failed', 'permission']) {
|
||||
vi.mocked(native.ensureAndroidAccount).mockClear();
|
||||
vi.mocked(native.ensureAndroidAccount).mockRejectedValueOnce(Object.assign(new Error(`AccountManager said no (${code})`), { code }));
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'failed', message: `AccountManager said no (${code})` });
|
||||
expect(native.ensureAndroidAccount).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no account behind when turning sync on fails', async () => {
|
||||
h.state.failSetSyncEnabled = true;
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome.kind).toBe('failed');
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the account of the other authority when turning one on fails', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
vi.mocked(native.setPeriodicSync).mockRejectedValueOnce(new Error('boom'));
|
||||
expect((await enableDeviceSync(ALICE, CALENDAR_AUTHORITY)).kind).toBe('failed');
|
||||
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(false);
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE)).toMatchObject({
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op where device sync is not available', async () => {
|
||||
h.state.available = false;
|
||||
expect((await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).kind).toBe('failed');
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
beforeEach(async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
await changeReminderOwner(ALICE, 'bulwark');
|
||||
h.state.log = [];
|
||||
});
|
||||
|
||||
it('turns automatic sync off before the teardown uploads and deletes', async () => {
|
||||
const result = await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(result).toEqual({ done: true, pending: 0 });
|
||||
expect(h.state.log).toEqual([
|
||||
`auto alice ${CALENDAR_AUTHORITY} false`,
|
||||
`teardown alice ${CALENDAR_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`,
|
||||
]);
|
||||
const stored = accountDeviceSync(ALICE);
|
||||
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: false });
|
||||
// Contacts still sync: the account stays; the reminder question comes again next time.
|
||||
expect(h.find('alice')).toBeDefined();
|
||||
expect(stored.reminderOwner).toBeNull();
|
||||
});
|
||||
|
||||
it('lets a turn-on wait for a turn-off that is still tearing down, instead of being undone by it', async () => {
|
||||
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
let finishTeardown: () => void = () => undefined;
|
||||
teardown.mockImplementationOnce(async (_registryId, name, authority, options) => {
|
||||
h.state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
await new Promise<void>((resolve) => { finishTeardown = resolve; });
|
||||
return { pending: 0 };
|
||||
});
|
||||
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.waitFor(() => expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`));
|
||||
// The user opens the settings again and turns contacts sync back on.
|
||||
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
finishTeardown();
|
||||
expect(await turningOff).toEqual({ done: true, pending: 0 });
|
||||
expect(await turningOn).toMatchObject({ kind: 'enabled' });
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY]?.automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('removes the Android account once both authorities are off', async () => {
|
||||
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(h.state.log).toContain('remove alice');
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes nothing and reports the waiting changes when they could not be uploaded', async () => {
|
||||
teardown.mockResolvedValueOnce({ pending: 3 });
|
||||
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(result).toEqual({ done: false, pending: 3 });
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
|
||||
|
||||
// The user keeps syncing: automatic sync comes back.
|
||||
h.state.log = [];
|
||||
await resumeDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(h.state.log).toEqual([
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes on the user\'s word with force', async () => {
|
||||
teardown.mockResolvedValueOnce({ pending: 2 });
|
||||
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY, { force: true });
|
||||
expect(result.done).toBe(true);
|
||||
expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"force":true,"timeoutMs":5000}`);
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a failed teardown as unknown changes', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
teardown.mockRejectedValueOnce(new Error('provider gone'));
|
||||
expect(await disableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ done: false, pending: 0, failed: true });
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-out', () => {
|
||||
it('lets an account without device sync go at once', async () => {
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([BOB], confirm)).toBe(true);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads, deletes and removes the Android account before the credentials go', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CALENDAR_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
// Automatic sync went off before the first teardown.
|
||||
expect(h.state.log.indexOf(`auto alice ${CONTACTS_AUTHORITY} false`))
|
||||
.toBeLessThan(h.state.log.findIndex((l) => l.startsWith('teardown')));
|
||||
});
|
||||
|
||||
it('asks before losing changes, and keeps syncing when the user stays', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 4 : 0 }));
|
||||
const confirm = vi.fn(async () => false);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledWith(4);
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('deletes what is left once the user agrees', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string, options?: { force?: boolean }) =>
|
||||
({ pending: authority === CONTACTS_AUTHORITY && !options?.force ? 4 : 0 }));
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { force: true, timeoutMs: 5_000 });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks once for several accounts', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await enableDeviceSync(BOB, CALENDAR_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, _a: string, options?: { force?: boolean }) => ({ pending: options?.force ? 0 : 1 }));
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE, BOB], confirm)).toBe(true);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).toHaveBeenCalledWith(4);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('waits for a turn-on in progress, then releases what it set up', async () => {
|
||||
let finishEnsure: () => void = () => undefined;
|
||||
vi.mocked(native.ensureAndroidAccount).mockImplementationOnce(async (name: string, registryId: string) => {
|
||||
h.state.log.push(`ensure ${name}`);
|
||||
await new Promise<void>((resolve) => { finishEnsure = resolve; });
|
||||
h.state.accounts.push({ name, registryId, sync: {} });
|
||||
return true;
|
||||
});
|
||||
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.waitFor(() => expect(h.state.log).toContain('ensure alice'));
|
||||
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
|
||||
finishEnsure();
|
||||
|
||||
expect(await signingOut).toBe(true);
|
||||
await turningOn;
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a turn-on or turn-off asked for during the sign-out set nothing up for the account', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
let finishTeardown: () => void = () => undefined;
|
||||
teardown.mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => { finishTeardown = resolve; });
|
||||
return { pending: 0 };
|
||||
});
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
|
||||
await vi.waitFor(() => expect(teardown).toHaveBeenCalled());
|
||||
|
||||
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
finishTeardown();
|
||||
|
||||
expect(await signingOut).toBe(true);
|
||||
expect((await turningOn).kind).toBe('failed');
|
||||
expect(await turningOff).toEqual({ done: true, pending: 0 });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(h.state.log.filter((l) => l.startsWith('ensure') || l.startsWith('sync'))).toEqual(['ensure alice', `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a turn-on asked for during the sign-out go ahead once the user chose to stay signed in', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 1 : 0 }));
|
||||
let answer: (signOut: boolean) => void = () => undefined;
|
||||
const confirm = vi.fn(() => new Promise<boolean>((resolve) => { answer = resolve; }));
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], confirm);
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalled());
|
||||
|
||||
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
answer(false);
|
||||
|
||||
expect(await signingOut).toBe(false);
|
||||
expect(await turningOn).toEqual({ kind: 'enabled', accountName: 'alice' });
|
||||
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile', () => {
|
||||
it('suspends an Android account whose app account the registry dropped, once', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'carol',
|
||||
registryId: 'carol@mail.example.org',
|
||||
sync: { [CONTACTS_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
|
||||
});
|
||||
await reconcileDeviceAccounts();
|
||||
expect(h.find('carol')?.sync[CONTACTS_AUTHORITY].automatic).toBe(false);
|
||||
expect(h.find('carol')).toBeDefined();
|
||||
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(native.showSyncProblem).mock.calls[0][3]).toBe('bulwarkmobile://settings/account');
|
||||
const carol = accountDeviceSync('carol@mail.example.org');
|
||||
expect(carol.suspended).toBe(true);
|
||||
expect(carol.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
|
||||
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops a suspended account and its data when the user removes it', async () => {
|
||||
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
|
||||
await reconcileDeviceAccounts();
|
||||
h.state.log = [];
|
||||
await removeSuspendedAccount('carol@mail.example.org');
|
||||
expect(h.state.log).toEqual(['remove carol', 'clear carol']);
|
||||
expect(useDeviceSyncStore.getState().accounts['carol@mail.example.org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never acts on an empty registry', async () => {
|
||||
await ready([]);
|
||||
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.showSyncProblem).not.toHaveBeenCalled();
|
||||
expect(setSyncEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows sync off for an account removed in Android Settings and never recreates it', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.accounts = [];
|
||||
vi.mocked(native.ensureAndroidAccount).mockClear();
|
||||
await reconcileDeviceAccounts();
|
||||
const alice = accountDeviceSync(ALICE);
|
||||
expect(alice.removedInAndroidSettings).toBe(true);
|
||||
expect(alice.androidAccountName).toBeUndefined();
|
||||
expect(alice.enabled).toEqual({});
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.ensureAndroidAccount).not.toHaveBeenCalled();
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets an account that is neither signed in nor on the device', async () => {
|
||||
useDeviceSyncStore.getState().update('gone@mail.example.org', { androidAccountName: 'gone', enabled: { [CONTACTS_AUTHORITY]: true } });
|
||||
await reconcileDeviceAccounts();
|
||||
expect(useDeviceSyncStore.getState().accounts['gone@mail.example.org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes an authority turned on in Android Settings as on', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'alice',
|
||||
registryId: ALICE,
|
||||
sync: { [CALENDAR_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
|
||||
});
|
||||
await reconcileDeviceAccounts();
|
||||
const alice = accountDeviceSync(ALICE);
|
||||
expect(alice.androidAccountName).toBe('alice');
|
||||
expect(alice.enabled).toEqual({ [CALENDAR_AUTHORITY]: true });
|
||||
});
|
||||
|
||||
it('hands the push router the accounts that feed each Android account', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.setPushRoutes).toHaveBeenLastCalledWith({
|
||||
c: [{ accountName: 'alice', authorities: [CONTACTS_AUTHORITY] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-in', () => {
|
||||
it('clears the sign-in notice and resumes a suspended account where it synced', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'alice',
|
||||
registryId: ALICE,
|
||||
sync: { [CONTACTS_AUTHORITY]: { automatic: false, periodicSeconds: 3600 } },
|
||||
});
|
||||
useDeviceSyncStore.getState().update(ALICE, {
|
||||
androidAccountName: 'alice',
|
||||
suspended: true,
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
});
|
||||
await deviceSyncSignedIn(ALICE);
|
||||
expect(h.state.log).toEqual([
|
||||
'clear alice',
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
expect(accountDeviceSync(ALICE).suspended).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing for an account without an Android account', async () => {
|
||||
await deviceSyncSignedIn(BOB);
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings actions', () => {
|
||||
it('deletes held-back deletions on the server, or brings the items back, with a sync', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'delete')).toBe(true);
|
||||
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'restore')).toBe(true);
|
||||
expect(h.state.log).toEqual([
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"overrideTooManyDeletions":true}`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"discardLocalDeletions":true}`,
|
||||
]);
|
||||
// Without an Android account there is nothing to resolve.
|
||||
expect(await resolveDeletions(BOB, CONTACTS_AUTHORITY, 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a new interval to Android while the authority syncs', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
await changeSyncInterval(ALICE, CONTACTS_AUTHORITY, 0);
|
||||
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
|
||||
await changeSyncInterval(ALICE, CALENDAR_AUTHORITY, 900);
|
||||
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
|
||||
expect(accountDeviceSync(ALICE).intervalSeconds[CALENDAR_AUTHORITY as Authority]).toBe(900);
|
||||
});
|
||||
|
||||
it('syncs the calendar when the reminder owner changes', async () => {
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
h.state.log = [];
|
||||
await changeReminderOwner(ALICE, 'device');
|
||||
expect(h.state.log).toEqual([`sync alice ${CALENDAR_AUTHORITY} {"manual":true}`]);
|
||||
h.state.log = [];
|
||||
await changeReminderOwner(ALICE, 'device');
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Android's side: the device's auto-sync, the account's calendar sync and the calendar permission.
|
||||
const h = vi.hoisted(() => ({ available: true, master: true, automatic: true, permitted: true }));
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.available,
|
||||
getSyncSettings: vi.fn(async () => ({
|
||||
masterAutomatic: h.master,
|
||||
authorities: {
|
||||
'com.android.contacts': { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: false },
|
||||
'com.android.calendar': { syncable: 1, automatic: h.automatic, periodicSeconds: 3600, active: false, pending: false },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../app/permissions', () => ({
|
||||
hasSyncPermissions: vi.fn(async () => h.permitted),
|
||||
}));
|
||||
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
|
||||
import type { CalendarEvent } from '../../../api/types';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import { onDeviceRemindersChange, remindedOnDevice } from '../../app/reminders';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
|
||||
function event(id: string, calendarIds: Record<string, boolean>): CalendarEvent {
|
||||
return { id, calendarIds, title: id, start: '2026-09-24T12:00:00', duration: 'PT1H' } as CalendarEvent;
|
||||
}
|
||||
|
||||
function calendarSync(extra: Record<string, unknown> = {}): void {
|
||||
useDeviceSyncStore.setState({
|
||||
accounts: {
|
||||
[ALICE]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: { 'c/work': false, 'team/shared-on': true },
|
||||
reminderOwner: 'device',
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Waits until the calendar app reminds of Alice's synced events (Android's side is read when the filter is asked for). */
|
||||
async function remindedByCalendarApp(): Promise<void> {
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).not.toBeNull());
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await waitForDeviceSyncHydration();
|
||||
Object.assign(h, { available: true, master: true, automatic: true, permitted: true });
|
||||
calendarSync();
|
||||
await remindedByCalendarApp();
|
||||
});
|
||||
|
||||
describe('remindedOnDevice', () => {
|
||||
it("picks the events of the account's synced calendars", () => {
|
||||
const onDevice = remindedOnDevice(ALICE, 'c')!;
|
||||
// Own calendars sync unless turned off.
|
||||
expect(onDevice(event('a', { personal: true }))).toBe(true);
|
||||
expect(onDevice(event('b', { work: true }))).toBe(false);
|
||||
expect(onDevice(event('c', { work: true, personal: true }))).toBe(true);
|
||||
// Shared calendars: namespaced ids in calendarIds, raw ones in originalCalendarIds.
|
||||
expect(onDevice({
|
||||
...event('d', { 'team:shared-on': true }),
|
||||
accountId: 'team',
|
||||
originalCalendarIds: { 'shared-on': true },
|
||||
})).toBe(true);
|
||||
expect(onDevice({
|
||||
...event('e', { 'team:other': true }),
|
||||
accountId: 'team',
|
||||
originalCalendarIds: { other: true },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves everything to Bulwark when Bulwark reminds, or calendars do not sync', () => {
|
||||
calendarSync({ reminderOwner: 'bulwark' });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
calendarSync({ enabled: { [CONTACTS_AUTHORITY]: true } });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
calendarSync({ removedInAndroidSettings: true });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
expect(remindedOnDevice('someone@else', 'c')).toBeNull();
|
||||
expect(remindedOnDevice(null, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the calendar app as the owner until the user chose', () => {
|
||||
calendarSync({ reminderOwner: null });
|
||||
expect(remindedOnDevice(ALICE, 'c')?.(event('a', { personal: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing where device sync is not available', () => {
|
||||
h.available = false;
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remindedOnDevice while the device calendar can't receive events", () => {
|
||||
it('leaves the reminders to Bulwark while calendar sync is paused in Android settings', async () => {
|
||||
h.automatic = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it("leaves them to Bulwark while Android's auto-sync is off: Android then runs manual syncs only", async () => {
|
||||
h.master = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it('leaves them to Bulwark while the calendar permission is revoked', async () => {
|
||||
h.permitted = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it('hands them back to the calendar app once it syncs again, and tells the scheduler both times', async () => {
|
||||
const listener = vi.fn();
|
||||
const off = onDeviceRemindersChange(listener);
|
||||
h.automatic = false;
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(1));
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
|
||||
h.automatic = true;
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(2));
|
||||
expect(remindedOnDevice(ALICE, 'c')).not.toBeNull();
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDeviceRemindersChange', () => {
|
||||
it('fires for the owner and the calendar selection, not for sync bookkeeping', () => {
|
||||
const listener = vi.fn();
|
||||
const off = onDeviceRemindersChange(listener);
|
||||
const store = useDeviceSyncStore.getState();
|
||||
|
||||
store.recordKnownState(ALICE, 'c', 'CalendarEvent', 's1');
|
||||
store.setIntervalSeconds(ALICE, CALENDAR_AUTHORITY, 900);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
store.setReminderOwner(ALICE, 'bulwark');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
store.setReminderOwner(ALICE, 'device');
|
||||
store.setCollectionSelected(ALICE, CALENDAR_AUTHORITY, 'c/work', true);
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
off();
|
||||
store.setEnabled(ALICE, CALENDAR_AUTHORITY, false);
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
available: true,
|
||||
appState: [] as Array<(state: string) => void>,
|
||||
accountsChanged: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'android', Version: 34, select: <T,>(s: { android?: T; default?: T }) => s.android ?? s.default },
|
||||
NativeModules: {},
|
||||
NativeEventEmitter: class {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
},
|
||||
AppState: {
|
||||
currentState: 'active',
|
||||
addEventListener: (_type: string, listener: (state: string) => void) => {
|
||||
h.appState.push(listener);
|
||||
return { remove: () => { h.appState = h.appState.filter((l) => l !== listener); } };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.available,
|
||||
requestSync: vi.fn(async () => undefined),
|
||||
onAccountsChanged: vi.fn((listener: () => void) => {
|
||||
h.accountsChanged.push(listener);
|
||||
return () => { h.accountsChanged = h.accountsChanged.filter((l) => l !== listener); };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../app/lifecycle', () => ({
|
||||
reconcileDeviceAccounts: vi.fn(async () => undefined),
|
||||
refreshPushRoutes: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as native from '../../native';
|
||||
import * as lifecycle from '../../app/lifecycle';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
|
||||
import { dispatchStateChange } from '../../../lib/state-change-bus';
|
||||
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import { cancelDeviceSyncRequests, requestDeviceSync, TRIGGER_DELAY_MS } from '../../app/request';
|
||||
import {
|
||||
FOREGROUND_MIN_INTERVAL_MS,
|
||||
handleForeground,
|
||||
handleStateChange,
|
||||
startDeviceSyncTriggers,
|
||||
} from '../../app/triggers';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
const BOB = 'bob@mail.example.org';
|
||||
const requestSync = vi.mocked(native.requestSync);
|
||||
|
||||
function account(id: string): AccountEntry {
|
||||
return {
|
||||
id,
|
||||
serverUrl: 'https://mail.example.org',
|
||||
username: id.split('@')[0],
|
||||
displayName: id,
|
||||
email: id,
|
||||
avatarColor: '#000',
|
||||
lastLoginAt: 0,
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Alice syncs contacts and calendars; Bob only calendars.
|
||||
function syncing(): void {
|
||||
useDeviceSyncStore.setState({
|
||||
accounts: {
|
||||
[ALICE]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: { 'team/cal': true },
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true },
|
||||
},
|
||||
[BOB]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'bob',
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
await waitForDeviceSyncHydration();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
h.available = true;
|
||||
cancelDeviceSyncRequests();
|
||||
useAccountStore.setState({ accounts: [account(ALICE), account(BOB)], activeAccountId: ALICE });
|
||||
syncing();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
cancelDeviceSyncRequests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('requestDeviceSync', () => {
|
||||
it('makes one sync of the edits and echoes of 5 seconds', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE, { jmapAccountId: 'c', type: 'ContactCard', state: 's2' });
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS - 2000);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
|
||||
|
||||
// The next change arms a new request.
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps accounts and authorities apart', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
|
||||
`alice ${CALENDAR_AUTHORITY}`,
|
||||
`alice ${CONTACTS_AUTHORITY}`,
|
||||
`bob ${CALENDAR_AUTHORITY}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks nothing for an authority the account does not sync, or without device sync', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, BOB);
|
||||
h.available = false;
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a request when sync was turned off meanwhile', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
useDeviceSyncStore.getState().setEnabled(ALICE, CONTACTS_AUTHORITY, false);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can be cancelled', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
|
||||
cancelDeviceSyncRequests(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StateChanges of the live stream', () => {
|
||||
it('syncs the active account on a contact or calendar change', async () => {
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'c', 'e7');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
|
||||
});
|
||||
|
||||
it('skips the echo of what the engine synced itself', async () => {
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's9');
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's9');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an echo that arrived before the engine recorded its state', async () => {
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's10');
|
||||
// The engine's upload returns and records the state it moved the account to.
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's10');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still syncs when an edit in the app joined the echo', async () => {
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's11');
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's11');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores shared accounts nothing is synced from, once the personal account is known', async () => {
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x1');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
// Unknown yet: anything may feed the device.
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CALENDAR_AUTHORITY, 'c');
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x2');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The shared account a calendar was picked in does feed it.
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'Calendar', 'team', 'x3');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing for an authority the active account does not sync', async () => {
|
||||
useAccountStore.setState({ activeAccountId: BOB });
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'AddressBook', 'b', 'x');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreground', () => {
|
||||
it('reconciles the accounts, then syncs everything that syncs, at most once a minute', async () => {
|
||||
await handleForeground(1_000_000);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
|
||||
`alice ${CALENDAR_AUTHORITY}`,
|
||||
`alice ${CONTACTS_AUTHORITY}`,
|
||||
`bob ${CALENDAR_AUTHORITY}`,
|
||||
]);
|
||||
|
||||
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS - 1);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(3);
|
||||
|
||||
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDeviceSyncTriggers', () => {
|
||||
it('listens to the stream, the foreground, account changes and the preferences', async () => {
|
||||
stop = startDeviceSyncTriggers();
|
||||
// Launch counts as coming to the foreground.
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
|
||||
requestSync.mockClear();
|
||||
|
||||
dispatchStateChange({ '@type': 'StateChange', changed: { c: { AddressBook: 'a2', Email: 'm1' } } });
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
|
||||
|
||||
for (const listener of h.appState) listener('background');
|
||||
for (const listener of h.appState) listener('active');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(2);
|
||||
|
||||
for (const listener of h.accountsChanged) listener();
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(3);
|
||||
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(lifecycle.refreshPushRoutes).toHaveBeenCalled();
|
||||
|
||||
// Started once only.
|
||||
expect(startDeviceSyncTriggers()).toBe(stop);
|
||||
});
|
||||
|
||||
it('starts nothing without device sync', async () => {
|
||||
h.available = false;
|
||||
startDeviceSyncTriggers()();
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(lifecycle.reconcileDeviceAccounts).not.toHaveBeenCalled();
|
||||
expect(native.onAccountsChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
// Big meetings (many attendees on many overrides): a write into such an event
|
||||
// has to fit one provider transaction (Binder: about 1 MB; a Parcel carries
|
||||
// the JSON as UTF-16, estimated at 2 bytes per character plus ~300 per op).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import type { OpGroup } from '../../planner';
|
||||
import { CALENDAR_AUTHORITY } from '../../types';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { createHarness, rowWrites, type Harness as EngineHarness } from '../engine/harness';
|
||||
import { Harness, ME, serverApply } from './harness';
|
||||
|
||||
const TRANSACTION = 1_000_000;
|
||||
const estimate = (group: OpGroup) => 2 * JSON.stringify(group.ops).length + 300 * group.ops.length;
|
||||
/** A plan's groups: the group and the ones applied after it. */
|
||||
const chain = (group: OpGroup): OpGroup[] => [group, ...(group.next ?? [])];
|
||||
|
||||
/** A daily meeting with `attendees` guests whose `overrides` occurrences each carry the whole participants map. */
|
||||
function meeting(attendees: number, overrides: number, me = ME, calendarId = 'b'): CalendarEventWire {
|
||||
const participants: Record<string, unknown> = {
|
||||
me: { '@type': 'Participant', calendarAddress: `mailto:${me}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
for (let i = 0; i < attendees; i++) {
|
||||
participants[`p${i}`] = { '@type': 'Participant', calendarAddress: `mailto:person${i}@example.com`, name: `Person ${i}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true };
|
||||
}
|
||||
const recurrenceOverrides: Record<string, unknown> = {};
|
||||
for (let i = 0; i < overrides; i++) {
|
||||
const day = new Date(Date.UTC(2026, 9, 6) + i * 86_400_000).toISOString().slice(0, 10);
|
||||
recurrenceOverrides[`${day}T09:00:00`] = { title: `Standup #${i}`, participants };
|
||||
}
|
||||
return {
|
||||
id: 'big',
|
||||
'@type': 'Event',
|
||||
uid: 'big@example.org',
|
||||
calendarIds: { [calendarId]: true },
|
||||
title: 'Standup',
|
||||
description: 'Daily standup',
|
||||
start: '2026-10-05T09:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT15M',
|
||||
recurrenceRule: { frequency: 'daily', count: 300 },
|
||||
organizerCalendarAddress: `mailto:${me}`,
|
||||
participants,
|
||||
recurrenceOverrides,
|
||||
} as unknown as CalendarEventWire;
|
||||
}
|
||||
|
||||
/** The engine with the real planners (the fake provider refuses a batch over one Binder transaction, as Android does). */
|
||||
function engineWithRealPlanners(): EngineHarness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The big meeting's rows on the engine's device: one master, one exception row per override, every attendee once. */
|
||||
function expectWholeMeeting(h: EngineHarness, id: string, attendees: number, overrides: number, context = ''): void {
|
||||
const rows = h.events();
|
||||
const masters = rows.filter((e) => e[Events._SYNC_ID] === `a/${id}`);
|
||||
expect(masters, context).toHaveLength(1);
|
||||
const exceptions = rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`);
|
||||
expect(exceptions, context).toHaveLength(overrides);
|
||||
expect(new Set(exceptions.map((x) => x[Events._SYNC_ID])).size, context).toBe(overrides);
|
||||
expect(exceptions.every((x) => Number(x[Events.ORIGINAL_ID]) === Number(masters[0]._id)), context).toBe(true);
|
||||
const attendeesOf = (eventId: unknown) => h.device.rows('attendees').filter((a) => Number(a.event_id) === Number(eventId)).length;
|
||||
expect([masters[0], ...exceptions].every((row) => attendeesOf(row._id) === attendees + 1), context).toBe(true);
|
||||
expect(Object.keys(JSON.parse(String(masters[0][Events.SYNC_DATA1])).recurrenceOverrides), context).toHaveLength(overrides);
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === null), context).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
describe('calendar planner: big meetings', () => {
|
||||
it('fits the writes after an edit of a big meeting into one transaction', async () => {
|
||||
const event = meeting(25, 52);
|
||||
const { h, id } = await synced(event);
|
||||
// Only the series row is edited; its 52 occurrences with 26 attendees each stay clean.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
|
||||
const local = (await h.local('big'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
|
||||
const server = serverApply(event, plan.actions[0].patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
expect(estimate(accepted.ops)).toBeLessThan(TRANSACTION);
|
||||
expect(estimate(accepted.keepDirtyOps)).toBeLessThan(TRANSACTION);
|
||||
|
||||
// A download into the edited event (the server changed one occurrence meanwhile).
|
||||
const remote = { ...event, recurrenceOverrides: { ...event.recurrenceOverrides, '2026-10-09T09:00:00': { title: 'Moved to Friday' } } } as CalendarEventWire;
|
||||
expect(estimate(calendarPlanner.planDownload(remote, local, h.ctx).ops)).toBeLessThan(TRANSACTION);
|
||||
|
||||
// A save that changed nothing mapped is cleared the same way.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Standup' });
|
||||
const clean = calendarPlanner.planUpload((await h.local('big'))!, h.ctx);
|
||||
expect(clean.kind).toBe('clean');
|
||||
if (clean.kind === 'clean') expect(estimate(clean.ops)).toBeLessThan(TRANSACTION);
|
||||
}, 60_000);
|
||||
|
||||
it('still fails a write when an app changed a clean occurrence\'s attendees meanwhile', async () => {
|
||||
const event = meeting(3, 4);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
|
||||
const local = (await h.local('big'))!;
|
||||
const x = local.exceptions[0];
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
|
||||
const accepted = calendarPlanner.planAccepted(local, serverApply(event, plan.actions[0].patch), h.ctx);
|
||||
// The occurrence's attendees are not asserted one by one: CalendarProvider marks the event DIRTY on any app write to them.
|
||||
h.fake.beforeNextBatch(() => h.fake.user.setAttendeeStatus(x.eventId, 'person1@example.com', 1));
|
||||
expect(await h.tryApply(accepted.ops)).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [x.eventId, 'person1@example.com'])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[25, 52],
|
||||
[100, 12],
|
||||
])('writes the first download of %i attendees on %i overrides in groups that each fit one transaction', async (attendees, overrides) => {
|
||||
const h = await new Harness().setup();
|
||||
const event = meeting(attendees, overrides);
|
||||
|
||||
const plan = calendarPlanner.planDownload(event, null, h.ctx);
|
||||
|
||||
expect(plan.effect).toBe('insert');
|
||||
expect(chain(plan.ops).length).toBeGreaterThan(1);
|
||||
for (const group of chain(plan.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
await h.apply(plan.ops);
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow).toEqual(event);
|
||||
expect(local.exceptions.map((x) => x.recurrenceId).sort()).toEqual(Object.keys(event.recurrenceOverrides!).sort());
|
||||
expect(local.exceptions.every((x) => x.attendees.length === attendees + 1 && !x.dirty)).toBe(true);
|
||||
// Its echo writes nothing.
|
||||
expect(calendarPlanner.planDownload(event, local, h.ctx).effect).toBe('none');
|
||||
}, 60_000);
|
||||
|
||||
it('finishes a first download cut off between its groups, without writing a row twice', async () => {
|
||||
const event = meeting(25, 52);
|
||||
const groups = chain(calendarPlanner.planDownload(event, null, (await new Harness().setup()).ctx).ops).length;
|
||||
for (let cut = 1; cut < groups; cut++) {
|
||||
const h = await new Harness().setup();
|
||||
for (const group of chain(calendarPlanner.planDownload(event, null, h.ctx).ops).slice(0, cut)) await h.apply({ ref: group.ref, ops: group.ops });
|
||||
|
||||
// Planned again from what the first groups wrote.
|
||||
const again = calendarPlanner.planDownload(event, await h.local('big'), h.ctx);
|
||||
for (const group of chain(again.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
await h.apply(again.ops);
|
||||
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow, `cut after ${cut}`).toEqual(event);
|
||||
expect(new Set(local.exceptions.map((x) => x.recurrenceId)).size, `cut after ${cut}`).toBe(52);
|
||||
expect(local.exceptions, `cut after ${cut}`).toHaveLength(52);
|
||||
expect(local.exceptions.every((x) => x.attendees.length === 26), `cut after ${cut}`).toBe(true);
|
||||
expect(calendarPlanner.planDownload(event, local, h.ctx).effect, `cut after ${cut}`).toBe('none');
|
||||
}
|
||||
// Every cut point writes a 2 MB meeting again.
|
||||
}, 60_000);
|
||||
|
||||
it('writes a change of every occurrence in groups, the series with its new shadow last', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = meeting(25, 52);
|
||||
await h.download(event);
|
||||
// Another client adds a guest to the meeting and to every occurrence.
|
||||
const guest = { '@type': 'Participant', calendarAddress: 'mailto:guest@example.com', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action' };
|
||||
const changed = JSON.parse(JSON.stringify(event)) as CalendarEventWire;
|
||||
(changed.participants as Record<string, unknown>).guest = guest;
|
||||
for (const override of Object.values(changed.recurrenceOverrides!)) (override as { participants: Record<string, unknown> }).participants.guest = guest;
|
||||
|
||||
const plan = calendarPlanner.planDownload(changed, await h.local('big'), h.ctx);
|
||||
|
||||
const groups = chain(plan.ops);
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
for (const group of groups) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
const writesShadow = (group: OpGroup) => group.ops.some((op) => op.op === 'update' && Events.SYNC_DATA1 in op.values);
|
||||
expect(groups.map(writesShadow)).toEqual(groups.map((_, i) => i === groups.length - 1));
|
||||
// Cut off after the first group: planned again from what it wrote, the rest follows.
|
||||
await h.apply({ ref: groups[0].ref, ops: groups[0].ops });
|
||||
await h.apply(calendarPlanner.planDownload(changed, await h.local('big'), h.ctx).ops);
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow).toEqual(changed);
|
||||
expect(local.exceptions.every((x) => x.attendees.length === 27)).toBe(true);
|
||||
expect(calendarPlanner.planDownload(changed, local, h.ctx).effect).toBe('none');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('device sync engine: big meetings', () => {
|
||||
it.each([
|
||||
[25, 52],
|
||||
[100, 12],
|
||||
])('downloads a meeting of %i attendees on %i overrides in several transactions', async (attendees, overrides) => {
|
||||
const h = engineWithRealPlanners();
|
||||
const { id: _drop, ...event } = meeting(attendees, overrides, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
|
||||
const id = h.server.addEvent('a', event);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { downloaded: { created: 1 } } });
|
||||
expectWholeMeeting(h, id, attendees, overrides);
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('finishes a big meeting cut off between its groups by a crash, without duplicating rows', async () => {
|
||||
const setup = () => {
|
||||
const h = engineWithRealPlanners();
|
||||
const { id: _drop, ...event } = meeting(25, 52, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
|
||||
return { h, id: h.server.addEvent('a', event) };
|
||||
};
|
||||
const probe = setup();
|
||||
await probe.h.run(CALENDAR_AUTHORITY);
|
||||
const batches = probe.h.batches.applied;
|
||||
|
||||
for (let n = 1; n < batches; n++) {
|
||||
const { h, id } = setup();
|
||||
h.batches.crashAfter = n;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
h.batches.crashAfter = null;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome, `crash after batch ${n}`).toBe('ok');
|
||||
expectWholeMeeting(h, id, 25, 52, `crash after batch ${n}`);
|
||||
}
|
||||
// Two runs of a 2 MB meeting per crash point.
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CalendarAccess, Calendars } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { CAN_PARTIALLY_UPDATE } from '../../calendar/columns';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../../lib/birthday-calendar';
|
||||
import type { CalendarLike } from '../../wire';
|
||||
import { ACCT, CALENDARS, Harness, ME } from './harness';
|
||||
|
||||
const personal = (calendar: CalendarLike, readOnly = false) => ({ jmapAccountId: ACCT, calendar, readOnly, accountName: ME });
|
||||
|
||||
describe('calendar planner: calendar rows', () => {
|
||||
it('inserts every column the provider and the calendar apps need', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const [row] = await h.calendars();
|
||||
expect(row.syncId).toBe(`${ACCT}/b`);
|
||||
expect(row.cells).toMatchObject({
|
||||
[Calendars.NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_COLOR]: cssColorToArgb('#3b82f6'),
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: CalendarAccess.OWNER,
|
||||
[Calendars.OWNER_ACCOUNT]: ME,
|
||||
[Calendars.SYNC_EVENTS]: 1,
|
||||
[Calendars.VISIBLE]: 1,
|
||||
[Calendars.CALENDAR_TIME_ZONE]: 'Europe/Berlin',
|
||||
[Calendars.ALLOWED_REMINDERS]: '1,2',
|
||||
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
|
||||
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
|
||||
[Calendars.MAX_REMINDERS]: 5,
|
||||
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
|
||||
[CAN_PARTIALLY_UPDATE]: 0,
|
||||
});
|
||||
// CAL_SYNC1 would become the `feed` extra of every sync the provider requests for the calendar.
|
||||
expect(h.fake.row('calendars', row.calendarRowId)).toMatchObject({ [Calendars.CAL_SYNC1]: null, [Calendars.DIRTY]: 0 });
|
||||
expect(row.shadow).toEqual(CALENDARS.b);
|
||||
expect(row.flags).toEqual({});
|
||||
});
|
||||
|
||||
it('writes nothing when nothing changed, and only what changed otherwise', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const local = await h.calendars();
|
||||
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], local, h.ctx)).toEqual({ groups: [], deleteCalendarRows: [] });
|
||||
const renamed = { ...CALENDARS.b, name: 'Private', color: 'tomato' } as CalendarLike;
|
||||
const plan = calendarPlanner.planCalendars([personal(renamed)], local, h.ctx);
|
||||
expect(plan.groups).toHaveLength(1);
|
||||
const [op] = plan.groups[0].ops;
|
||||
expect(op).toMatchObject({ op: 'update', table: 'calendars', id: local[0].calendarRowId, expectCount: 1 });
|
||||
expect(Object.keys((op as { values: object }).values).sort()).toEqual(
|
||||
[Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CAL_SYNC2].sort(),
|
||||
);
|
||||
// VISIBLE is the user's once the row exists.
|
||||
expect((op as { values: object }).values).not.toHaveProperty(Calendars.VISIBLE);
|
||||
});
|
||||
|
||||
it('keeps VISIBLE as the user set it', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const [row] = await h.calendars();
|
||||
await h.apply({ ref: 'x', ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: { [Calendars.VISIBLE]: 0 } }] });
|
||||
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx).groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps rights to access levels, and a writable read-only calendar is a subscribed feed', () => {
|
||||
const level = (rights: CalendarLike['myRights'], readOnly = false) => {
|
||||
const plan = calendarPlanner.planCalendars([personal({ id: 'z', name: 'Z', myRights: rights } as CalendarLike, readOnly)], [], new Harness().ctx);
|
||||
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
|
||||
return [values[Calendars.CALENDAR_ACCESS_LEVEL], JSON.parse(String(values[Calendars.CAL_SYNC3]))];
|
||||
};
|
||||
expect(level({ mayWriteAll: true, mayWriteOwn: true })).toEqual([CalendarAccess.OWNER, {}]);
|
||||
expect(level({ mayWriteOwn: true })).toEqual([CalendarAccess.CONTRIBUTOR, {}]);
|
||||
expect(level({ mayRSVP: true }, true)).toEqual([CalendarAccess.RESPOND, { readOnly: 'rights' }]);
|
||||
expect(level({ mayReadItems: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'rights' }]);
|
||||
expect(level({ mayWriteAll: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'subscription' }]);
|
||||
});
|
||||
|
||||
it('offers no reminder editor when Bulwark owns reminders', async () => {
|
||||
const h = await new Harness({ reminderOwner: 'bulwark' }).setup(['b']);
|
||||
expect((await h.calendars())[0].cells[Calendars.MAX_REMINDERS]).toBe(0);
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'device' };
|
||||
const plan = calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx);
|
||||
expect((plan.groups[0].ops[0] as { values: object }).values).toEqual({ [Calendars.MAX_REMINDERS]: 5 });
|
||||
});
|
||||
|
||||
it('names shared accounts\' calendars after their account and colours calendars without a colour', () => {
|
||||
const ctx = new Harness().ctx;
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
[{ jmapAccountId: 'd', calendar: { id: 'x', name: 'Team', myRights: { mayWriteAll: true } } as CalendarLike, readOnly: false, accountName: 'Bob Builder' }],
|
||||
[],
|
||||
ctx,
|
||||
);
|
||||
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
|
||||
expect(values[Calendars._SYNC_ID]).toBe('d/x');
|
||||
expect(values[Calendars.CALENDAR_DISPLAY_NAME]).toBe('Team (Bob Builder)');
|
||||
expect(typeof values[Calendars.CALENDAR_COLOR]).toBe('number');
|
||||
expect(values[Calendars.OWNER_ACCOUNT]).toBe(ME);
|
||||
});
|
||||
|
||||
it('deletes rows of deselected calendars and never syncs the birthday calendar', async () => {
|
||||
const h = await new Harness().setup(['b', 'w']);
|
||||
const local = await h.calendars();
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
[personal(CALENDARS.b), personal({ id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays' } as CalendarLike)],
|
||||
local,
|
||||
h.ctx,
|
||||
);
|
||||
expect(plan.groups).toEqual([]);
|
||||
expect(plan.deleteCalendarRows).toEqual([local.find((c) => c.syncId === `${ACCT}/w`)!.calendarRowId]);
|
||||
});
|
||||
|
||||
it('reads calendar rows defensively', () => {
|
||||
const cal = calendarPlanner.decodeCalendar({ _id: '4', _sync_id: 'c/b', cal_sync2: 'garbage', cal_sync3: '{"readOnly":"subscription","taskOnly":true}' });
|
||||
expect(cal).toMatchObject({ calendarRowId: 4, syncId: 'c/b', shadow: null, flags: { readOnly: 'subscription', taskOnly: true } });
|
||||
expect(calendarPlanner.decodeCalendar({ _id: 5, _sync_id: null, cal_sync3: '[1]' }).flags).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Calendars, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { ACCT, CALENDARS, Harness, ME, single, utc, weekly } from './harness';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
|
||||
describe('calendar planner: downloads', () => {
|
||||
it('writes a recurring event with its exception, exclusion, attendees and reminders', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = await h.download(weekly());
|
||||
expect(plan.effect).toBe('insert');
|
||||
const master = h.masterRow('bl');
|
||||
expect(master).toMatchObject({
|
||||
[Events.TITLE]: 'Probe weekly',
|
||||
[Events.DESCRIPTION]: 'desc',
|
||||
[Events.EVENT_LOCATION]: 'Room 1',
|
||||
[Events.STATUS]: 0,
|
||||
[Events.AVAILABILITY]: 1,
|
||||
[Events.ACCESS_LEVEL]: 2,
|
||||
[Events.EVENT_COLOR]: cssColorToArgb('steelblue'),
|
||||
[Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
|
||||
[Events.DTEND]: null,
|
||||
[Events.DURATION]: 'P5400S',
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.ALL_DAY]: 0,
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE',
|
||||
[Events.EXDATE]: '20261012T130000Z',
|
||||
[Events.RDATE]: null,
|
||||
[Events.UID_2445]: 'probe-rec@example.org',
|
||||
[Events.ORGANIZER]: ME,
|
||||
[Events.HAS_ATTENDEE_DATA]: 1,
|
||||
[Events.DIRTY]: 0,
|
||||
});
|
||||
expect(JSON.parse(String(master[Events.SYNC_DATA1]))).toEqual(weekly());
|
||||
expect(master[Events.SELF_ATTENDEE_STATUS] ?? null).toBeNull();
|
||||
|
||||
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
|
||||
expect(exceptions).toHaveLength(1);
|
||||
expect(exceptions[0]).toMatchObject({
|
||||
[Events._SYNC_ID]: `${ACCT}/bl#2026-10-07T09:00:00`,
|
||||
[Events.SYNC_DATA2]: '2026-10-07T09:00:00',
|
||||
[Events.ORIGINAL_ID]: master._id,
|
||||
// Exact to the millisecond, or the provider shows the original instance as well.
|
||||
[Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-07T13:00:00Z'),
|
||||
[Events.ORIGINAL_ALL_DAY]: 0,
|
||||
[Events.TITLE]: 'Moved Wednesday',
|
||||
[Events.DTSTART]: utc('2026-10-07T15:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-07T16:30:00Z'),
|
||||
[Events.DURATION]: null,
|
||||
[Events.RRULE]: null,
|
||||
[Events.STATUS]: 0,
|
||||
});
|
||||
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
[ME, 2, 1],
|
||||
['guest@example.net', 1, 3],
|
||||
]);
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
// Only the start-relative offset alert has a Reminders row; the others stay on the server.
|
||||
expect(reminders.map((r) => [r[Reminders.MINUTES], r[Reminders.METHOD]])).toEqual([[15, 1]]);
|
||||
});
|
||||
|
||||
it('writes nothing for an echo of what the rows already hold', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const batches = h.fake.batches.length;
|
||||
const plan = calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0, stillDirty: false });
|
||||
expect(plan.ops.ops).toEqual([]);
|
||||
expect(h.fake.batches.length).toBe(batches);
|
||||
});
|
||||
|
||||
it('writes only what the server changed, behind asserts', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = await h.download({ ...weekly(), title: 'Renamed', updated: '2026-09-27T08:00:00Z' });
|
||||
expect(plan.effect).toBe('update');
|
||||
const ops = plan.ops.ops;
|
||||
const firstWrite = ops.findIndex((op) => op.op !== 'assert');
|
||||
expect(ops.slice(0, firstWrite).every((op) => op.op === 'assert')).toBe(true);
|
||||
expect(ops.slice(firstWrite).every((op) => op.op !== 'assert')).toBe(true);
|
||||
const updates = ops.filter((op) => op.op === 'update');
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(Object.keys((updates[0] as { values: object }).values).sort()).toEqual([Events.SYNC_DATA1, Events.SYNC_DATA4, Events.TITLE].sort());
|
||||
expect(h.masterRow('bl')[Events.TITLE]).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('updates only the shadow when the server changed a property the device does not show', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = await h.download({ ...weekly(), priority: 1, keywords: { other: true } });
|
||||
expect(plan).toMatchObject({ effect: 'update', writes: 1 });
|
||||
expect(JSON.parse(String(h.masterRow('bl')[Events.SYNC_DATA1])).priority).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps floating events on the wall clock of the device zone', async () => {
|
||||
const h = await new Harness({ deviceZone: 'America/New_York' }).setup();
|
||||
await h.download(single({ id: 'f1', timeZone: null, start: '2026-10-06T14:00:00', duration: 'PT45M' }));
|
||||
expect(h.masterRow('f1')).toMatchObject({
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.DTSTART]: utc('2026-10-06T18:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-06T18:45:00Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a timed event in its own zone, not the device one', async () => {
|
||||
const h = await new Harness({ deviceZone: 'Europe/Berlin' }).setup();
|
||||
await h.download(single({ id: 'tk', timeZone: 'Asia/Tokyo', start: '2026-10-06T09:00:00' }));
|
||||
expect(h.masterRow('tk')).toMatchObject({ [Events.EVENT_TIMEZONE]: 'Asia/Tokyo', [Events.DTSTART]: utc('2026-10-06T00:00:00Z') });
|
||||
});
|
||||
|
||||
it('writes all-day events as UTC midnights, multi-day and in weeks too', async () => {
|
||||
const h = await new Harness({ deviceZone: 'Pacific/Auckland' }).setup();
|
||||
await h.download(single({ id: 'ad', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P2D' }));
|
||||
expect(h.masterRow('ad')).toMatchObject({
|
||||
[Events.ALL_DAY]: 1,
|
||||
[Events.EVENT_TIMEZONE]: 'UTC',
|
||||
[Events.DTSTART]: utc('2026-10-08T00:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-10T00:00:00Z'),
|
||||
});
|
||||
// CalDAV DTENDs arrive as weeks.
|
||||
await h.download(single({ id: 'wk', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1W' }));
|
||||
expect(h.masterRow('wk')[Events.DTEND]).toBe(utc('2026-10-19T00:00:00Z'));
|
||||
// A recurring all-day event gets a DURATION in days (the provider throws on seconds).
|
||||
await h.download(single({ id: 'ar', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } }));
|
||||
expect(h.masterRow('ar')).toMatchObject({ [Events.DURATION]: 'P1D', [Events.DTEND]: null, [Events.RRULE]: 'FREQ=YEARLY' });
|
||||
// A time part on an all-day duration rounds up.
|
||||
await h.download(single({ id: 'ap', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1DT2H' }));
|
||||
expect(h.masterRow('ap')[Events.DTEND]).toBe(utc('2026-10-14T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('writes the calendar defaults as reminders when the event uses them', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ id: 'da', useDefaultAlerts: true, alerts: { own: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } }));
|
||||
const master = h.masterRow('da');
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(reminders.map((r) => r[Reminders.MINUTES])).toEqual([10]);
|
||||
});
|
||||
|
||||
it('writes no reminders when Bulwark owns them, and leaves the ones an app adds', async () => {
|
||||
const h = await new Harness({ reminderOwner: 'bulwark' }).setup();
|
||||
const cal = (await h.calendars()).find((c) => c.syncId === `${ACCT}/b`)!;
|
||||
expect(cal.cells[Calendars.MAX_REMINDERS]).toBe(0);
|
||||
await h.download(weekly());
|
||||
const master = h.masterRow('bl');
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(0);
|
||||
h.fake.user.setReminders(Number(master._id), [{ minutes: 5 }]);
|
||||
// Neither uploaded nor deleted: the edit only clears DIRTY.
|
||||
const upload = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
expect(upload.kind).toBe('clean');
|
||||
if (upload.kind === 'clean') await h.apply(upload.ops);
|
||||
await h.download({ ...weekly(), title: 'Again' });
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('normalises the user\'s own address, under any alias, to OWNER_ACCOUNT', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = single({
|
||||
id: 'inv',
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:ALIAS@Example.org', roles: { attendee: true }, participationStatus: 'tentative' },
|
||||
room: { calendarAddress: 'mailto:room@example.net', kind: 'resource', roles: { attendee: true } },
|
||||
opt: { calendarAddress: 'mailto:opt@example.net', roles: { attendee: true, optional: true }, participationStatus: 'declined' },
|
||||
noMail: { name: 'Phone only', roles: { attendee: true } },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
await h.download(event);
|
||||
const master = h.masterRow('inv');
|
||||
expect(master[Events.ORGANIZER]).toBe('boss@example.net');
|
||||
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_TYPE], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
['boss@example.net', 2, 1, 3],
|
||||
[ME, 1, 1, 4],
|
||||
['room@example.net', 1, 3, 3],
|
||||
['opt@example.net', 1, 2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves ORGANIZER to the provider for events without participants', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = await h.download(single());
|
||||
const insert = plan.ops.ops.find((op) => op.op === 'insert' && op.table === 'events') as { values: Record<string, unknown> };
|
||||
expect(Events.ORGANIZER in insert.values).toBe(false);
|
||||
expect(Events.SELF_ATTENDEE_STATUS in insert.values).toBe(false);
|
||||
expect(h.masterRow('e1')[Events.ORGANIZER]).toBe(ME);
|
||||
// The baseline predicted the provider's value, so there is nothing to heal and an echo writes nothing.
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
|
||||
expect(calendarPlanner.planDownload(single(), await h.local('e1'), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('never writes a NULL STATUS', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ status: undefined }));
|
||||
expect(h.masterRow('e1')[Events.STATUS]).toBe(1);
|
||||
await h.download(single({ status: 'cancelled' }));
|
||||
expect(h.masterRow('e1')[Events.STATUS]).toBe(2);
|
||||
});
|
||||
|
||||
it('removes and adds exception rows as overrides come and go', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const next = weekly();
|
||||
next.recurrenceOverrides = {
|
||||
'2026-10-12T09:00:00': { excluded: true },
|
||||
'2026-10-14T09:00:00': { excluded: true },
|
||||
'2026-10-19T09:00:00': { title: 'Other' },
|
||||
};
|
||||
const plan = await h.download(next);
|
||||
expect(plan.effect).toBe('update');
|
||||
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
|
||||
expect(exceptions.map((x) => x[Events.SYNC_DATA2])).toEqual(['2026-10-19T09:00:00']);
|
||||
expect(exceptions[0][Events.TITLE]).toBe('Other');
|
||||
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
|
||||
});
|
||||
|
||||
it('replaces the rows of a different object under a reused id', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const other = single({ id: 'bl', uid: 'other-uid', title: 'Someone else' });
|
||||
const plan = await h.download(other);
|
||||
expect(plan.effect).toBe('update');
|
||||
const events = await h.events();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ syncId: `${ACCT}/bl`, exceptions: [] });
|
||||
expect(events[0].cells[Events.TITLE]).toBe('Someone else');
|
||||
});
|
||||
|
||||
it('refuses tasks', async () => {
|
||||
const h = await new Harness().setup();
|
||||
expect(() => calendarPlanner.planDownload({ ...single(), '@type': 'Task' } as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
|
||||
expect(() => calendarPlanner.planDownload({ ...single(), '@type': undefined, due: '2026-10-01T00:00:00' } as unknown as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
|
||||
});
|
||||
|
||||
it('merges an edited event another client moved out of every synced calendar where its rows are', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single());
|
||||
const id = Number(h.masterRow('e1')._id);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Team lunch' });
|
||||
// Filed in a calendar this device does not sync, with a new description.
|
||||
const moved = single({ calendarIds: { archive: true }, description: 'Bring salad' });
|
||||
|
||||
const plan = calendarPlanner.planDownload(moved, await h.local('e1'), h.ctx);
|
||||
await h.apply(plan.ops);
|
||||
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
expect(h.masterRow('e1')).toMatchObject({ [Events.CALENDAR_ID]: h.rowIds.get('b'), [Events.TITLE]: 'Team lunch', [Events.DESCRIPTION]: 'Bring salad', [Events.DIRTY]: 1 });
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(local.shadow).toEqual(moved);
|
||||
// Only the device's edit goes up (no calendar move), and the accepted version takes the rows away.
|
||||
const plan2 = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(plan2.kind === 'upload' && plan2.actions).toEqual([{ kind: 'update', id: 'e1', patch: { title: 'Team lunch' } }]);
|
||||
await h.apply(calendarPlanner.planAccepted(local, { ...moved, title: 'Team lunch' }, h.ctx).ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('places an event in several calendars in the first selected one, and keeps it where it is', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ calendarIds: { w: true, b: true } }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('b'));
|
||||
await h.download(single({ calendarIds: { w: true } }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
|
||||
await h.download(single({ calendarIds: { w: true, b: true }, title: 'x' }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
|
||||
expect(CALENDARS.w.name).toBe('Work');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addDuration,
|
||||
durationBetween,
|
||||
durationTextSeconds,
|
||||
formatAospDuration,
|
||||
formatJsDuration,
|
||||
formatJsDurationSeconds,
|
||||
parseDuration,
|
||||
sameDurationLength,
|
||||
} from '../../calendar/duration';
|
||||
import { aospDurationValid } from '../fakes/fake-provider';
|
||||
import { localToUtc } from '../../calendar/zoned-time';
|
||||
|
||||
describe('calendar/duration', () => {
|
||||
it('parses JSCalendar, iCalendar and AOSP durations', () => {
|
||||
expect(parseDuration('PT1H30M')).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
expect(parseDuration('P1W')).toEqual({ negative: false, days: 7, seconds: 0 });
|
||||
expect(parseDuration('P1W2D')).toEqual({ negative: false, days: 9, seconds: 0 });
|
||||
expect(parseDuration('P1DT2H')).toEqual({ negative: false, days: 1, seconds: 7200 });
|
||||
expect(parseDuration('-PT15M')).toEqual({ negative: true, days: 0, seconds: 900 });
|
||||
expect(parseDuration('+PT15M')).toEqual({ negative: false, days: 0, seconds: 900 });
|
||||
expect(parseDuration('PT1.5S')).toEqual({ negative: false, days: 0, seconds: 1.5 });
|
||||
expect(parseDuration('pt10m')).toEqual({ negative: false, days: 0, seconds: 600 });
|
||||
// AOSP: no T before seconds, M always minutes, Fossify's padded form.
|
||||
expect(parseDuration('P3600S')).toEqual({ negative: false, days: 0, seconds: 3600 });
|
||||
expect(parseDuration('P0DT1H30M0S')).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
for (const bad of ['P', 'PT', 'P1Y', 'P1.5D', '1H', '', 'PTH']) expect(parseDuration(bad)).toBeNull();
|
||||
expect(durationTextSeconds('P1W')).toBe(604_800);
|
||||
});
|
||||
|
||||
it('writes the strict JSCalendar grammar', () => {
|
||||
expect(formatJsDuration(0, 0)).toBe('PT0S');
|
||||
expect(formatJsDuration(2, 0)).toBe('P2D');
|
||||
expect(formatJsDuration(1, 3600)).toBe('P1DT1H');
|
||||
expect(formatJsDuration(0, 5400)).toBe('PT1H30M');
|
||||
// Minutes stay between hours and seconds.
|
||||
expect(formatJsDuration(0, 3605)).toBe('PT1H0M5S');
|
||||
expect(formatJsDuration(0, 45)).toBe('PT45S');
|
||||
expect(formatJsDuration(0, 59.6)).toBe('PT1M');
|
||||
expect(formatJsDurationSeconds(90_000)).toBe('P1DT1H');
|
||||
expect(formatJsDuration(-1, -5)).toBe('PT0S');
|
||||
});
|
||||
|
||||
it('writes only what CalendarProvider accepts', () => {
|
||||
expect(formatAospDuration(5400, false)).toBe('P5400S');
|
||||
expect(formatAospDuration(0, false)).toBe('P0S');
|
||||
expect(formatAospDuration(172_800, true)).toBe('P2D');
|
||||
// A time part on an all-day event is rounded up to whole days; never zero days.
|
||||
expect(formatAospDuration(90_000, true)).toBe('P2D');
|
||||
expect(formatAospDuration(0, true)).toBe('P1D');
|
||||
for (const s of [0, 1, 59, 3600, 86_400, 1_000_000]) {
|
||||
expect(aospDurationValid(formatAospDuration(s, false))).toBe(true);
|
||||
expect(formatAospDuration(s, true)).toMatch(/^P\d+D$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts whole days nominally in the zone and the rest exactly', () => {
|
||||
const berlin = 'Europe/Berlin';
|
||||
// 23 h across the spring change are still one day.
|
||||
const start = localToUtc('2026-03-28T12:00:00', berlin)!;
|
||||
const end = localToUtc('2026-03-29T12:00:00', berlin)!;
|
||||
expect(end - start).toBe(23 * 3_600_000);
|
||||
expect(durationBetween(start, end, berlin)).toEqual({ negative: false, days: 1, seconds: 0 });
|
||||
expect(addDuration(start, { negative: false, days: 1, seconds: 0 }, berlin)).toBe(end);
|
||||
expect(durationBetween(start, end + 1_800_000, berlin)).toEqual({ negative: false, days: 1, seconds: 1800 });
|
||||
expect(durationBetween(start, start + 5_400_000, berlin)).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
expect(durationBetween(end, start, berlin)).toEqual({ negative: false, days: 0, seconds: 0 });
|
||||
});
|
||||
|
||||
it('keeps the second instant of a repeated hour when adding exact time', () => {
|
||||
const second = Date.parse('2026-10-25T01:30:00Z'); // 02:30 CET, after the change
|
||||
expect(addDuration(second, { negative: false, days: 0, seconds: 3600 }, 'Europe/Berlin')).toBe(second + 3_600_000);
|
||||
});
|
||||
|
||||
it('compares lengths with a day as 24 hours', () => {
|
||||
expect(sameDurationLength('PT24H', 'P1D')).toBe(true);
|
||||
expect(sameDurationLength('P3600S', 'PT1H')).toBe(true);
|
||||
expect(sameDurationLength('PT1H', 'PT2H')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Upload → server → accepted plan → the server's echo: after every kind of
|
||||
* device edit, the rows must already be what the server's next download
|
||||
* describes, so that echo writes nothing (docs/device-sync.md, invariant 4).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
async function roundTrip(event: CalendarEventWire, edit: (h: Harness, id: number) => void | Promise<void>) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow(event.id)._id);
|
||||
await edit(h, id);
|
||||
const local = (await h.local(event.id))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update: ${JSON.stringify(plan)}`);
|
||||
const server = { ...serverApply(event, plan.actions[0].patch), updated: '2026-09-27T13:00:00Z' };
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
const after = (await h.local(event.id))!;
|
||||
return { h, id, server, after, patch: plan.actions[0].patch };
|
||||
}
|
||||
|
||||
const EDITS: Array<[string, () => CalendarEventWire, (h: Harness, id: number) => void | Promise<void>]> = [
|
||||
['a title', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.TITLE]: 'T' })],
|
||||
['a start of a single event', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') })],
|
||||
['a DST-ambiguous start (fixed-offset zone)', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') })],
|
||||
['an "all events" move', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') })],
|
||||
['a "this event" edit', weekly, (h, id) => void h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.TITLE]: 'Only this', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z') })],
|
||||
['a cancelled instance', weekly, (h, id) => void h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'))],
|
||||
['a capped rule', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261010T000000Z;BYDAY=MO,WE' })],
|
||||
['an RSVP', weekly, (h, id) => h.fake.user.setAttendeeStatus(id, ME, 4)],
|
||||
['an added attendee', weekly, (h, id) => void h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'x@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3, [Attendees.ATTENDEE_RELATIONSHIP]: 1 })],
|
||||
['reminders', weekly, (h, id) => h.fake.user.setReminders(id, [{ minutes: 5 }, { minutes: 1440, method: 2 }])],
|
||||
['a location on an event without one', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Hall' })],
|
||||
['an all-day event\'s dates', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-12T00:00:00Z') })],
|
||||
];
|
||||
|
||||
describe('calendar planner: accepted uploads leave nothing for the echo', () => {
|
||||
it.each(EDITS)('%s', async (_name, make, edit) => {
|
||||
const { h, server, after } = await roundTrip(make(), edit);
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.exceptions.every((x) => !x.dirty)).toBe(true);
|
||||
const echo = calendarPlanner.planDownload(server, after, h.ctx);
|
||||
expect(echo).toMatchObject({ effect: 'none', writes: 0 });
|
||||
expect(calendarPlanner.planBaselineHeal(after)).toBeNull();
|
||||
});
|
||||
|
||||
it('re-keys the exception rows with the series after an "all events" move', async () => {
|
||||
const { after, patch } = await roundTrip(weekly(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') }));
|
||||
expect(Object.keys(patch.recurrenceOverrides as object).sort()).toEqual(['2026-10-07T10:00:00', '2026-10-12T10:00:00']);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.cells[Events.ORIGINAL_INSTANCE_TIME]])).toEqual([
|
||||
[`${ACCT}/bl#2026-10-07T10:00:00`, utc('2026-10-07T14:00:00Z')],
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the exception rows of overrides a "this and following" cut pruned', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Early' }, '2026-10-21T09:00:00': { title: 'Late' } };
|
||||
const { after } = await roundTrip(event, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' }));
|
||||
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, single, utc, weekly } from './harness';
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
describe('calendar planner: edge cases', () => {
|
||||
it('undoes a deletion in a read-only calendar by purging and refetching', async () => {
|
||||
const { h, id } = await synced(single({ calendarIds: { r: true } }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', refetch: true, reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an instance Stalwart holds without its series as read-only', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceId: '2026-10-06T12:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }));
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine' });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'instanceOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('pairs Etar\'s move of a series with its copied exceptions and adopts them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const fresh = h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Probe weekly', [Events.DESCRIPTION]: 'desc', [Events.EVENT_LOCATION]: 'Room 1', [Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
|
||||
[Events.DURATION]: 'P5400S', [Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE', [Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.STATUS]: 0, [Events.AVAILABILITY]: 1, [Events.ACCESS_LEVEL]: 2, [Events.EVENT_COLOR]: h.row(id)[Events.EVENT_COLOR],
|
||||
});
|
||||
for (const a of h.fake.rows('attendees', 'event_id = ?', [id])) {
|
||||
h.fake.table('attendees').set(h.fake.allocId(), { ...a, _id: null, event_id: fresh });
|
||||
}
|
||||
// Etar copies the exception too (API 30+).
|
||||
h.fake.user.insertException(fresh, utc('2026-10-07T13:00:00Z'), { [Events.TITLE]: 'Moved Wednesday', [Events.DTSTART]: utc('2026-10-07T15:00:00Z'), [Events.DTEND]: utc('2026-10-07T16:30:00Z') });
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'bl', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
|
||||
await h.apply(pairs[0].ops);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.eventId).toBe(fresh);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/bl#2026-10-07T09:00:00`, false]]);
|
||||
// The old row and its own exception are gone.
|
||||
expect((await h.events()).map((e) => e.eventId)).toEqual([fresh]);
|
||||
});
|
||||
|
||||
it('compares descriptions over 1 KB by hash and uploads them only when edited', async () => {
|
||||
const long = 'Lorem ipsum '.repeat(200);
|
||||
const { h, id } = await synced(single({ description: long }));
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(String(local.baseline!.cells[Events.DESCRIPTION])).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
h.fake.user.updateEvent(id, {});
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('clean');
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: `${long}!` });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan.kind === 'upload' && plan.actions[0].kind === 'update' && plan.actions[0].patch).toEqual({ description: `${long}!` });
|
||||
});
|
||||
|
||||
it('writes nothing into a split clone on download: it is a new event', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'daily' } });
|
||||
const { h, id } = await synced(event);
|
||||
const clone = h.fake.user.splitViaUri(id, utc('2026-10-10T10:00:00Z'), '20261010T095959Z', {});
|
||||
const cloneEvent = (await h.events()).find((e) => e.eventId === clone)!;
|
||||
expect(calendarPlanner.planDownload({ ...event, title: 'x' }, cloneEvent, h.ctx)).toMatchObject({ effect: 'none', stillDirty: true });
|
||||
});
|
||||
|
||||
it('keeps an exception row an app left without a description or attendees inheriting them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'), [Events.EVENT_LOCATION]: null });
|
||||
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error();
|
||||
const override = plan.actions[0].patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
|
||||
// The series' description, location and attendees stay on the instance.
|
||||
expect(override).toMatchObject({ description: 'desc', locations: weekly().locations, participants: weekly().participants });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
// The calendar planner through the engine and the fake JMAP server: device
|
||||
// edits reach the server as the user made them and the rows stay as the user
|
||||
// left them, end to end. Mapping details are the planner's own tests.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { CALENDAR_AUTHORITY, createHarness, type Harness } from '../engine/harness';
|
||||
|
||||
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
|
||||
const h = createHarness(options);
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
type Overrides = Record<string, Record<string, unknown>>;
|
||||
|
||||
describe('calendar device edits through the engine', () => {
|
||||
it('creates an event and an occurrence edit an app left without a status as confirmed ones', async () => {
|
||||
const h = real();
|
||||
const series = h.server.addEvent('a', {
|
||||
uid: 'weekly-uid',
|
||||
title: 'Weekly',
|
||||
start: '2026-10-05T08:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 8 },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${series}`)!;
|
||||
const rowId = Number(master[Events.CALENDAR_ID]);
|
||||
|
||||
// Google Calendar leaves STATUS NULL on the events and exceptions it inserts.
|
||||
const created = h.device.user.insertEvent(rowId, {
|
||||
[Events.TITLE]: 'New from Google Calendar',
|
||||
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
|
||||
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
[Events.STATUS]: null,
|
||||
});
|
||||
h.device.user.insertException(Number(master._id), Date.UTC(2026, 10, 9, 7), {
|
||||
[Events.TITLE]: 'Only Nov 9',
|
||||
[Events.DTSTART]: Date.UTC(2026, 10, 9, 7),
|
||||
[Events.DTEND]: Date.UTC(2026, 10, 9, 8),
|
||||
[Events.STATUS]: null,
|
||||
});
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
|
||||
|
||||
const event = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'New from Google Calendar')!;
|
||||
expect(event.status).toBeUndefined();
|
||||
const overrides = h.server.get('CalendarEvent', 'a', series)!.recurrenceOverrides as Overrides;
|
||||
expect(overrides['2026-11-09T08:00:00']).toMatchObject({ title: 'Only Nov 9' });
|
||||
expect(overrides['2026-11-09T08:00:00'].status).toBeUndefined();
|
||||
// The rows hold the default, confirmed, and are clean.
|
||||
expect(h.events().find((e) => Number(e._id) === created)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${series}`).map((e) => [e[Events.STATUS], e[Events.DIRTY]])).toEqual([[1, 0]]);
|
||||
});
|
||||
|
||||
it('keeps a reminder changed on one occurrence of a series on the calendar\'s default alerts', async () => {
|
||||
const h = real();
|
||||
h.server.serverUpdate('Calendar', 'a', h.calendar, {
|
||||
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
|
||||
});
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', start: '2026-09-29T10:00:00' } },
|
||||
useDefaultAlerts: true,
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const minutes = (eventId: unknown) => h.device.rows('reminders').filter((r) => r[Reminders.EVENT_ID] === Number(eventId)).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [15]]);
|
||||
|
||||
h.device.user.setReminders(Number(exception()._id), [{ minutes: 60 }]);
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()[Events.DIRTY]).toBe(0);
|
||||
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [60]]);
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event.useDefaultAlerts).toBe(true);
|
||||
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
// Its echo writes nothing.
|
||||
const before = h.batches.log.length;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', {
|
||||
name: 'Team events',
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false },
|
||||
});
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
const id = h.server.addEvent('team', {
|
||||
uid: 'team-weekly',
|
||||
title: 'Team weekly',
|
||||
start: '2026-10-05T09:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 },
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
calendarIds: { [team]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!;
|
||||
|
||||
// Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI.
|
||||
h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
|
||||
const before = sets();
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
// The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back.
|
||||
expect(sets()).toBeGreaterThan(before);
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]);
|
||||
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]);
|
||||
expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true);
|
||||
// Nothing was stored at all: the report does not claim it was, without invitations.
|
||||
expect(report.message ?? '').not.toContain('invitations not sent');
|
||||
});
|
||||
|
||||
it('notes that invitations were not sent only when the change was stored without them', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'review-uid',
|
||||
title: 'Review',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const row = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
h.device.user.updateEvent(row, { [Events.TITLE]: 'Design review' });
|
||||
// The server refuses the scheduling messages once (e.g. scheduling turned off): the change goes up without them.
|
||||
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'forbidden' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
expect(report.message).toContain('invitations not sent');
|
||||
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Design review' });
|
||||
});
|
||||
|
||||
it('puts an event an app moved in place to a calendar of another account back into its calendar', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = String(h.server.all('Calendar', 'team')[0].id);
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const calendarRow = (key: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === key)!._id);
|
||||
const row = () => h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
|
||||
expect(row()[Events.CALENDAR_ID]).toBe(calendarRow(`a/${h.calendar}`));
|
||||
|
||||
// An app moves it by changing CALENDAR_ID in place (a move across JMAP accounts can't be a patch).
|
||||
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`) });
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.DIRTY]: 0 });
|
||||
expect(h.server.get('CalendarEvent', 'a', id)!.calendarIds).toEqual({ [h.calendar]: true });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
// Nothing was sent; the change counts as skipped, and the report says why.
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0, updated: 0, deleted: 0 }, skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'crossAccountMove' })]);
|
||||
|
||||
// Together with another edit: that one uploads, and the row goes back all the same.
|
||||
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`), [Events.TITLE]: 'Team lunch' });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', stats: { uploaded: { updated: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Team lunch', calendarIds: { [h.calendar]: true } });
|
||||
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.TITLE]: 'Team lunch', [Events.DIRTY]: 0 });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
});
|
||||
|
||||
describe("Etar's move (delete + insert) with other edits", () => {
|
||||
/** The event columns Etar copies into the row it inserts when it moves an event. */
|
||||
const ETAR_COPY = [
|
||||
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE, Events.EXDATE,
|
||||
];
|
||||
function etarMove(h: Harness, masterId: number, calendarRowId: number, reminders: Array<{ minutes: number }>): number {
|
||||
const old = h.events().find((e) => Number(e._id) === masterId)!;
|
||||
const values = Object.fromEntries(ETAR_COPY.filter((c) => old[c] !== undefined).map((c) => [c, old[c]]));
|
||||
h.device.user.deleteEvent(masterId);
|
||||
return h.device.user.insertEvent(calendarRowId, values, {
|
||||
reminders: reminders.map((r) => ({ [Reminders.MINUTES]: r.minutes, [Reminders.METHOD]: 1 })),
|
||||
});
|
||||
}
|
||||
const minutes = (h: Harness, eventId: number) =>
|
||||
h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === eventId).map((r) => Number(r[Reminders.MINUTES]));
|
||||
|
||||
it('uploads a reminder saved with the move, and keeps the event', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
keywords: { important: true },
|
||||
alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
|
||||
const moved = etarMove(h, master, workRow, [{ minutes: 60 }]);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
|
||||
const alerts = Object.values(event.alerts as Record<string, { trigger: { offset: string } }>).map((a) => a.trigger.offset);
|
||||
expect(alerts).toEqual(['-PT1H']);
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
|
||||
expect(minutes(h, moved)).toEqual([60]);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
|
||||
});
|
||||
|
||||
describe('when the server deleted the event meanwhile', () => {
|
||||
async function movedLunch() {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
etarMove(h, master, workRow, [{ minutes: 30 }]);
|
||||
return { h, id };
|
||||
}
|
||||
const creates = (h: Harness) =>
|
||||
h.server.calls('CalendarEvent/set').filter(([, args]) => Object.keys((args as { create?: object }).create ?? {}).length);
|
||||
|
||||
it('removes the moved event when the patch of the move finds it gone', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'notFound' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', conflicts: 1, stats: { uploaded: { created: 0 } } });
|
||||
expect(creates(h)).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the moved event when the download finds it destroyed', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
h.server.serverDestroy('CalendarEvent', 'a', id);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], conflicts: 1, stats: { uploaded: { created: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the moved event when it is destroyed while the move is on its way', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
const stop = h.server.onBeforeRequest((request) => {
|
||||
if (!request.methods.includes('CalendarEvent/set')) return;
|
||||
stop();
|
||||
h.server.serverDestroy('CalendarEvent', 'a', id);
|
||||
});
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an occurrence edit that had not reached the server when the series moves', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const exception = Number(h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
// The occurrence is renamed on the device; before it syncs, Etar moves the series (it copies only unsynced exceptions).
|
||||
h.device.user.updateEvent(exception, { [Events.TITLE]: 'Sprint planning' });
|
||||
|
||||
const moved = etarMove(h, master, workRow, []);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event.calendarIds).toEqual({ [work]: true });
|
||||
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ title: 'Sprint planning' });
|
||||
const rows = h.events();
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_ID] === null)).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_ID] !== null)).toEqual([
|
||||
expect.objectContaining({ [Events.ORIGINAL_ID]: moved, [Events.CALENDAR_ID]: workRow, [Events.TITLE]: 'Sprint planning', [Events.DIRTY]: 0 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an event created in a read-only calendar and reports it', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', {
|
||||
name: 'Team events',
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false },
|
||||
});
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
|
||||
|
||||
const id = h.device.user.insertEvent(teamRow, {
|
||||
[Events.TITLE]: 'Mine',
|
||||
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
|
||||
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
});
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0 }, skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, side: 'upload', type: 'readOnly' })]);
|
||||
expect(h.events().find((e) => Number(e._id) === id)).toBeUndefined();
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
// Nothing is left waiting: the next run has nothing to say about it.
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).itemErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves nothing waiting for turning sync off after an event was created in a read-only calendar', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', { name: 'Team events', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: false } });
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
|
||||
h.device.user.insertEvent(teamRow, { [Events.TITLE]: 'Mine', [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), [Events.DTEND]: Date.UTC(2026, 9, 14, 14), [Events.EVENT_TIMEZONE]: 'Europe/Berlin' });
|
||||
|
||||
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
});
|
||||
|
||||
describe('an occurrence\'s own colour and reminders removed on the device', () => {
|
||||
async function standup(extra: Record<string, unknown>, override: Record<string, unknown>) {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', ...override } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
...extra,
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
const minutes = () => h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === Number(exception()._id)).map((r) => Number(r[Reminders.MINUTES]));
|
||||
return { h, id, exception, minutes };
|
||||
}
|
||||
|
||||
it('keeps a colour cleared on one occurrence cleared', async () => {
|
||||
const { h, id, exception } = await standup({ color: 'steelblue' }, { color: 'red' });
|
||||
|
||||
h.device.user.updateEvent(Number(exception()._id), { [Events.EVENT_COLOR]: null });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
|
||||
expect((h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ color: '' });
|
||||
});
|
||||
|
||||
it('puts back the removal of every reminder of an occurrence whose series shows its own, and reports it', async () => {
|
||||
const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' };
|
||||
const { h, id, exception, minutes } = await standup({ alerts: { al1: alert } }, {});
|
||||
expect(minutes()).toEqual([15]);
|
||||
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
|
||||
const before = sets();
|
||||
|
||||
h.device.user.setReminders(Number(exception()._id), []);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'remindersNotRepresentable' })]);
|
||||
expect(sets()).toBe(before);
|
||||
expect(minutes()).toEqual([15]);
|
||||
expect(exception()[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a description and location cleared on one occurrence cleared', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
description: 'Daily agenda',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
locations: { locA: { '@type': 'Location', name: 'Room 1' } },
|
||||
recurrenceOverrides: {
|
||||
'2026-09-29T09:00:00': { title: 'Standup', description: 'Special agenda', locations: { locB: { '@type': 'Location', name: 'Room 2' } } },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
|
||||
h.device.user.updateEvent(Number(exception()._id), { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
const overrides = h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides;
|
||||
expect(overrides['2026-09-29T09:00:00']).toMatchObject({ description: '', locations: { locB: { name: '' } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import type { LocalEvent, UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
function patchOf(plan: Plan) {
|
||||
expect(plan.kind).toBe('upload');
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(JSON.stringify(plan));
|
||||
return plan.actions[0];
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
/** Uploads, lets the server apply the patch, applies the accepted plan and returns the new server object. */
|
||||
async function accept(h: Harness, event: CalendarEventWire, local: LocalEvent, plan: Plan): Promise<CalendarEventWire> {
|
||||
const action = patchOf(plan);
|
||||
const server = serverApply(event, action.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
return server;
|
||||
}
|
||||
|
||||
const NO_FORBIDDEN = ['@type', 'organizerCalendarAddress', 'privacy', 'uid', 'recurrenceRule', 'recurrenceOverrides', 'recurrenceId', 'method', 'prodId', 'sentBy'];
|
||||
|
||||
describe('calendar planner: recurrence edits made on the device', () => {
|
||||
it('uploads an Etar "this event" edit as a new override carrying the master\'s details', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
|
||||
[Events.TITLE]: 'Only this Wednesday',
|
||||
[Events.DTSTART]: utc('2026-10-14T14:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-14T15:30:00Z'),
|
||||
});
|
||||
const local = (await h.local('bl'))!;
|
||||
expect(local.exceptions.find((x) => !x.cells[Events.SYNC_DATA2])?.recurrenceId).toBe('2026-10-14T09:00:00');
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const action = patchOf(plan);
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-14T09:00:00']);
|
||||
const override = action.patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
|
||||
expect(override).toMatchObject({
|
||||
title: 'Only this Wednesday',
|
||||
start: '2026-10-14T10:00:00',
|
||||
duration: 'PT1H30M',
|
||||
locations: weekly().locations,
|
||||
participants: weekly().participants,
|
||||
description: 'desc',
|
||||
});
|
||||
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
|
||||
// The organizer changed an instance: attendees are told.
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
|
||||
const server = await accept(h, weekly(), local, plan);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([
|
||||
[`${ACCT}/bl#2026-10-07T09:00:00`, false],
|
||||
[`${ACCT}/bl#2026-10-14T09:00:00`, false],
|
||||
]);
|
||||
// The download of our own change writes nothing.
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('sends the whole overrides map for the first override of a series', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'daily', count: 5 } });
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.insertException(id, utc('2026-10-07T10:00:00Z'), { [Events.TITLE]: 'Tuesday lunch', [Events.DTSTART]: utc('2026-10-07T10:00:00Z'), [Events.DTEND]: utc('2026-10-07T11:00:00Z') });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides']);
|
||||
expect(action.patch.recurrenceOverrides).toEqual({
|
||||
'2026-10-07T12:00:00': { title: 'Tuesday lunch', start: '2026-10-07T12:00:00', duration: 'PT1H' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads "delete this event" (a cancelled exception) as an exclusion and cleans up after', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-14T13:00:00Z'), utc('2026-10-14T14:30:00Z'));
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-14T09:00:00': { excluded: true } });
|
||||
const server = await accept(h, weekly(), local, plan);
|
||||
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('excludes an instance whose synced exception row was deleted', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.deleteEvent(x.eventId);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true } });
|
||||
await accept(h, weekly(), local, plan);
|
||||
expect(h.row(x.eventId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not make an occurrence an app left without a status (Google Calendar\'s "this event") tentative', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'weekly', count: 5 } });
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.insertException(id, utc('2026-10-13T10:00:00Z'), {
|
||||
[Events.TITLE]: 'Only this Tuesday', [Events.DTSTART]: utc('2026-10-13T10:00:00Z'), [Events.DTEND]: utc('2026-10-13T11:00:00Z'), [Events.STATUS]: null,
|
||||
});
|
||||
const local = (await h.local('e1'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const overrides = patchOf(plan).patch.recurrenceOverrides as Record<string, Record<string, unknown>>;
|
||||
expect(overrides['2026-10-13T12:00:00']).toEqual({ title: 'Only this Tuesday', start: '2026-10-13T12:00:00', duration: 'PT1H' });
|
||||
await accept(h, event, local, plan);
|
||||
const [x] = (await h.local('e1'))!.exceptions;
|
||||
expect(x.cells).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => {
|
||||
const event = weekly();
|
||||
event.description = 'Daily agenda';
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
// Without a value of its own the override would show the series' "Daily agenda" and "Room 1" again, so it
|
||||
// gets the empty ones Stalwart keeps (an empty map would read back as no locations, i.e. the series' ones).
|
||||
expect(patchOf(plan).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': '',
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locB: { '@type': 'Location', name: '' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an occurrence\'s own description and location when the series has none to show instead', async () => {
|
||||
const event = weekly();
|
||||
delete event.description;
|
||||
delete event.locations;
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': null,
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a reminder changed on one occurrence of a series that uses the calendar\'s default alerts', async () => {
|
||||
const event = weekly();
|
||||
delete event.alerts;
|
||||
event.useDefaultAlerts = true;
|
||||
const { h, id } = await synced(event);
|
||||
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
// The calendar's default (10 minutes) on the series and on its occurrences.
|
||||
expect([minutes(id), minutes(x.eventId)]).toEqual([[10], [10]]);
|
||||
h.fake.user.setReminders(x.eventId, [{ minutes: 60 }]);
|
||||
// A new occurrence edit with its own reminder, too (Etar "this event").
|
||||
const added = h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
|
||||
[Events.TITLE]: 'Probe weekly', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'),
|
||||
});
|
||||
h.fake.user.setReminders(added, [{ minutes: 5 }]);
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((e) => [e.recurrenceId, minutes(e.eventId), e.dirty])).toEqual([
|
||||
['2026-10-07T09:00:00', [60], false],
|
||||
['2026-10-14T09:00:00', [5], false],
|
||||
]);
|
||||
// The series and its other occurrences keep the calendar's defaults.
|
||||
expect(server.useDefaultAlerts).toBe(true);
|
||||
expect(minutes(id)).toEqual([10]);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
// Only the occurrences leave the defaults: `useDefaultAlerts` is a property an override can patch.
|
||||
const patch = patchOf(plan).patch;
|
||||
expect(patch['recurrenceOverrides/2026-10-07T09:00:00/useDefaultAlerts']).toBe(false);
|
||||
expect(patch['recurrenceOverrides/2026-10-14T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
});
|
||||
|
||||
it('clears an occurrence\'s own colour instead of letting the series\' come back', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...event.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(h.row(x.eventId)[Events.EVENT_COLOR]).toBe(cssColorToArgb('red'));
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.EVENT_COLOR]: null });
|
||||
const local = (await h.local('bl'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
|
||||
// Without a colour of its own the occurrence would show the series' steelblue again: an empty one, which Stalwart keeps.
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': '' });
|
||||
const server = await accept(h, event, local, plan);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
|
||||
// A series without a colour has none to show: the occurrence's own is removed.
|
||||
const plain = weekly();
|
||||
delete plain.color;
|
||||
plain.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...plain.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
|
||||
const p = await synced(plain);
|
||||
const px = (await p.h.local('bl'))!.exceptions[0];
|
||||
p.h.fake.user.updateEvent(px.eventId, { [Events.EVENT_COLOR]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await p.h.local('bl'))!, p.h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': null });
|
||||
});
|
||||
|
||||
it('removes every reminder of one occurrence of a series on the calendar\'s defaults', async () => {
|
||||
const event = weekly();
|
||||
delete event.alerts;
|
||||
event.useDefaultAlerts = true;
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
useDefaultAlerts: false,
|
||||
alerts: { a60: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
const local = (await h.local('bl'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
|
||||
// Stalwart drops the empty map, but the series has no alerts of its own to show instead.
|
||||
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).not.toHaveProperty('alerts');
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [x.eventId])).toEqual([]);
|
||||
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('puts back and reports the removal of every reminder of one occurrence whose series would show its own again', async () => {
|
||||
const event = weekly();
|
||||
event.alerts = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } };
|
||||
const { h } = await synced(event);
|
||||
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(minutes(x.eventId)).toEqual([15]);
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
|
||||
// An override without alerts shows the series' (Stalwart writes no VALARM for an empty map): none can't be stored.
|
||||
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'remindersNotRepresentable' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(minutes(x.eventId)).toEqual([15]);
|
||||
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
|
||||
|
||||
// With another edit, that one goes up alone.
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again' });
|
||||
});
|
||||
|
||||
it('patches an existing override one level deep, never deeper', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again', [Events.EVENT_LOCATION]: 'Annex' });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again',
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locA: { name: 'Annex', '@type': 'Location' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers one instance (CONTENT_EXCEPTION_URI) with the override\'s whole participants map', async () => {
|
||||
const invite = weekly();
|
||||
invite.organizerCalendarAddress = 'mailto:boss@example.net';
|
||||
invite.participants = {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
other: { calendarAddress: 'mailto:other@example.net', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
const { h, id } = await synced(invite);
|
||||
h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 1, [Events.STATUS]: 1 });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const override = action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Record<string, unknown>;
|
||||
expect(override.participants).toEqual({ ...invite.participants, me: { ...invite.participants!.me, participationStatus: 'accepted' } });
|
||||
// Etar's STATUS_CONFIRMED on the instance is not the attendee's to upload: the series' status is kept.
|
||||
expect(override).toMatchObject({ status: 'tentative', start: '2026-10-19T09:00:00', duration: 'PT1H30M', title: 'Probe weekly' });
|
||||
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
|
||||
// An attendee's RSVP sends the REPLY.
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => {
|
||||
const invite = weekly();
|
||||
invite.calendarIds = { r: true };
|
||||
invite.organizerCalendarAddress = 'mailto:boss@example.net';
|
||||
invite.participants = {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
};
|
||||
const { h, id } = await synced(invite);
|
||||
// Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)...
|
||||
const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
// ... and another app answers the existing override of Oct 7 on its row.
|
||||
const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!;
|
||||
h.fake.table('attendees').forEach((a) => {
|
||||
if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1;
|
||||
});
|
||||
h.fake.user.updateEvent(existing.eventId, {});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
type Answer = { participants: Record<string, { participationStatus?: string }> };
|
||||
expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined');
|
||||
expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted');
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
// Anything more than the answer is still the calendar owner's to change.
|
||||
h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' });
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
});
|
||||
|
||||
it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': { excluded: true },
|
||||
});
|
||||
h.fake.user.updateEvent(id, { [Events.EXDATE]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-12T09:00:00': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads Etar\'s "this and following" as a capped rule, prunes later overrides, and creates the new series', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = {
|
||||
'2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
'2026-10-21T09:00:00': { title: 'Later one' },
|
||||
'2026-10-26T09:00:00': { excluded: true },
|
||||
};
|
||||
const { h, id } = await synced(event);
|
||||
// Split at Mon 2026-10-19 09:00 New York: UNTIL = the instance minus one second, in UTC.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' });
|
||||
const fresh = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Probe weekly (new time)',
|
||||
[Events.DTSTART]: utc('2026-10-19T14:00:00Z'),
|
||||
[Events.DURATION]: 'P5400S',
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;BYDAY=MO,WE',
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': null,
|
||||
'recurrenceOverrides/2026-10-26T09:00:00': null,
|
||||
});
|
||||
|
||||
// The new series is a new event: claimed with a fresh uid, then created.
|
||||
const newRow = await h.byRow(fresh);
|
||||
const claim = calendarPlanner.planUpload(newRow, h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const claimed = await h.byRow(fresh);
|
||||
expect(claimed.syncId).toMatch(/^~pending\//);
|
||||
expect(claimed.pending).toEqual({ uid: claimed.cells[Events.UID_2445], target: `${ACCT}/b` });
|
||||
const create = calendarPlanner.planUpload(claimed, h.ctx);
|
||||
expect(create.kind).toBe('upload');
|
||||
if (create.kind !== 'upload' || create.actions[0].kind !== 'create') throw new Error('no create');
|
||||
expect(create.actions[0]).toMatchObject({
|
||||
kind: 'create',
|
||||
uid: claimed.pending!.uid,
|
||||
collectionId: 'b',
|
||||
object: {
|
||||
'@type': 'Event',
|
||||
uid: claimed.pending!.uid,
|
||||
calendarIds: { b: true },
|
||||
title: 'Probe weekly (new time)',
|
||||
start: '2026-10-19T10:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
duration: 'PT1H30M',
|
||||
recurrenceRule: { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes overrides after a series Etar capped with a lower COUNT, using the provider\'s LAST_DATE', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
// COUNT 10 → 3 (Mon 5, Wed 7, Mon 12); the provider computed LAST_DATE = last start + duration.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE' });
|
||||
h.fake.table('events').get(id)![Events.LAST_DATE] = utc('2026-10-12T13:00:00Z') + 5_400_000;
|
||||
const event = weekly();
|
||||
event.recurrenceOverrides!['2026-10-14T09:00:00'] = { title: 'x' };
|
||||
const local = (await h.local('bl'))!;
|
||||
local.shadow = event;
|
||||
expect(patchOf(calendarPlanner.planUpload(local, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', count: 3, byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-14T09:00:00': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves every recurrence id with an "all events" start change', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
start: '2026-10-05T10:00:00',
|
||||
recurrenceOverrides: {
|
||||
'2026-10-12T10:00:00': { excluded: true },
|
||||
// Its own start was a move elsewhere: it stays.
|
||||
'2026-10-07T10:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a CONTENT_EXCEPTION_URI split: the clone becomes a new event, the source uploads its capped rule', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' }, '2026-10-21T09:00:00': { title: 'Later' } };
|
||||
const { h, id } = await synced(event);
|
||||
const clone = h.fake.user.splitViaUri(id, utc('2026-10-19T13:00:00Z'), '20261019T125959Z', { [Events.TITLE]: 'New series' });
|
||||
const all = await h.events();
|
||||
const source = all.find((e) => e.eventId === id)!;
|
||||
const cloneEvent = all.find((e) => e.eventId === clone)!;
|
||||
expect([source.split, cloneEvent.split]).toEqual(['source', 'clone']);
|
||||
expect(source.dirty).toBe(false);
|
||||
|
||||
// The source's rule was capped without DIRTY; it uploads as it stands.
|
||||
expect(patchOf(calendarPlanner.planUpload(source, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': null,
|
||||
});
|
||||
|
||||
// The clone is claimed under a fresh uid, drops the source's sync data and marks the source dirty.
|
||||
const claim = calendarPlanner.planUpload(cloneEvent, h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const after = await h.events();
|
||||
const claimed = after.find((e) => e.eventId === clone)!;
|
||||
expect(claimed.syncId).toMatch(/^~pending\//);
|
||||
expect(claimed.cells[Events.UID_2445]).not.toBe(event.uid);
|
||||
expect(claimed.shadow).toBeNull();
|
||||
expect(claimed.split).toBeUndefined();
|
||||
const sourceAfter = after.find((e) => e.eventId === id)!;
|
||||
expect(sourceAfter.dirty).toBe(true);
|
||||
// The source keeps its exceptions (and their link) whatever the provider's trigger did.
|
||||
expect(sourceAfter.exceptions.map((x) => x.cells[Events.ORIGINAL_SYNC_ID])).toEqual([`${ACCT}/bl`, `${ACCT}/bl`]);
|
||||
expect(patchOf(calendarPlanner.planUpload(sourceAfter, h.ctx)).patch.recurrenceRule).toMatchObject({ until: '2026-10-19T08:59:59' });
|
||||
});
|
||||
|
||||
it('keeps a local exception edit through a download that changed another instance', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
|
||||
const remote = weekly();
|
||||
remote.recurrenceOverrides!['2026-10-19T09:00:00'] = { title: 'Remote added' };
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((e) => [e.recurrenceId, e.cells[Events.TITLE], e.dirty])).toEqual([
|
||||
['2026-10-07T09:00:00', 'Local title', true],
|
||||
['2026-10-19T09:00:00', 'Remote added', false],
|
||||
]);
|
||||
expect(patchOf(calendarPlanner.planUpload(after, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Local title' });
|
||||
});
|
||||
|
||||
it('lets a server removal of an override win over a local edit of it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
|
||||
const remote = weekly();
|
||||
delete remote.recurrenceOverrides!['2026-10-07T09:00:00'];
|
||||
const plan = await h.download(remote);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.row(x.eventId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a device deletion of an instance win over a server edit of it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.deleteEvent(x.eventId);
|
||||
const remote = weekly();
|
||||
remote.recurrenceOverrides!['2026-10-07T09:00:00'] = { title: 'Remote title', start: '2026-10-07T11:00:00' };
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes an all-day series\' exceptions at UTC midnight and keys them by date', async () => {
|
||||
const event = single({
|
||||
id: 'ad',
|
||||
start: '2026-10-05T00:00:00',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
duration: 'P1D',
|
||||
recurrenceRule: { frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-10-06T00:00:00': { title: 'Day two' }, '2026-10-07T00:00:00': { excluded: true } },
|
||||
});
|
||||
const { h, id } = await synced(event);
|
||||
expect(h.row(id)).toMatchObject({ [Events.EXDATE]: '20261007', [Events.DURATION]: 'P1D' });
|
||||
const [x] = (await h.local('ad'))!.exceptions;
|
||||
expect(x.cells).toMatchObject({ [Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-06T00:00:00Z'), [Events.ORIGINAL_ALL_DAY]: 1, [Events.DTSTART]: utc('2026-10-06T00:00:00Z'), [Events.DTEND]: utc('2026-10-07T00:00:00Z') });
|
||||
// Etar cancels day four.
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-08T00:00:00Z'), utc('2026-10-09T00:00:00Z'));
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('ad'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-08T00:00:00': { excluded: true } });
|
||||
});
|
||||
|
||||
it('skips a series whose instance to change is a repeated wall time', async () => {
|
||||
const event = single({ id: 'n', start: '2026-10-20T02:30:00', recurrenceRule: { frequency: 'daily' } });
|
||||
const { h, id } = await synced(event);
|
||||
// Etar "this event" on the 25th: 02:30 happens twice that night in Berlin.
|
||||
h.fake.user.insertException(id, utc('2026-10-25T00:30:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('n'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
|
||||
});
|
||||
|
||||
it('moves an UNTIL that falls into a DST gap or overlap one hour later', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
// Until 2026-10-25 00:30Z = 02:30 CEST, a repeated wall time Stalwart would drop.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=DAILY;UNTIL=20261025T003000Z' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'daily', until: '2026-10-25T03:30:00' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the organizer and other attendees on the instance a pointer RSVP would have dropped', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.table('attendees').forEach((a) => {
|
||||
if (a.event_id === x.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 2;
|
||||
});
|
||||
h.fake.user.updateEvent(x.eventId, {});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-07T09:00:00/participants']);
|
||||
expect(Object.keys(action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as object)).toEqual(['me1', 'guestX']);
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Test harness for the calendar planner: the fake CalendarProvider, a
|
||||
* CalendarContext over a few server calendars, and helpers that plan, apply
|
||||
* the op groups the way the engine does and read the rows back.
|
||||
*/
|
||||
import { expect } from 'vitest';
|
||||
import { FakeDeviceProviders } from '../fakes/fake-provider';
|
||||
import { CALENDAR_AUTHORITY, type ProviderPort, type Row } from '../../types';
|
||||
import type { CalendarContext, LocalCalendar, LocalEvent, OpGroup } from '../../planner';
|
||||
import type { CalendarEventWire, CalendarLike } from '../../wire';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { applyPatch, type PatchObject } from '../../common/patch';
|
||||
import { makeKeyMinter, parseCollectionKey, uuidFrom } from '../../common/ids';
|
||||
import { Events } from '../../android-columns';
|
||||
|
||||
export const ACCOUNT = 'usera@example.org';
|
||||
export const ME = 'usera@example.org';
|
||||
export const ALIAS = 'alias@example.org';
|
||||
export const ACCT = 'c';
|
||||
|
||||
export function seeded(seed: number) {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
export const CALENDARS: Record<string, CalendarLike> = {
|
||||
b: {
|
||||
id: 'b',
|
||||
name: 'Personal',
|
||||
color: '#3b82f6',
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true, mayDelete: true },
|
||||
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M' }, action: 'display' } },
|
||||
defaultAlertsWithoutTime: { d2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15H' }, action: 'display' } },
|
||||
} as CalendarLike,
|
||||
w: { id: 'w', name: 'Work', color: '#22c55e', myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true } } as CalendarLike,
|
||||
r: { id: 'r', name: 'Team (read)', myRights: { mayReadItems: true, mayRSVP: true } } as CalendarLike,
|
||||
};
|
||||
|
||||
export interface HarnessOptions {
|
||||
deviceZone?: string;
|
||||
reminderOwner?: 'device' | 'bulwark';
|
||||
readOnly?: string[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export class Harness {
|
||||
readonly fake = new FakeDeviceProviders();
|
||||
readonly port: ProviderPort = this.fake.port(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
readonly rowIds = new Map<string, number>();
|
||||
ctx: CalendarContext;
|
||||
|
||||
constructor(options: HarnessOptions = {}) {
|
||||
const random = seeded(7);
|
||||
const readOnly = new Set(options.readOnly ?? ['r']);
|
||||
this.ctx = {
|
||||
jmapAccountId: ACCT,
|
||||
now: options.now ?? Date.parse('2026-09-27T12:00:00Z'),
|
||||
mintKey: makeKeyMinter(random),
|
||||
mintUid: () => uuidFrom(random),
|
||||
deviceZone: options.deviceZone ?? 'Europe/Berlin',
|
||||
ownerAccount: ME,
|
||||
selfAddresses: [ME, ALIAS],
|
||||
reminderOwner: options.reminderOwner ?? 'device',
|
||||
calendar: (id) => CALENDARS[id],
|
||||
calendarRowId: (id) => this.rowIds.get(id) ?? null,
|
||||
calendarIdOfRow: (rowId) => {
|
||||
for (const [id, row] of this.rowIds) if (row === rowId) return { jmapAccountId: ACCT, calendarId: id };
|
||||
return null;
|
||||
},
|
||||
isReadOnly: (id) => readOnly.has(id),
|
||||
isSelected: (key) => parseCollectionKey(key)?.accountId === ACCT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inserts the calendar rows through planCalendars. */
|
||||
async setup(ids: string[] = ['b', 'w', 'r']): Promise<this> {
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
ids.map((id) => ({ jmapAccountId: ACCT, calendar: CALENDARS[id], readOnly: this.ctx.isReadOnly(id), accountName: ME })),
|
||||
[],
|
||||
this.ctx,
|
||||
);
|
||||
for (const group of plan.groups) await this.apply(group);
|
||||
for (const cal of await this.calendars()) this.rowIds.set(parseCollectionKey(cal.syncId)!.id, cal.calendarRowId);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Applies a group and the groups after it (`next`), each in a batch of its own, as the engine does. */
|
||||
async apply(group: OpGroup): Promise<void> {
|
||||
for (const g of [group, ...(group.next ?? [])]) {
|
||||
const result = await this.port.applyBatch(g.ops);
|
||||
if (!result.ok) throw new Error(`batch ${group.ref} failed: ${result.reason} ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async tryApply(group: OpGroup) {
|
||||
return this.port.applyBatch(group.ops);
|
||||
}
|
||||
|
||||
async query(table: 'events' | 'attendees' | 'reminders' | 'calendars', columns: readonly string[]): Promise<Row[]> {
|
||||
const res = await this.port.query({ table, columns: [...columns] });
|
||||
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])));
|
||||
}
|
||||
|
||||
async calendars(): Promise<LocalCalendar[]> {
|
||||
return (await this.query('calendars', calendarPlanner.calendarColumns)).map(calendarPlanner.decodeCalendar);
|
||||
}
|
||||
|
||||
async events(): Promise<LocalEvent[]> {
|
||||
return calendarPlanner.decodeEvents(
|
||||
await this.query('events', calendarPlanner.eventColumns),
|
||||
await this.query('attendees', calendarPlanner.attendeeColumns),
|
||||
await this.query('reminders', calendarPlanner.reminderColumns),
|
||||
);
|
||||
}
|
||||
|
||||
/** The local event with this server id (or null). */
|
||||
async local(id: string): Promise<LocalEvent | null> {
|
||||
return (await this.events()).find((e) => e.syncId === `${ACCT}/${id}`) ?? null;
|
||||
}
|
||||
|
||||
async byRow(eventId: number): Promise<LocalEvent> {
|
||||
const all = await this.events();
|
||||
const found = all.find((e) => e.eventId === eventId);
|
||||
if (!found) throw new Error(`no event row ${eventId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Plans and applies a download of `event`, then heals baselines like the engine's read-back. */
|
||||
async download(event: CalendarEventWire) {
|
||||
const local = await this.local(event.id);
|
||||
const plan = calendarPlanner.planDownload(event, local, this.ctx);
|
||||
if (plan.effect !== 'none') await this.apply(plan.ops);
|
||||
await this.heal(event.id);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async heal(id: string): Promise<void> {
|
||||
const local = await this.local(id);
|
||||
if (!local) return;
|
||||
const heal = calendarPlanner.planBaselineHeal(local);
|
||||
if (heal) await this.apply(heal);
|
||||
}
|
||||
|
||||
row(eventId: number): Row {
|
||||
return this.fake.row('events', eventId)!;
|
||||
}
|
||||
|
||||
masterRow(id: string): Row {
|
||||
const rows = this.fake.rows('events', `${Events._SYNC_ID} = ?`, [`${ACCT}/${id}`]);
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
/** The server applying an accepted patch (Stalwart's normalisation of what matters here). */
|
||||
export function serverApply(event: CalendarEventWire, patch: PatchObject): CalendarEventWire {
|
||||
const next = applyPatch(event, patch);
|
||||
if (!next) throw new Error(`patch does not apply: ${JSON.stringify(patch)}`);
|
||||
const overrides = next.recurrenceOverrides;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
if ((value as Record<string, unknown>).excluded === true) {
|
||||
overrides[key] = { excluded: true };
|
||||
continue;
|
||||
}
|
||||
for (const forbidden of ['@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceRule', 'recurrenceOverrides']) {
|
||||
delete (value as Record<string, unknown>)[forbidden];
|
||||
}
|
||||
// An override is stored as its own VEVENT: an empty map writes nothing, so it reads back without it.
|
||||
for (const map of ['participants', 'alerts', 'locations']) {
|
||||
const inner = (value as Record<string, unknown>)[map];
|
||||
if (inner && typeof inner === 'object' && !Object.keys(inner).length) delete (value as Record<string, unknown>)[map];
|
||||
}
|
||||
}
|
||||
if (!Object.keys(overrides).length) delete next.recurrenceOverrides;
|
||||
}
|
||||
for (const map of ['participants', 'alerts', 'locations'] as const) {
|
||||
if (next[map] && !Object.keys(next[map] as object).length) delete next[map];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The probe's weekly event as Stalwart returned it (scratchpad/probe/calendar.out.txt). */
|
||||
export function weekly(): CalendarEventWire {
|
||||
return {
|
||||
id: 'bl',
|
||||
'@type': 'Event',
|
||||
uid: 'probe-rec@example.org',
|
||||
calendarIds: { b: true },
|
||||
title: 'Probe weekly',
|
||||
description: 'desc',
|
||||
start: '2026-10-05T09:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
duration: 'PT1H30M',
|
||||
status: 'tentative',
|
||||
freeBusyStatus: 'free',
|
||||
privacy: 'private',
|
||||
color: 'steelblue',
|
||||
keywords: { probe: true },
|
||||
priority: 5,
|
||||
recurrenceRule: { frequency: 'weekly', count: 10, firstDayOfWeek: 'mo', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
recurrenceOverrides: {
|
||||
'2026-10-12T09:00:00': { excluded: true },
|
||||
'2026-10-07T09:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
},
|
||||
locations: { locA: { name: 'Room 1', '@type': 'Location' } },
|
||||
participants: {
|
||||
me1: { calendarAddress: 'mailto:usera@example.org', '@type': 'Participant', participationStatus: 'accepted', roles: { owner: true, attendee: true } },
|
||||
guestX: { calendarAddress: 'mailto:guest@example.net', '@type': 'Participant', participationStatus: 'needs-action', name: 'Guest', expectReply: true, roles: { attendee: true } },
|
||||
},
|
||||
organizerCalendarAddress: 'mailto:usera@example.org',
|
||||
alerts: {
|
||||
al1: { action: 'display', '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
|
||||
alEnd: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'end' } },
|
||||
alAbs: { '@type': 'Alert', action: 'email', trigger: { '@type': 'AbsoluteTrigger', when: '2026-10-01T08:00:00Z' } },
|
||||
},
|
||||
updated: '2026-09-26T22:57:38Z',
|
||||
isDraft: false,
|
||||
} as CalendarEventWire;
|
||||
}
|
||||
|
||||
export function single(extra: Partial<CalendarEventWire> = {}): CalendarEventWire {
|
||||
return {
|
||||
id: 'e1',
|
||||
'@type': 'Event',
|
||||
uid: 'single-1@example.org',
|
||||
calendarIds: { b: true },
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT1H',
|
||||
updated: '2026-09-26T10:00:00Z',
|
||||
...extra,
|
||||
} as CalendarEventWire;
|
||||
}
|
||||
|
||||
export const utc = (iso: string) => Date.parse(iso);
|
||||
@@ -0,0 +1,694 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { LocalEvent, UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
async function synced(event: CalendarEventWire, options = {}) {
|
||||
const h = await new Harness(options).setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
function actionsOf(plan: Plan) {
|
||||
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${JSON.stringify(plan)}`);
|
||||
return plan.actions;
|
||||
}
|
||||
|
||||
/** Claims a new row, then returns its create action. */
|
||||
async function claimAndCreate(h: Harness, eventId: number) {
|
||||
const claim = calendarPlanner.planUpload(await h.byRow(eventId), h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const local = await h.byRow(eventId);
|
||||
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (create.kind !== 'create') throw new Error('not a create');
|
||||
return { local, create };
|
||||
}
|
||||
|
||||
describe('calendar planner: new events', () => {
|
||||
it('claims, creates and adopts a device event without writing anything on its echo', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(
|
||||
h.rowIds.get('b')!,
|
||||
{ [Events.TITLE]: 'Dentist', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.EVENT_LOCATION]: 'Main St 1', [Events.STATUS]: 1, [Events.ACCESS_LEVEL]: 2, [Events.AVAILABILITY]: 0 },
|
||||
{ reminders: [{ [Reminders.MINUTES]: 30, [Reminders.METHOD]: 1 }] },
|
||||
);
|
||||
const { local, create } = await claimAndCreate(h, id);
|
||||
expect(local.syncId).toBe(`~pending/${create.uid}`);
|
||||
expect(create).toMatchObject({ collectionId: 'b', uid: local.pending!.uid });
|
||||
expect(create.sendSchedulingMessages).toBeUndefined();
|
||||
const object = create.object as Record<string, unknown>;
|
||||
expect(object).toMatchObject({
|
||||
'@type': 'Event',
|
||||
uid: create.uid,
|
||||
calendarIds: { b: true },
|
||||
title: 'Dentist',
|
||||
start: '2026-10-09T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'private',
|
||||
});
|
||||
expect(Object.values(object.locations as object)).toEqual([{ '@type': 'Location', name: 'Main St 1' }]);
|
||||
expect(Object.values(object.alerts as object)).toEqual([{ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' }]);
|
||||
|
||||
// The server stores it; the engine re-fetches it and applies the accepted plan.
|
||||
const server = { ...(object as CalendarEventWire), id: 'n1', updated: '2026-09-27T12:00:00Z' } as CalendarEventWire;
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
const after = (await h.local('n1'))!;
|
||||
expect(after).toMatchObject({ syncId: `${ACCT}/n1`, dirty: false, pending: null });
|
||||
expect(after.cells[Events.UID_2445]).toBe(create.uid);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('creates a new series with the exceptions and exclusions made before its upload', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Standup',
|
||||
[Events.DTSTART]: utc('2026-10-05T07:00:00Z'),
|
||||
[Events.DURATION]: 'P900S',
|
||||
[Events.RRULE]: 'FREQ=DAILY;COUNT=5',
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
// Claimed first (the pending _SYNC_ID lets Etar offer "this event").
|
||||
const claim = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
h.fake.user.insertException(id, utc('2026-10-06T07:00:00Z'), { [Events.TITLE]: 'Standup (late)', [Events.DTSTART]: utc('2026-10-06T08:00:00Z'), [Events.DTEND]: utc('2026-10-06T08:15:00Z') });
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-07T07:00:00Z'), utc('2026-10-07T07:15:00Z'));
|
||||
const local = await h.byRow(id);
|
||||
expect(local.exceptions).toHaveLength(2);
|
||||
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (create.kind !== 'create') throw new Error('not a create');
|
||||
expect(create.object.recurrenceRule).toEqual({ frequency: 'daily', count: 5 });
|
||||
expect(create.object.recurrenceOverrides).toEqual({
|
||||
// The series' details a new override needs come along (withNewOverrideDetails).
|
||||
'2026-10-06T09:00:00': { title: 'Standup (late)', start: '2026-10-06T10:00:00', duration: 'PT15M', status: 'confirmed', freeBusyStatus: 'busy' },
|
||||
'2026-10-07T09:00:00': { excluded: true },
|
||||
});
|
||||
const server = { ...(create.object as CalendarEventWire), id: 's1' } as CalendarEventWire;
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
const after = (await h.local('s1'))!;
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/s1#2026-10-06T09:00:00`, false]]);
|
||||
expect(h.masterRow('s1')[Events.EXDATE]).toBe('20261007T070000Z');
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('creates an event an app left without a status (Google Calendar) without making it tentative', async () => {
|
||||
const h = await new Harness().setup();
|
||||
// CalendarProvider has no default for STATUS: an app that does not write it leaves it NULL.
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'From Google Calendar', [Events.DTSTART]: utc('2026-10-14T15:00:00Z'), [Events.DTEND]: utc('2026-10-14T16:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null,
|
||||
});
|
||||
const { local, create } = await claimAndCreate(h, id);
|
||||
expect(create.object).not.toHaveProperty('status');
|
||||
const server = { ...(create.object as CalendarEventWire), id: 'g1' } as CalendarEventWire;
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
// Written as JSCalendar's default, confirmed.
|
||||
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('g1'))!, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves out exclusions a new series inherited from before its start (Etar\'s split copies the EXDATE)', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Weekly (new time)',
|
||||
[Events.DTSTART]: utc('2026-11-16T09:00:00Z'),
|
||||
[Events.DURATION]: 'P3600S',
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO',
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
// The old series' exclusions (Oct 12, Nov 2) and one of this series (Nov 23).
|
||||
[Events.EXDATE]: '20261012T060000Z,20261102T070000Z,20261123T090000Z',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
expect(create.object.recurrenceOverrides).toEqual({ '2026-11-23T10:00:00': { excluded: true } });
|
||||
});
|
||||
|
||||
it('invites the attendees of a new event as its organizer', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(
|
||||
h.rowIds.get('b')!,
|
||||
{ [Events.TITLE]: 'Review', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 },
|
||||
{
|
||||
attendees: [
|
||||
{ [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_RELATIONSHIP]: 2, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 1 },
|
||||
{ [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_NAME]: 'Guest', [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 },
|
||||
],
|
||||
},
|
||||
);
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
expect(create.sendSchedulingMessages).toBe(true);
|
||||
const participants = Object.values(create.object.participants ?? {});
|
||||
expect(participants).toEqual([
|
||||
{ '@type': 'Participant', calendarAddress: `mailto:${ME}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
{ '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
|
||||
]);
|
||||
expect(create.object.organizerCalendarAddress).toBe(`mailto:${ME}`);
|
||||
});
|
||||
|
||||
it('gives a new event carrying another row\'s uid a fresh one', async () => {
|
||||
const { h } = await synced(single());
|
||||
const copy = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Copy', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'single-1@example.org', [Events.STATUS]: 1,
|
||||
});
|
||||
const local = await h.byRow(copy);
|
||||
expect(local.split).toBe('clone');
|
||||
const { create } = await claimAndCreate(h, copy);
|
||||
expect(create.uid).not.toBe('single-1@example.org');
|
||||
// A uid nobody else carries is kept (an imported invitation keeps its UID).
|
||||
const own = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Imported', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'ics-uid@example.net', [Events.STATUS]: 1,
|
||||
});
|
||||
expect((await claimAndCreate(h, own)).create.uid).toBe('ics-uid@example.net');
|
||||
});
|
||||
|
||||
it('adopts its own create found by uid on download, keeping later local edits', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'Draft', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
// The create reached the server, but the response was lost; meanwhile the user renamed it.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Final' });
|
||||
const server = { ...(create.object as CalendarEventWire), id: 'n7' } as CalendarEventWire;
|
||||
const plan = calendarPlanner.planDownload(server, await h.byRow(id), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
await h.apply(plan.ops);
|
||||
const adopted = (await h.local('n7'))!;
|
||||
expect(adopted).toMatchObject({ eventId: id, dirty: true, pending: null });
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(adopted, h.ctx));
|
||||
expect(update).toMatchObject({ kind: 'update', id: 'n7', patch: { title: 'Final' } });
|
||||
});
|
||||
|
||||
it('removes a new event created in a read-only calendar', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('r')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
|
||||
const plan = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: accepted uploads, merges and deletions', () => {
|
||||
it('keeps a row edited again during its upload dirty, with the uploaded value as its baseline', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'First' });
|
||||
const local = (await h.local('e1'))!;
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error();
|
||||
const server = serverApply(single(), update.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
// The user edits again before the clearing batch: its assert fails.
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Second' }));
|
||||
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
|
||||
await h.apply(accepted.keepDirtyOps);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after).toMatchObject({ dirty: true });
|
||||
expect(after.cells[Events.TITLE]).toBe('Second');
|
||||
expect(after.shadow?.title).toBe('First');
|
||||
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
expect(next).toMatchObject({ patch: { title: 'Second' } });
|
||||
// A revert to the old value uploads too.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Lunch' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Lunch' } });
|
||||
});
|
||||
|
||||
it('purges a cancelled instance whose exclusion was uploaded even when the series was edited meanwhile', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const cancelled = h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'));
|
||||
const local = (await h.local('bl'))!;
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error();
|
||||
const server = serverApply(weekly(), update.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Edited meanwhile' }));
|
||||
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
|
||||
await h.apply(accepted.keepDirtyOps);
|
||||
expect(h.row(cancelled)).toBeUndefined();
|
||||
const after = (await h.local('bl'))!;
|
||||
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
// Only the newer edit: the exclusion is on the server already and stays one.
|
||||
expect(next).toMatchObject({ kind: 'update', patch: { title: 'Edited meanwhile' } });
|
||||
});
|
||||
|
||||
it('fails a download write when the user edits the event meanwhile', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const plan = calendarPlanner.planDownload({ ...weekly(), title: 'Server' }, await h.local('bl'), h.ctx);
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'mine' }));
|
||||
const result = await h.tryApply(plan.ops);
|
||||
expect(result).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.row(id)[Events.TITLE]).toBe('Probe weekly');
|
||||
});
|
||||
|
||||
it('merges per unit: local and server changes to different fields both survive', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
|
||||
const plan = await h.download({ ...weekly(), description: 'Server description' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Local title', [Events.DESCRIPTION]: 'Server description', [Events.DIRTY]: 1 });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Local title' } });
|
||||
});
|
||||
|
||||
it('lets the server win a real conflict and counts it', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
|
||||
const plan = await h.download({ ...weekly(), title: 'Server title' });
|
||||
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false });
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Server title', [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('never reads a status an app left NULL as an edit, in uploads or merges', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
expect(h.row(id)[Events.STATUS]).toBe(0);
|
||||
// A row an app wrote without STATUS (only an insert can leave it NULL), then edited.
|
||||
h.fake.table('events').get(id)![Events.STATUS] = null;
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ kind: 'update', patch: { title: 'Renamed' } });
|
||||
expect(Object.keys((actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0] as { patch: object }).patch)).toEqual(['title']);
|
||||
// The server's status change meanwhile is no conflict: the row takes it, the title stays for the upload.
|
||||
const plan = await h.download({ ...weekly(), status: 'cancelled' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 2, [Events.TITLE]: 'Renamed', [Events.DIRTY]: 1 });
|
||||
});
|
||||
|
||||
it('treats the same change on both sides as converged', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Same' });
|
||||
const plan = await h.download({ ...weekly(), title: 'Same' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
|
||||
expect(h.row(id)[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
|
||||
it('merges attendees by address and keeps the user\'s RSVP', async () => {
|
||||
const invite = single({
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(invite);
|
||||
h.fake.user.setAttendeeStatus(id, ME, 1);
|
||||
const remote = { ...invite, participants: { ...invite.participants, new: { calendarAddress: 'mailto:new@example.net', roles: { attendee: true } } } } as CalendarEventWire;
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]);
|
||||
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
['boss@example.net', 3],
|
||||
[ME, 1],
|
||||
['new@example.net', 3],
|
||||
]);
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({
|
||||
patch: { 'participants/me/participationStatus': 'accepted' },
|
||||
sendSchedulingMessages: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('destroys an event deleted on the device, with a CANCEL when others take part', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.deleteEvent(id);
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))).toEqual([
|
||||
{ kind: 'destroy', id: 'bl', uid: 'probe-rec@example.org', sendSchedulingMessages: true },
|
||||
]);
|
||||
// Its exceptions go with it.
|
||||
const local = (await h.local('bl'))!;
|
||||
const group = calendarPlanner.planLocalDelete(local);
|
||||
await h.apply(group);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes only the synced membership of an event that is in several calendars', async () => {
|
||||
const h = await new Harness().setup(['b', 'w', 'r']);
|
||||
const isSelected = h.ctx.isSelected;
|
||||
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
|
||||
await h.download(single({ calendarIds: { b: true, x: true } }));
|
||||
h.fake.user.deleteEvent(Number(h.masterRow('e1')._id));
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null } }]);
|
||||
// The server keeps it elsewhere: the rows go.
|
||||
const accepted = calendarPlanner.planAccepted((await h.local('e1'))!, single({ calendarIds: { x: true } }), h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves an event the server moved out of every synced calendar before the deletion reached it, and only purges the rows', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
const isSelected = h.ctx.isSelected;
|
||||
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
|
||||
h.fake.user.deleteEvent(id);
|
||||
// The shadow follows the server although no synced calendar holds the event now.
|
||||
expect(await h.download(single({ calendarIds: { x: true } }))).toMatchObject({ stillDirty: true, effect: 'update' });
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
|
||||
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan.kind).toBe('purge');
|
||||
if (plan.kind === 'purge') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still destroys an event the server moved to another synced calendar, or whose calendar was deselected before the upload', async () => {
|
||||
const destroy = [{ kind: 'destroy', id: 'e1', uid: 'single-1@example.org' }];
|
||||
const moved = await synced(single());
|
||||
moved.h.fake.user.deleteEvent(moved.id);
|
||||
await moved.h.download(single({ calendarIds: { w: true } }));
|
||||
expect(actionsOf(calendarPlanner.planUpload((await moved.h.local('e1'))!, moved.h.ctx))).toEqual(destroy);
|
||||
|
||||
const deselected = await synced(single());
|
||||
deselected.h.fake.user.deleteEvent(deselected.id);
|
||||
deselected.h.ctx = { ...deselected.h.ctx, isSelected: () => false };
|
||||
expect(actionsOf(calendarPlanner.planUpload((await deselected.h.local('e1'))!, deselected.h.ctx))).toEqual(destroy);
|
||||
});
|
||||
|
||||
it('destroys a deleted event whose create had an unknown outcome by its uid', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
|
||||
const { local } = await claimAndCreate(h, id);
|
||||
h.fake.user.deleteEvent(id);
|
||||
// With the pending _SYNC_ID the app delete was a soft delete the engine can act on.
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
|
||||
expect(actionsOf(calendarPlanner.planUpload(await h.byRow(id), h.ctx))).toEqual([{ kind: 'destroy', id: null, uid: local.pending!.uid }]);
|
||||
});
|
||||
|
||||
it('keeps a device deletion when the server edited the event meanwhile', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const plan = await h.download(single({ title: 'Server edit' }));
|
||||
expect(plan).toMatchObject({ stillDirty: true, effect: 'update', writes: 1 });
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1, [Events.TITLE]: 'Lunch' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0].kind).toBe('destroy');
|
||||
});
|
||||
|
||||
it('purges an exception row whose master is gone instead of uploading it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const local = (await h.local('bl'))!;
|
||||
await h.apply({ ref: 'x', ops: [{ op: 'delete', table: 'events', id: local.eventId, expectCount: 1 }] });
|
||||
const [orphan] = await h.events();
|
||||
expect(orphan.cells[Events.ORIGINAL_SYNC_ID]).toBe(`${ACCT}/bl`);
|
||||
const plan = calendarPlanner.planUpload(orphan, h.ctx);
|
||||
expect(plan.kind).toBe('purge');
|
||||
if (plan.kind === 'purge') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses tasks everywhere', async () => {
|
||||
const { h } = await synced(single());
|
||||
const local = (await h.local('e1'))!;
|
||||
const task = { ...single(), '@type': 'Task' } as CalendarEventWire;
|
||||
expect(() => calendarPlanner.planAccepted(local, task, h.ctx)).toThrow(/tasks/);
|
||||
expect(() => calendarPlanner.planUpload({ ...local, shadow: task }, h.ctx)).toThrow(/tasks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: pairs', () => {
|
||||
it('uploads Etar\'s move to another calendar (delete + insert) as a calendarIds swap', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs).toHaveLength(1);
|
||||
expect(pairs[0].actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
|
||||
await h.apply(pairs[0].ops);
|
||||
const after = await h.events();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]).toMatchObject({ eventId: moved, syncId: `${ACCT}/e1`, dirty: false });
|
||||
expect(after[0].shadow?.calendarIds).toEqual({ w: true });
|
||||
// The server's echo writes nothing.
|
||||
expect(calendarPlanner.planDownload(single({ calendarIds: { w: true } }), after[0], h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('uploads a series turned into a single event by delete + insert as a rule removal', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]]);
|
||||
});
|
||||
|
||||
it('pairs a copy an app inserted without a status (Google Calendar\'s "Copy to" + delete) with the original', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
|
||||
});
|
||||
|
||||
it('does not pair unrelated rows', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { [Events.TITLE]: 'Something else', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
|
||||
const events = await h.events();
|
||||
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
/** Etar's move of `single()` (delete + insert into Work), with `extra` columns and children on the new row. */
|
||||
const lunchColumns = {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
};
|
||||
async function pairAndApply(h: Harness) {
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
|
||||
expect(pairs).toHaveLength(1);
|
||||
await h.apply(pairs[0].ops);
|
||||
return pairs[0];
|
||||
}
|
||||
const alert15 = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } } as CalendarEventWire['alerts'];
|
||||
|
||||
it('keeps a reminder saved with the move for the next upload instead of taking it as the baseline', async () => {
|
||||
const { h, id } = await synced(single({ alerts: alert15 }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { reminders: [{ [Reminders.MINUTES]: 60, [Reminders.METHOD]: 1 }] });
|
||||
|
||||
const pair = await pairAndApply(h);
|
||||
|
||||
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after).toMatchObject({ eventId: moved, dirty: true });
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error('not an update');
|
||||
// Only the reminder is left to send: the move went up with the pair.
|
||||
expect(Object.keys(update.patch).every((k) => k.startsWith('alerts/'))).toBe(true);
|
||||
const alerts = serverApply(after.shadow!, update.patch).alerts!;
|
||||
expect(Object.values(alerts).map((a) => (a.trigger as { offset?: string }).offset)).toEqual(['-PT1H']);
|
||||
});
|
||||
|
||||
it('uploads an edit made before the move, which the new row copied', async () => {
|
||||
const { h, id } = await synced(single({ description: 'Bring salad' }));
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Bring soup' });
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.DESCRIPTION]: 'Bring soup' });
|
||||
|
||||
await pairAndApply(h);
|
||||
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(true);
|
||||
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { description: 'Bring soup' } }]);
|
||||
});
|
||||
|
||||
it('takes attendee rows an app re-inserted with constant details as they are, uploading nothing for them', async () => {
|
||||
const participants = {
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.net', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
|
||||
carol: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', name: 'Carol', roles: { attendee: true }, participationStatus: 'declined' },
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
|
||||
// Etar writes every attendee it re-inserts as a required attendee without a status.
|
||||
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL], [Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0,
|
||||
}));
|
||||
const organizer = h.row(id)[Events.ORGANIZER];
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.ORGANIZER]: organizer }, { attendees });
|
||||
|
||||
await pairAndApply(h);
|
||||
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(calendarPlanner.planUpload({ ...after, dirty: true }, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
describe("Etar's move of a meeting (its copy leaves the user's own row out, or adds the user as organizer)", () => {
|
||||
const emailsOf = (h: Harness, eventId: number) =>
|
||||
h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [eventId]).map((a) => String(a[Attendees.ATTENDEE_EMAIL])).sort();
|
||||
const person = (address: string, extra: Record<string, unknown> = {}) => ({ '@type': 'Participant', calendarAddress: `mailto:${address}`, roles: { attendee: true }, ...extra });
|
||||
const moveToWork = { kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } };
|
||||
|
||||
it('pairs a meeting that lists the user', async () => {
|
||||
const participants = {
|
||||
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net', { name: 'Bob', participationStatus: 'accepted' }),
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
|
||||
|
||||
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
|
||||
|
||||
expect(emailsOf(h, moved)).toEqual(['bob@example.net']);
|
||||
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
|
||||
expect((await h.local('e1'))!).toMatchObject({ eventId: moved, dirty: false });
|
||||
});
|
||||
|
||||
it('pairs a meeting that does not list the user', async () => {
|
||||
const participants = {
|
||||
boss: person('boss@example.net', { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net', { participationStatus: 'needs-action' }),
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
|
||||
|
||||
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
|
||||
|
||||
expect(emailsOf(h, moved)).toEqual(['bob@example.net', 'boss@example.net', ME]);
|
||||
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
|
||||
});
|
||||
|
||||
it('never pairs a copy that lost or gained someone else', async () => {
|
||||
const participants = {
|
||||
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net'),
|
||||
carol: person('carol@example.net'),
|
||||
} as CalendarEventWire['participants'];
|
||||
const attendee = (email: string) => ({ [Attendees.ATTENDEE_EMAIL]: email, [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0 });
|
||||
for (const copied of [['bob@example.net'], ['bob@example.net', 'carol@example.net', 'dave@example.net']]) {
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { attendees: copied.map(attendee) });
|
||||
const events = await h.events();
|
||||
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx), copied.join()).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the timing of a series turned single against the series, so a start moved before uploads', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
// Moved an hour later in place, then turned into a single event (Etar: delete + insert).
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('b')!, { ...lunchColumns, [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
|
||||
|
||||
const pair = await pairAndApply(h);
|
||||
|
||||
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(true);
|
||||
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { start: '2026-10-06T13:00:00' } }]);
|
||||
|
||||
// Without the earlier edit, the single event matches what the server holds after the pair: nothing left.
|
||||
const plain = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
plain.h.fake.user.deleteEvent(plain.id);
|
||||
plain.h.fake.user.insertEvent(plain.h.rowIds.get('b')!, lunchColumns);
|
||||
await pairAndApply(plain.h);
|
||||
expect((await plain.h.local('e1'))!.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: baselines, zones and reminder owners', () => {
|
||||
it('heals a clean row\'s baseline after provider normalisation, but never a series\' timing or rule', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
// Provider-side changes without DIRTY (as CONTENT_EXCEPTION_URI's split does to the rule).
|
||||
const row = h.fake.table('events').get(id)!;
|
||||
row[Events.ORGANIZER] = 'someone@example.net';
|
||||
row[Events.RRULE] = 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE';
|
||||
const heal = calendarPlanner.planBaselineHeal((await h.local('bl'))!)!;
|
||||
expect(heal).not.toBeNull();
|
||||
await h.apply(heal);
|
||||
const baseline = (await h.local('bl'))!.baseline!;
|
||||
expect(baseline.cells[Events.ORGANIZER]).toBe('someone@example.net');
|
||||
expect(baseline.cells[Events.RRULE]).toBe('FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE');
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('bl'))!)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not heal a row that turned dirty', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'x' });
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites floating events for a new device zone, keeping their wall time', async () => {
|
||||
const { h, id } = await synced(single({ id: 'f', timeZone: null, start: '2026-10-06T14:00:00' }));
|
||||
expect(h.row(id)[Events.DTSTART]).toBe(utc('2026-10-06T12:00:00Z'));
|
||||
h.ctx = { ...h.ctx, deviceZone: 'America/New_York' };
|
||||
const group = calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)!;
|
||||
await h.apply(group);
|
||||
expect(h.row(id)).toMatchObject({ [Events.DTSTART]: utc('2026-10-06T18:00:00Z'), [Events.EVENT_TIMEZONE]: 'America/New_York' });
|
||||
expect(calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)).toBeNull();
|
||||
// Events with a zone are not floating.
|
||||
const t = await synced(single());
|
||||
expect(calendarPlanner.planZoneChange((await t.h.local('e1'))!, 'Europe/Berlin', { ...t.h.ctx, deviceZone: 'Asia/Tokyo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites Reminders rows when the reminder owner changes, without server writes', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(1);
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'bulwark' };
|
||||
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(0);
|
||||
expect(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)).toBeNull();
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'device' };
|
||||
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => r[Reminders.MINUTES])).toEqual([15]);
|
||||
// Back with the calendar app: an echo download writes nothing.
|
||||
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: decoding', () => {
|
||||
it('tolerates garbage in sync columns and numbers read back as text', async () => {
|
||||
const [event] = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{
|
||||
_id: '5', calendar_id: '1', _sync_id: 'c/x', dirty: '1', deleted: '0', sync_data1: '{broken', sync_data3: '{"uid":1}',
|
||||
sync_data4: '[]', sync_data5: 'nope', title: 'T', allDay: '0', dtstart: '1000', rrule: null, original_id: null, original_sync_id: null,
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(event).toMatchObject({ eventId: 5, calendarRowId: 1, syncId: 'c/x', dirty: true, deleted: false, shadow: null, pending: null, baseline: null, poison: null });
|
||||
});
|
||||
|
||||
it('groups exceptions by ORIGINAL_ID, else ORIGINAL_SYNC_ID, and returns orphans as masters', () => {
|
||||
const events = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{ _id: 1, calendar_id: 1, _sync_id: 'c/m', eventTimezone: 'Europe/Berlin', allDay: 0, dirty: 0, deleted: 0 },
|
||||
{ _id: 2, calendar_id: 1, _sync_id: null, original_id: 1, originalInstanceTime: utc('2026-10-07T07:00:00Z'), dirty: 1, deleted: 0 },
|
||||
{ _id: 3, calendar_id: 1, _sync_id: 'c/m#2026-10-08T09:00:00', original_sync_id: 'c/m', sync_data2: '2026-10-08T09:00:00', dirty: 0, deleted: 0 },
|
||||
{ _id: 4, calendar_id: 1, _sync_id: null, original_sync_id: 'c/gone', originalInstanceTime: 0, dirty: 1, deleted: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(events.map((e) => [e.eventId, e.exceptions.map((x) => [x.eventId, (x as { recurrenceId: string }).recurrenceId])])).toEqual([
|
||||
[1, [[2, '2026-10-07T09:00:00'], [3, '2026-10-08T09:00:00']]],
|
||||
[4, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks two masters sharing a _SYNC_ID as a split source and clone', () => {
|
||||
const events = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{ _id: 1, calendar_id: 1, _sync_id: 'c/m' },
|
||||
{ _id: 9, calendar_id: 1, _sync_id: 'c/m' },
|
||||
{ _id: 3, calendar_id: 1, _sync_id: 'c/n' },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(events.map((e: LocalEvent) => [e.eventId, e.split])).toEqual([
|
||||
[1, 'source'],
|
||||
[3, undefined],
|
||||
[9, 'clone'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { Harness, single, utc, weekly } from './harness';
|
||||
|
||||
describe('calendar planner: what the real providers hand back', () => {
|
||||
it('sees no edit in numbers read back as text', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
// Some provider columns come back through the bridge as text.
|
||||
const row = h.fake.table('events').get(id)!;
|
||||
for (const c of [Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL, Events.DTSTART, Events.ALL_DAY, Events.EVENT_COLOR, Events.CALENDAR_ID]) {
|
||||
row[c] = String(row[c]);
|
||||
}
|
||||
for (const a of h.fake.table('attendees').values()) {
|
||||
if (a.event_id === id) for (const c of [Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_RELATIONSHIP]) a[c] = String(a[c]);
|
||||
}
|
||||
for (const r of h.fake.table('reminders').values()) {
|
||||
if (r.event_id === id) for (const c of [Reminders.MINUTES, Reminders.METHOD]) r[c] = String(r[c]);
|
||||
}
|
||||
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
|
||||
h.fake.user.updateEvent(id, {});
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('asserts DIRTY as read, so a row inserted with a NULL DIRTY still takes a guarded write', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single());
|
||||
const id = Number(h.masterRow('e1')._id);
|
||||
h.fake.table('events').get(id)![Events.DIRTY] = null;
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(local.dirty).toBe(false);
|
||||
const plan = calendarPlanner.planDownload(single({ title: 'Changed' }), local, h.ctx);
|
||||
const assert = plan.ops.ops.find((op) => op.op === 'assert' && 'id' in op && op.id === id) as { values: Record<string, unknown> };
|
||||
expect(assert.values[Events.DIRTY]).toBeNull();
|
||||
await h.apply(plan.ops);
|
||||
expect(h.row(id)[Events.TITLE]).toBe('Changed');
|
||||
});
|
||||
|
||||
it('writes DIRTY = 0 on every row it inserts', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = calendarPlanner.planDownload(weekly(), null, h.ctx);
|
||||
const inserts = plan.ops.ops.filter((op) => op.op === 'insert' && op.table === 'events') as Array<{ values: Record<string, unknown> }>;
|
||||
expect(inserts).toHaveLength(2);
|
||||
for (const insert of inserts) expect(insert.values[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
|
||||
it('addresses attendee and reminder rows by id only', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = calendarPlanner.planDownload(
|
||||
{ ...weekly(), participants: { me1: weekly().participants!.me1 }, alerts: {} },
|
||||
await h.local('bl'),
|
||||
h.ctx,
|
||||
);
|
||||
for (const op of plan.ops.ops) {
|
||||
if ((op.op === 'update' || op.op === 'delete') && (op.table === 'attendees' || op.table === 'reminders')) {
|
||||
expect(op).toMatchObject({ expectCount: 1 });
|
||||
expect(typeof op.id).toBe('number');
|
||||
expect(op.where).toBeUndefined();
|
||||
}
|
||||
if ('where' in op && op.where) expect(op.where).not.toMatch(/\b_id\b/);
|
||||
}
|
||||
await h.apply(plan.ops);
|
||||
});
|
||||
|
||||
it('computes instance times exactly across a DST change of the series\' zone', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
|
||||
event.recurrenceOverrides = { '2026-11-02T09:00:00': { title: 'After the change' } };
|
||||
await h.download(event);
|
||||
const [x] = (await h.local('bl'))!.exceptions;
|
||||
// New York left DST on 1 November: 09:00 is 14:00Z from then on, not 13:00Z.
|
||||
expect(x.cells[Events.ORIGINAL_INSTANCE_TIME]).toBe(utc('2026-11-02T14:00:00Z'));
|
||||
expect(x.cells[Events.DTSTART]).toBe(utc('2026-11-02T14:00:00Z'));
|
||||
});
|
||||
|
||||
it('derives the recurrence id of an app\'s exception from its instance time in the master\'s zone', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
|
||||
event.recurrenceOverrides = {};
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
h.fake.user.insertException(id, utc('2026-11-02T14:00:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-11-02T14:00:00Z'), [Events.DTEND]: utc('2026-11-02T15:30:00Z') });
|
||||
expect((await h.local('bl'))!.exceptions[0].recurrenceId).toBe('2026-11-02T09:00:00');
|
||||
});
|
||||
|
||||
it('ignores an EXRULE or RDATE an app wrote', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
h.fake.user.updateEvent(id, { [Events.EXRULE]: 'FREQ=MONTHLY' });
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RecurrenceRule } from '../../../api/types';
|
||||
import {
|
||||
canonicalRRule,
|
||||
isRuleRepresentable,
|
||||
parseUntil,
|
||||
ruleToRRule,
|
||||
rruleToRule,
|
||||
unrepresentableParts,
|
||||
untilUtc,
|
||||
} from '../../calendar/rrule';
|
||||
import { aospRruleValid } from '../fakes/fake-provider';
|
||||
import { exdateSet, formatExdate, parseExdate } from '../../calendar/exdate';
|
||||
|
||||
const timed = { allDay: false, zone: 'Europe/Berlin' };
|
||||
const allDay = { allDay: true, zone: 'UTC' };
|
||||
|
||||
describe('calendar/rrule', () => {
|
||||
it.each<[string, RecurrenceRule, string]>([
|
||||
['daily', { frequency: 'daily' }, 'FREQ=DAILY'],
|
||||
['interval', { frequency: 'weekly', interval: 2 }, 'FREQ=WEEKLY;INTERVAL=2'],
|
||||
['count', { frequency: 'daily', count: 10 }, 'FREQ=DAILY;COUNT=10'],
|
||||
['byDay', { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }, 'FREQ=WEEKLY;BYDAY=MO,WE'],
|
||||
['nth weekday', { frequency: 'monthly', byDay: [{ day: 'fr', nthOfPeriod: -1 }, { day: 'tu', nthOfPeriod: 2 }] }, 'FREQ=MONTHLY;BYDAY=-1FR,2TU'],
|
||||
['byMonthDay', { frequency: 'monthly', byMonthDay: [1, 15, -1] }, 'FREQ=MONTHLY;BYMONTHDAY=1,15,-1'],
|
||||
['byMonth', { frequency: 'yearly', byMonth: ['3', '10'] }, 'FREQ=YEARLY;BYMONTH=3,10'],
|
||||
['byYearDay', { frequency: 'yearly', byYearDay: [1, -1] }, 'FREQ=YEARLY;BYYEARDAY=1,-1'],
|
||||
['byWeekNo', { frequency: 'yearly', byWeekNo: [20], byDay: [{ day: 'mo' }] }, 'FREQ=YEARLY;BYDAY=MO;BYWEEKNO=20'],
|
||||
['byHour/Minute/Second', { frequency: 'daily', byHour: [9, 17], byMinute: [0, 30], bySecond: [0] }, 'FREQ=DAILY;BYSECOND=0;BYMINUTE=0,30;BYHOUR=9,17'],
|
||||
['bySetPosition', { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] }, 'FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1'],
|
||||
['wkst', { frequency: 'weekly', interval: 2, firstDayOfWeek: 'su' }, 'FREQ=WEEKLY;INTERVAL=2;WKST=SU'],
|
||||
])('writes and reads back %s', (_name, rule, rrule) => {
|
||||
const written = ruleToRRule(rule, timed)!;
|
||||
expect(written).toBe(rrule);
|
||||
expect(aospRruleValid(written)).toBe(true);
|
||||
expect(rruleToRule(written, timed)).toEqual(rule);
|
||||
});
|
||||
|
||||
it('leaves out the defaults: INTERVAL=1, WKST=MO, rscale gregorian, skip omit', () => {
|
||||
expect(ruleToRRule({ frequency: 'weekly', interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' }, timed)).toBe('FREQ=WEEKLY');
|
||||
});
|
||||
|
||||
it('writes UNTIL as UTC for timed rules, converted from the event zone, and as a date for all-day rules', () => {
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261030T080000Z');
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-07-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20260730T070000Z');
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T23:59:59' }, allDay)).toBe('FREQ=DAILY;UNTIL=20261030');
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', until: '2026-10-30T09:00:00' });
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030', allDay)).toEqual({ frequency: 'daily', until: '2026-10-30T00:00:00' });
|
||||
});
|
||||
|
||||
it('converts UNTIL across DST edges', () => {
|
||||
// New York spring gap: 02:30 does not exist, UNTIL moves forward like the instance does.
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-03-08T02:30:00' }, { allDay: false, zone: 'America/New_York' })).toBe(
|
||||
'FREQ=DAILY;UNTIL=20260308T073000Z',
|
||||
);
|
||||
// Berlin autumn overlap: the first instant.
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261025T003000Z');
|
||||
expect(untilUtc({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe(Date.parse('2026-10-25T00:30:00Z'));
|
||||
// Sydney's autumn overlap.
|
||||
expect(ruleToRRule({ frequency: 'weekly', until: '2026-04-05T02:30:00' }, { allDay: false, zone: 'Australia/Sydney' })).toBe(
|
||||
'FREQ=WEEKLY;UNTIL=20260404T153000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads rules the way device apps write them', () => {
|
||||
expect(rruleToRule('rrule:freq=weekly;byday=mo,we;x-fossify=1', timed)).toEqual({ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] });
|
||||
// Etar writes all-day UNTIL as a UTC date-time (the instance start minus 1 s).
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261015T235959Z', allDay)).toEqual({ frequency: 'daily', until: '2026-10-15T00:00:00' });
|
||||
// A floating UNTIL is UTC to AOSP, and so is a DATE on a timed rule.
|
||||
expect(parseUntil('20261030T080000', timed)).toBe('2026-10-30T09:00:00');
|
||||
expect(parseUntil('20261030', timed)).toBe('2026-10-30T01:00:00');
|
||||
// COUNT wins over UNTIL next to it.
|
||||
expect(rruleToRule('FREQ=DAILY;COUNT=5;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', count: 5 });
|
||||
// Several rules: the first one.
|
||||
expect(rruleToRule('FREQ=DAILY\nFREQ=WEEKLY', timed)).toEqual({ frequency: 'daily' });
|
||||
expect(rruleToRule('INTERVAL=2', timed)).toBeNull();
|
||||
expect(rruleToRule('FREQ=FORTNIGHTLY', timed)).toBeNull();
|
||||
expect(rruleToRule(null, timed)).toBeNull();
|
||||
});
|
||||
|
||||
it('tells rules AOSP cannot hold', () => {
|
||||
expect(unrepresentableParts({ frequency: 'yearly', rscale: 'chinese' })).toEqual(['rscale']);
|
||||
expect(unrepresentableParts({ frequency: 'monthly', skip: 'forward' })).toEqual(['skip']);
|
||||
expect(unrepresentableParts({ frequency: 'yearly', byMonth: ['5L'] })).toEqual(['byMonth']);
|
||||
expect(isRuleRepresentable({ frequency: 'yearly', rscale: 'GREGORIAN', skip: 'omit' })).toBe(true);
|
||||
// Never RSCALE or SKIP; a leap month is dropped, not written.
|
||||
const written = ruleToRRule({ frequency: 'yearly', rscale: 'chinese', skip: 'forward', byMonth: ['5L', '6'] }, timed)!;
|
||||
expect(written).toBe('FREQ=YEARLY;BYMONTH=6');
|
||||
expect(aospRruleValid(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('compares rules by meaning', () => {
|
||||
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=WE,MO;INTERVAL=1;WKST=MO')).toBe(canonicalRRule('freq=weekly;byday=mo,we'));
|
||||
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=MO')).not.toBe(canonicalRRule('FREQ=WEEKLY;BYDAY=TU'));
|
||||
expect(canonicalRRule(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar/exdate', () => {
|
||||
it('writes excluded instances as UTC date-times, or dates for all-day events', () => {
|
||||
expect(formatExdate(['2026-10-14T09:00:00', '2026-10-12T09:00:00'], timed)).toBe('20261012T070000Z,20261014T070000Z');
|
||||
expect(formatExdate(['2026-10-12T00:00:00'], allDay)).toBe('20261012');
|
||||
expect(formatExdate([], timed)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads every form back into recurrence ids in the event zone', () => {
|
||||
expect(parseExdate('20261012T070000Z,20261014T070000Z', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
|
||||
// A zone prefix, a floating time without one (UTC), several lines.
|
||||
expect(parseExdate('Europe/Berlin;20261012T090000\n20261014T070000', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
|
||||
expect(parseExdate('20261012,20261013T000000Z', allDay)).toEqual(['2026-10-12T00:00:00', '2026-10-13T00:00:00']);
|
||||
expect(parseExdate('garbage,20261012T070000Z', timed)).toEqual(['2026-10-12T09:00:00']);
|
||||
expect(parseExdate(null, timed)).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares columns by the instants they name', () => {
|
||||
expect([...exdateSet('Europe/Berlin;20261012T090000')]).toEqual(['20261012T070000Z']);
|
||||
expect([...exdateSet('20261012T000000Z', true)]).toEqual(['20261012']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import type { Row } from '../../types';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
function updateOf(plan: Plan) {
|
||||
expect(plan.kind).toBe('upload');
|
||||
if (plan.kind !== 'upload') throw new Error('not an upload');
|
||||
expect(plan.actions).toHaveLength(1);
|
||||
const action = plan.actions[0];
|
||||
if (action.kind !== 'update') throw new Error(`not an update: ${action.kind}`);
|
||||
return action;
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire, options = {}) {
|
||||
const h = await new Harness(options).setup();
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow(event.id)._id);
|
||||
return { h, id };
|
||||
}
|
||||
|
||||
const FIXTURES: Array<[string, () => CalendarEventWire]> = [
|
||||
['a recurring event with overrides', weekly],
|
||||
['a single timed event', () => single()],
|
||||
['a floating event', () => single({ id: 'f', timeZone: null })],
|
||||
['an all-day event', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P3D' })],
|
||||
['a recurring all-day event', () => single({ id: 'y', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } })],
|
||||
['an HTML description and a CSS colour name', () => single({ id: 'h', description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html', color: 'rebeccapurple' })],
|
||||
];
|
||||
|
||||
describe('calendar planner: uploads of edited events', () => {
|
||||
it.each(FIXTURES)('round-trips %s: rows untouched → clean', async (_name, make) => {
|
||||
const event = make();
|
||||
const { h, id } = await synced(event);
|
||||
// An app saving the event without changing anything.
|
||||
h.fake.user.updateEvent(id, {});
|
||||
const plan = calendarPlanner.planUpload((await h.local(event.id))!, h.ctx);
|
||||
expect(plan.kind).toBe('clean');
|
||||
if (plan.kind !== 'clean') return;
|
||||
await h.apply(plan.ops);
|
||||
expect(h.row(id)[Events.DIRTY]).toBe(0);
|
||||
// And the next download of the same event writes nothing.
|
||||
expect(calendarPlanner.planDownload(event, await h.local(event.id), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it.each<[string, Row, Record<string, unknown>]>([
|
||||
['title', { [Events.TITLE]: 'New title' }, { title: 'New title' }],
|
||||
['description', { [Events.DESCRIPTION]: 'New text' }, { description: 'New text' }],
|
||||
['a cleared description', { [Events.DESCRIPTION]: '' }, { description: null }],
|
||||
['location', { [Events.EVENT_LOCATION]: 'Room 2' }, { 'locations/locA/name': 'Room 2' }],
|
||||
['a cleared location', { [Events.EVENT_LOCATION]: null }, { 'locations/locA': null }],
|
||||
['status', { [Events.STATUS]: 1 }, { status: 'confirmed' }],
|
||||
['availability', { [Events.AVAILABILITY]: 0 }, { freeBusyStatus: 'busy' }],
|
||||
['tentative availability (busy)', { [Events.AVAILABILITY]: 2 }, { freeBusyStatus: 'busy' }],
|
||||
['privacy', { [Events.ACCESS_LEVEL]: 3 }, { privacy: 'public' }],
|
||||
['default privacy', { [Events.ACCESS_LEVEL]: 0 }, { privacy: null }],
|
||||
['colour', { [Events.EVENT_COLOR]: cssColorToArgb('#ff8800') }, { color: '#ff8800' }],
|
||||
['duration', { [Events.DURATION]: 'P7200S' }, { duration: 'PT2H' }],
|
||||
])('uploads one edited field of a series as a patch of only that field: %s', async (_name, values, patch) => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, values);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.id).toBe('bl');
|
||||
expect(action.patch).toEqual(patch);
|
||||
});
|
||||
|
||||
it('adds the first location as a whole map', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Café' });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
const [[key, value]] = Object.entries(action.patch.locations as Record<string, unknown>);
|
||||
expect(Object.keys(action.patch)).toEqual(['locations']);
|
||||
expect(key).toMatch(/^b[0-9a-z]{8}$/);
|
||||
expect(value).toEqual({ '@type': 'Location', name: 'Café' });
|
||||
});
|
||||
|
||||
it('uploads an HTML description as text/plain only when it was edited', async () => {
|
||||
const { h, id } = await synced(single({ description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html' }));
|
||||
expect(h.row(id)[Events.DESCRIPTION]).toBe('Hi there');
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Hi there!' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
|
||||
description: 'Hi there!',
|
||||
descriptionContentType: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('never uploads an edit of a description that was written truncated', async () => {
|
||||
const long = 'x'.repeat(70_000);
|
||||
const { h, id } = await synced(single({ description: long }));
|
||||
expect(String(h.row(id)[Events.DESCRIPTION]).length).toBe(65_536);
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'short now', [Events.TITLE]: 'T' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'T' });
|
||||
});
|
||||
|
||||
it('moves a single event: start in its zone, the length unchanged', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T12:30:00Z'), [Events.DTEND]: utc('2026-10-06T13:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T14:30:00' });
|
||||
});
|
||||
|
||||
it('keeps a floating event floating, and takes a zone the user picked', async () => {
|
||||
const { h, id } = await synced(single({ id: 'f', timeZone: null }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T13:00:00' });
|
||||
h.fake.user.updateEvent(id, { [Events.EVENT_TIMEZONE]: 'Asia/Tokyo' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T20:00:00', timeZone: 'Asia/Tokyo' });
|
||||
});
|
||||
|
||||
it('keeps all-day events dates and turns timed ones into all-day ones', async () => {
|
||||
const { h, id } = await synced(single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-11T00:00:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('a'))!, h.ctx)).patch).toEqual({ start: '2026-10-09T00:00:00', duration: 'P2D' });
|
||||
|
||||
const t = await synced(single());
|
||||
t.h.fake.user.updateEvent(t.id, {
|
||||
[Events.ALL_DAY]: 1,
|
||||
[Events.EVENT_TIMEZONE]: 'UTC',
|
||||
[Events.DTSTART]: utc('2026-10-06T00:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-07T00:00:00Z'),
|
||||
});
|
||||
expect(updateOf(calendarPlanner.planUpload((await t.h.local('e1'))!, t.h.ctx)).patch).toEqual({
|
||||
start: '2026-10-06T00:00:00',
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a single event whose new start is a repeated wall time in a fixed-offset zone', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
// 02:30 CEST on the night the clocks go back: Stalwart would drop it in Europe/Berlin.
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-2' });
|
||||
// The second 02:30 (CET) is the same wall time an hour later.
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T01:30:00Z'), [Events.DTEND]: utc('2026-10-25T02:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-1' });
|
||||
});
|
||||
|
||||
it('skips a series whose new start is a repeated wall time', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
|
||||
});
|
||||
|
||||
it('never uploads the timing of a rule Android cannot represent', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'yearly', rscale: 'chinese', byMonth: ['5L'] } });
|
||||
const { h, id } = await synced(event);
|
||||
expect(h.row(id)[Events.RRULE]).toBe('FREQ=YEARLY');
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'ruleNotRepresentable' });
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Festival' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'Festival' });
|
||||
});
|
||||
|
||||
it('uploads the user\'s RSVP with scheduling messages', async () => {
|
||||
const event = single({
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.setAttendeeStatus(id, ME, 1);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(action).toMatchObject({ patch: { 'participants/me/participationStatus': 'accepted' }, sendSchedulingMessages: true });
|
||||
});
|
||||
|
||||
it('adds and removes attendees; the organizer notifies them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const guest = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [id, 'guest@example.net'])[0];
|
||||
h.fake.user.removeAttendee(Number(guest._id));
|
||||
h.fake.user.addAttendee(id, {
|
||||
[Attendees.ATTENDEE_EMAIL]: 'new@example.net',
|
||||
[Attendees.ATTENDEE_NAME]: 'New',
|
||||
[Attendees.ATTENDEE_TYPE]: 2,
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
|
||||
[Attendees.ATTENDEE_STATUS]: 3,
|
||||
});
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
const added = Object.entries(action.patch).find(([k, v]) => k.startsWith('participants/') && v);
|
||||
expect(added![1]).toEqual({
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:new@example.net',
|
||||
name: 'New',
|
||||
roles: { attendee: true, optional: true },
|
||||
participationStatus: 'needs-action',
|
||||
expectReply: true,
|
||||
});
|
||||
expect(action.patch['participants/guestX']).toBeNull();
|
||||
});
|
||||
|
||||
it('sends the whole participants map for the first attendee', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['participants']);
|
||||
expect(Object.values(action.patch.participants as object)).toHaveLength(1);
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces the reminders it can show, keeping the matching keys and the others', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.setReminders(id, [{ minutes: 15 }, { minutes: 60, method: 2 }]);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const keys = Object.keys(action.patch);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0]).toMatch(/^alerts\/b[0-9a-z]{8}$/);
|
||||
expect(action.patch[keys[0]]).toEqual({ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'email' });
|
||||
// Reminders alone notify nobody.
|
||||
expect(action.sendSchedulingMessages).toBeUndefined();
|
||||
|
||||
h.fake.user.setReminders(id, []);
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'alerts/al1': null });
|
||||
});
|
||||
|
||||
it('switches off the calendar defaults when reminders of an event that used them change', async () => {
|
||||
const { h, id } = await synced(single({ useDefaultAlerts: true }));
|
||||
h.fake.user.setReminders(id, [{ minutes: 10 }, { minutes: 30 }]);
|
||||
const patch = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch;
|
||||
expect(patch.useDefaultAlerts).toBe(false);
|
||||
const alerts = patch.alerts as Record<string, { trigger: { offset: string } }>;
|
||||
// The default's key is kept for the reminder that matches it.
|
||||
expect(alerts.d1.trigger.offset).toBe('-PT10M');
|
||||
expect(Object.values(alerts).map((a) => a.trigger.offset).sort()).toEqual(['-PT10M', '-PT30M']);
|
||||
});
|
||||
|
||||
it('does not upload reminders when Bulwark owns them', async () => {
|
||||
const { h, id } = await synced(weekly(), { reminderOwner: 'bulwark' });
|
||||
h.fake.user.setReminders(id, [{ minutes: 5 }]);
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('ignores attendee types and relationships a lossy editor rewrote, and merges its rule part by part', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1], count: 10 };
|
||||
const { h, id } = await synced(event);
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
|
||||
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
|
||||
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
|
||||
[Attendees.ATTENDEE_TYPE]: 1,
|
||||
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
|
||||
}));
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => ({ minutes: Number(r[Reminders.MINUTES]) }));
|
||||
// Fossify rebuilds the rule without BYSETPOS and changes the count.
|
||||
h.fake.user.fossifySaveEvent(id, { [Events.RRULE]: 'FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU' }, attendees, reminders);
|
||||
h.fake.table('events').get(id)![Events.MUTATORS] = 'org.fossify.calendar';
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
recurrenceRule: { frequency: 'monthly', count: 12, byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a series into a single event in place', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: null, [Events.DURATION]: null, [Events.DTEND]: utc('2026-10-05T14:30:00Z') });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({ recurrenceRule: null, recurrenceOverrides: null });
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('moves an event between calendars of its account by swapping calendarIds', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.CALENDAR_ID]: h.rowIds.get('w')! });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'calendarIds/b': null, 'calendarIds/w': true });
|
||||
});
|
||||
|
||||
it('reverts edits in a read-only calendar, but lets an RSVP through', async () => {
|
||||
const event = single({
|
||||
calendarIds: { r: true },
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine now' });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
|
||||
|
||||
h.fake.user.setAttendeeStatus(id, ME, 2);
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'participants/me/participationStatus': 'declined' });
|
||||
|
||||
// Without mayRSVP, the answer is put back too, and says why.
|
||||
const noRsvp = { ...h.ctx, calendar: (cid: string) => (cid === 'r' ? { ...h.ctx.calendar(cid)!, myRights: { mayReadItems: true } } : h.ctx.calendar(cid)) };
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, noRsvp)).toMatchObject({ kind: 'revert', reason: 'rsvpRefused' });
|
||||
});
|
||||
|
||||
it('skips a poisoned item until it changes or the back-off ends', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Bad' });
|
||||
const local = (await h.local('e1'))!;
|
||||
const { poisonFingerprint, poisonOps } = await import('../../calendar/planner');
|
||||
const { nextMarker } = await import('../../engine/poison');
|
||||
// The marker as the engine writes it after an invalidProperties SetError.
|
||||
const marker = nextMarker(null, poisonFingerprint(local), { type: 'invalidProperties' }, h.ctx.now, { firstMs: 3_600_000, maxMs: 86_400_000 });
|
||||
await h.apply(poisonOps(local, marker));
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'invalidProperties' });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, { ...h.ctx, now: h.ctx.now + 7_200_000 }).kind).toBe('upload');
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Better' });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('upload');
|
||||
});
|
||||
|
||||
it('applies a patch the server accepts (round trip through serverApply)', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed', [Events.EVENT_LOCATION]: 'Hall' });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const server = serverApply(weekly(), action.patch);
|
||||
expect(server.title).toBe('Renamed');
|
||||
expect(server.locations).toEqual({ locA: { name: 'Hall', '@type': 'Location' } });
|
||||
expect(server.participants).toEqual(weekly().participants);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalZone,
|
||||
classifyLocal,
|
||||
dateToUtcMidnight,
|
||||
fixedOffsetZone,
|
||||
isValidTimeZone,
|
||||
localToUtc,
|
||||
offsetAt,
|
||||
parseLocalDateTime,
|
||||
utcMidnightToDate,
|
||||
utcToLocal,
|
||||
} from '../../calendar/zoned-time';
|
||||
|
||||
const utc = (iso: string) => Date.parse(iso);
|
||||
|
||||
describe('calendar/zoned-time', () => {
|
||||
it('reads LocalDateTimes like Stalwart: fraction and suffix ignored, impossible dates refused', () => {
|
||||
expect(parseLocalDateTime('2026-10-25T02:30:00')).toEqual({ year: 2026, month: 10, day: 25, hour: 2, minute: 30, second: 0 });
|
||||
expect(parseLocalDateTime('2026-10-25T02:30:00.250Z')).toMatchObject({ hour: 2, minute: 30, second: 0 });
|
||||
expect(parseLocalDateTime('2026-10-25')).toMatchObject({ hour: 0, minute: 0 });
|
||||
expect(parseLocalDateTime('2026-02-30T00:00:00')).toBeNull();
|
||||
expect(parseLocalDateTime('2026-01-01T24:00:00')).toBeNull();
|
||||
expect(parseLocalDateTime('garbage')).toBeNull();
|
||||
});
|
||||
|
||||
// R3 B1: the app's localDateTimeToInstant gets these wrong in one direction or the other.
|
||||
it.each([
|
||||
['America/New_York', '2026-03-08T02:30:00', 'gap', '2026-03-08T07:30:00Z'],
|
||||
['America/New_York', '2026-11-01T01:30:00', 'overlap', '2026-11-01T05:30:00Z'],
|
||||
['Europe/Berlin', '2026-03-29T02:30:00', 'gap', '2026-03-29T01:30:00Z'],
|
||||
['Europe/Berlin', '2026-10-25T02:30:00', 'overlap', '2026-10-25T00:30:00Z'],
|
||||
['Australia/Sydney', '2026-04-05T02:30:00', 'overlap', '2026-04-04T15:30:00Z'],
|
||||
['Australia/Sydney', '2026-10-04T02:30:00', 'gap', '2026-10-03T16:30:00Z'],
|
||||
['Australia/Lord_Howe', '2026-10-04T02:15:00', 'gap', '2026-10-03T15:45:00Z'],
|
||||
['Europe/Berlin', '2026-10-25T04:30:00', 'ok', '2026-10-25T03:30:00Z'],
|
||||
['Asia/Kolkata', '2026-06-01T10:00:00', 'ok', '2026-06-01T04:30:00Z'],
|
||||
])('resolves %s %s (%s) with RFC 5545 semantics', (zone, local, kind, expected) => {
|
||||
expect(classifyLocal(local, zone)).toBe(kind);
|
||||
expect(new Date(localToUtc(local, zone)!).toISOString().replace('.000', '')).toBe(expected);
|
||||
});
|
||||
|
||||
it('moves a gap time forward by the gap and keeps the wall time of an overlap', () => {
|
||||
expect(utcToLocal(localToUtc('2026-03-29T02:30:00', 'Europe/Berlin')!, 'Europe/Berlin')).toBe('2026-03-29T03:30:00');
|
||||
expect(utcToLocal(localToUtc('2026-03-08T02:30:00', 'America/New_York')!, 'America/New_York')).toBe('2026-03-08T03:30:00');
|
||||
// Both instants of the repeated hour read back as the same wall time.
|
||||
expect(utcToLocal(utc('2026-10-25T00:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
|
||||
expect(utcToLocal(utc('2026-10-25T01:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
|
||||
});
|
||||
|
||||
it('does not depend on the zone the process runs in', () => {
|
||||
// The device's own gap day is no special case: fields are parsed, not handed to Date.
|
||||
expect(localToUtc('2026-03-29T02:30:00', 'UTC')).toBe(utc('2026-03-29T02:30:00Z'));
|
||||
expect(offsetAt(utc('2026-07-01T00:00:00Z'), 'Europe/Berlin')).toBe(7_200_000);
|
||||
expect(offsetAt(utc('2026-01-01T00:00:00Z'), 'America/New_York')).toBe(-18_000_000);
|
||||
});
|
||||
|
||||
it('validates zones with Intl and names them canonically', () => {
|
||||
expect(isValidTimeZone('Europe/Berlin')).toBe(true);
|
||||
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false);
|
||||
expect(isValidTimeZone('')).toBe(false);
|
||||
expect(isValidTimeZone(null)).toBe(false);
|
||||
expect(canonicalZone('europe/berlin')).toBe('Europe/Berlin');
|
||||
expect(canonicalZone('US/Eastern')).toBe('America/New_York');
|
||||
});
|
||||
|
||||
it('names fixed-offset zones the Etc way, UTC for fractional offsets', () => {
|
||||
expect(fixedOffsetZone(7_200_000)).toBe('Etc/GMT-2');
|
||||
expect(fixedOffsetZone(-18_000_000)).toBe('Etc/GMT+5');
|
||||
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
|
||||
expect(fixedOffsetZone(19_800_000)).toBe('Etc/UTC');
|
||||
});
|
||||
|
||||
it('maps all-day dates to UTC midnight and back', () => {
|
||||
expect(dateToUtcMidnight('2026-10-08T00:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
|
||||
expect(dateToUtcMidnight('2026-10-08T09:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
|
||||
expect(utcMidnightToDate(utc('2026-10-08T00:00:00Z'))).toBe('2026-10-08T00:00:00');
|
||||
// Date.UTC would read year 50 as 1950.
|
||||
expect(new Date(dateToUtcMidnight('0050-01-01T00:00:00')!).toISOString()).toBe('0050-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canonicalJson, deepEqual, jsonHash, parseJsonColumn } from '../common/json';
|
||||
import { applyPatch, assertPatchWellFormed, ptr, ptrSegments, setInPatch } from '../common/patch';
|
||||
import {
|
||||
exceptionRef,
|
||||
makeKeyMinter,
|
||||
objectRef,
|
||||
parseObjectRef,
|
||||
pendingRef,
|
||||
pendingUidOf,
|
||||
uuidFrom,
|
||||
} from '../common/ids';
|
||||
|
||||
function seeded(seed: number) {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
describe('common/json', () => {
|
||||
it('compares and hashes independently of key order and undefined members', () => {
|
||||
const a = { b: 1, a: { d: [1, { y: 2, x: 1 }], c: undefined } };
|
||||
const b = { a: { d: [1, { x: 1, y: 2 }] }, b: 1 };
|
||||
expect(canonicalJson(a)).toBe(canonicalJson(b));
|
||||
expect(deepEqual(a, b)).toBe(true);
|
||||
expect(jsonHash(a)).toBe(jsonHash(b));
|
||||
expect(deepEqual({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
|
||||
expect(deepEqual({ a: null }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('reads sync columns defensively', () => {
|
||||
expect(parseJsonColumn('{"v":1}')).toEqual({ v: 1 });
|
||||
expect(parseJsonColumn('not json')).toBeNull();
|
||||
expect(parseJsonColumn(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('common/patch', () => {
|
||||
it('escapes pointer segments', () => {
|
||||
expect(ptr('emails', 'x/y', 'label')).toBe('emails/x~1y/label');
|
||||
expect(ptrSegments('emails/x~1y/a~0b')).toEqual(['emails', 'x/y', 'a~b']);
|
||||
});
|
||||
|
||||
it('applies patches like Stalwart, refusing a missing parent', () => {
|
||||
const card = { emails: { e1: { address: 'a@x' } }, name: { full: 'A' } };
|
||||
expect(applyPatch(card, { 'emails/e1/address': 'b@x', 'emails/e2': { address: 'c@x' }, 'name/full': null })).toEqual({
|
||||
emails: { e1: { address: 'b@x' }, e2: { address: 'c@x' } },
|
||||
name: {},
|
||||
});
|
||||
expect(applyPatch(card, { 'phones/p1': { number: '1' } })).toBeNull();
|
||||
expect(card.emails.e1.address).toBe('a@x');
|
||||
});
|
||||
|
||||
it('keeps patches free of overlapping pointers', () => {
|
||||
expect(() => assertPatchWellFormed({ emails: {}, 'emails/e1': null })).toThrow(/prefix/);
|
||||
const patch: Record<string, unknown> = { 'emails/e1/label': 'Office' };
|
||||
setInPatch(patch, 'emails', { e1: { address: 'a@x', label: 'Office' } });
|
||||
expect(patch).toEqual({ emails: { e1: { address: 'a@x', label: 'Office' } } });
|
||||
setInPatch(patch, 'emails/e1/address', 'b@x');
|
||||
expect(patch).toEqual({ emails: { e1: { address: 'b@x', label: 'Office' } } });
|
||||
assertPatchWellFormed(patch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('common/ids', () => {
|
||||
it('mints keys Stalwart treats as plain keys', () => {
|
||||
const mint = makeKeyMinter(seeded(7));
|
||||
const keys = Array.from({ length: 50 }, () => mint());
|
||||
for (const key of keys) {
|
||||
expect(key).toMatch(/^b[0-9a-z]{8}$/);
|
||||
expect(key).not.toMatch(/^k\d+$/);
|
||||
}
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
const again = makeKeyMinter(() => 0);
|
||||
expect(again(['b00000000'])).not.toBe('b00000000');
|
||||
});
|
||||
|
||||
it('builds v4 uuids and object refs', () => {
|
||||
expect(uuidFrom(seeded(1))).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(objectRef('c', 'e9')).toBe('c/e9');
|
||||
expect(parseObjectRef('c/e9')).toEqual({ accountId: 'c', id: 'e9' });
|
||||
expect(parseObjectRef(pendingRef('u1'))).toBeNull();
|
||||
expect(pendingUidOf(pendingRef('urn:uuid:1'))).toBe('urn:uuid:1');
|
||||
expect(exceptionRef('c/e9', '2026-10-07T09:00:00')).toBe('c/e9#2026-10-07T09:00:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RawContacts } from '../../android-columns';
|
||||
import { sha256Hex } from '../../../lib/sha256';
|
||||
import { base64ToBytes } from '../../contacts/photo';
|
||||
import { appleCard, JPEG2, probeCard } from './fixtures';
|
||||
import { Harness } from './harness';
|
||||
|
||||
async function patchOf(h: Harness, rawId: number) {
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
|
||||
}
|
||||
|
||||
describe('contacts planAccepted', () => {
|
||||
it('writes the new shadow and baselines and clears DIRTY behind the VERSION read before the upload', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
|
||||
h.device.user.updateData(email, { data1: 'new@example.com' });
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload') throw new Error(plan.kind);
|
||||
h.send(plan.actions);
|
||||
const accepted = h.planner.planAccepted(local, h.card(id), h.ctx);
|
||||
expect(accepted.ops.ops[0]).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version }, expectCount: 1 });
|
||||
await h.applyOk(accepted.ops);
|
||||
const after = await h.contact(rawId);
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.shadow?.emails).toEqual(h.card(id).emails);
|
||||
expect(after.rows.find((r) => r.id === email)?.baseline?.data1).toBe('new@example.com');
|
||||
expect(h.planner.planDownload(h.card(id), after, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(after)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps DIRTY and uploads only the newer edit when the contact was edited during the upload', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
h.device.user.updateData(rows['emails:k1']._id as number, { data1: 'first@example.com' });
|
||||
await h.upload(rawId, () => h.device.user.updateData(rows['phones:k1']._id as number, { data1: '+1 555 0142' }));
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0142' });
|
||||
});
|
||||
|
||||
it('uploads a second edit of the same field made during the upload, and a revert to the old value', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
|
||||
h.device.user.updateData(email, { data1: 'a1@example.com' });
|
||||
await h.upload(rawId, () => h.device.user.updateData(email, { data1: 'a2@example.com' }));
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'a2@example.com' });
|
||||
|
||||
const h2 = new Harness();
|
||||
const seeded = await h2.seed(appleCard());
|
||||
const email2 = h2.rowsByKey(seeded.rawId)['emails:k1']._id as number;
|
||||
h2.device.user.updateData(email2, { data1: 'a1@example.com' });
|
||||
await h2.upload(seeded.rawId, () => h2.device.user.updateData(email2, { data1: 'anna@example.com' }));
|
||||
expect(await patchOf(h2, seeded.rawId)).toEqual({ 'emails/k1/address': 'anna@example.com' });
|
||||
});
|
||||
|
||||
it('writes the identity of a create even when the contact changed meanwhile', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact('usera@example.org', [
|
||||
{ mimetype: 'vnd.android.cursor.item/name', data1: 'Late Edit', data2: 'Late', data3: 'Edit' },
|
||||
]);
|
||||
await h.upload(rawId, () => h.device.user.star(rawId));
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.sourceId).toMatch(/^c\//);
|
||||
expect(local.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the device photo after uploading it and records the server hash', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
const fileId = h.rowsByKey(rawId)['media:ph'].data14;
|
||||
await h.upload(rawId);
|
||||
const photo = h.rowsByKey(rawId)['media:ph'];
|
||||
expect(photo.data14).toBe(fileId);
|
||||
expect(photo.data_sync2).toBe(`sha256:${sha256Hex(base64ToBytes(JPEG2)!)}`);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
const local = await h.contact(rawId);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the poison marker and the pending create once accepted', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
await h.applyOk({ ref: 'x', ops: [{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC4]: JSON.stringify({ fp: 'old', type: 'invalidProperties', n: 1, until: 1 }) } }] });
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'z@example.com' });
|
||||
await h.upload(rawId);
|
||||
expect(h.rawContact(rawId)[RawContacts.SYNC4]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType, RawContacts } from '../../android-columns';
|
||||
import { sha256Hex } from '../../../lib/sha256';
|
||||
import { base64ToBytes } from '../../contacts/photo';
|
||||
import type { Row } from '../../types';
|
||||
import { appleCard, googleCard, JPEG, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** Cells as text, so the fixtures don't depend on how the provider types a column. */
|
||||
function cells(row: Row | undefined, columns: string[]): Record<string, string | null> {
|
||||
if (!row) throw new Error('missing row');
|
||||
return Object.fromEntries(columns.map((c) => [c, row[c] === null || row[c] === undefined ? null : String(row[c])]));
|
||||
}
|
||||
|
||||
const NAME = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9'];
|
||||
const TYPED = ['data1', 'data2', 'data3'];
|
||||
const PRIMARY = ['data1', 'data2', 'data3', 'is_primary'];
|
||||
const POSTAL = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9', 'data10'];
|
||||
const ORG = ['data1', 'data4', 'data5', 'data6'];
|
||||
|
||||
const photoHash = (b64: string) => `sha256:${sha256Hex(base64ToBytes(b64)!)}`;
|
||||
|
||||
describe('contacts download: golden rows', () => {
|
||||
it('writes the probe card as one row per entry with its keys', async () => {
|
||||
const h = new Harness();
|
||||
h.names.set('urn:uuid:other-person', 'Other Person');
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(Object.keys(rows).sort()).toEqual([
|
||||
'addresses:home', 'anniversaries:bday', 'anniversaries:wed', 'emails:work1', 'emails:x/y', 'emails:zz9',
|
||||
'links:l1', 'media:ph', 'name', 'nicknames:nk', 'notes:n1', 'organizations:o1|titles:t1,r1', 'phones:fax7',
|
||||
'phones:p-a', 'relatedTo:urn:uuid:other-person',
|
||||
]);
|
||||
expect(cells(rows.name, NAME)).toEqual({
|
||||
data1: 'Dr. Probe Person', data2: 'Probe', data3: 'Person', data4: 'Dr.', data5: null, data6: null,
|
||||
data7: 'Proob', data8: null, data9: null,
|
||||
});
|
||||
expect(cells(rows['nicknames:nk'], ['data1', 'data2'])).toEqual({ data1: 'Probby', data2: '1' });
|
||||
expect(cells(rows['emails:work1'], PRIMARY)).toEqual({ data1: 'probe@work.example', data2: '0', data3: 'Office', is_primary: '1' });
|
||||
expect(cells(rows['emails:x/y'], PRIMARY)).toEqual({ data1: 'slash@example.org', data2: '3', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['emails:zz9'], PRIMARY)).toEqual({ data1: 'private@example.org', data2: '1', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['phones:p-a'], TYPED)).toEqual({ data1: '+49 30 1234', data2: '2', data3: null });
|
||||
expect(cells(rows['phones:fax7'], TYPED)).toEqual({ data1: '+49 30 9999', data2: '4', data3: null });
|
||||
expect(cells(rows['addresses:home'], POSTAL)).toEqual({
|
||||
data1: 'Main St 12, 10115 Berlin, Germany', data2: '1', data3: null, data4: 'Main St 12 4b', data5: null,
|
||||
data6: null, data7: 'Berlin', data8: null, data9: '10115', data10: 'Germany',
|
||||
});
|
||||
expect(cells(rows['organizations:o1|titles:t1,r1'], ORG)).toEqual({ data1: 'ACME', data4: 'Chief Prober', data5: 'R&D', data6: 'Lead' });
|
||||
expect(cells(rows['links:l1'], TYPED)).toEqual({ data1: 'https://probe.example', data2: '5', data3: null });
|
||||
expect(cells(rows['anniversaries:bday'], TYPED)).toEqual({ data1: '--03-14', data2: '3', data3: null });
|
||||
expect(cells(rows['anniversaries:wed'], TYPED)).toEqual({ data1: '2010-06-01', data2: '1', data3: null });
|
||||
expect(cells(rows['relatedTo:urn:uuid:other-person'], TYPED)).toEqual({ data1: 'Other Person', data2: '6', data3: null });
|
||||
expect(cells(rows['notes:n1'], ['data1'])).toEqual({ data1: 'first note' });
|
||||
expect(rows['media:ph'].data_sync2).toBe(photoHash(JPEG));
|
||||
// A 1×1 photo is kept as the thumbnail alone (no PHOTO_FILE_ID), and read from it.
|
||||
expect(rows['media:ph'].data14).toBeNull();
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
|
||||
const raw = h.rawContact(rawId);
|
||||
expect(raw).toMatchObject({
|
||||
[RawContacts.SOURCE_ID]: `${JMAP}/${id}`,
|
||||
[RawContacts.SYNC1]: `${JMAP}/${h.book}`,
|
||||
[RawContacts.SYNC3]: null,
|
||||
[RawContacts.DIRTY]: 0,
|
||||
});
|
||||
expect(Number(raw[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the photo out of the shadow: its data URI becomes the hash of its bytes', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const shadow = JSON.parse(h.rawContact(rawId)[RawContacts.SYNC2] as string);
|
||||
expect(shadow.media.ph.uri).toBe(photoHash(JPEG));
|
||||
expect({ ...shadow, media: undefined, '~memberOf': undefined }).toEqual({ ...h.card(id), media: undefined });
|
||||
});
|
||||
|
||||
it('records each row as stored, so a clean contact needs no baseline heal', async () => {
|
||||
const h = new Harness();
|
||||
for (const card of [probeCard(), appleCard(), googleCard(), { name: { full: 'Madonna' } }]) {
|
||||
const { rawId } = await h.seed(card);
|
||||
const local = await h.contact(rawId);
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
for (const row of local.rows) if (row.mimetype !== MimeType.PHOTO) expect(row.baseline).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps an Apple card with positional keys, filling the formatted address itself', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(cells(rows['emails:k1'], PRIMARY)).toEqual({ data1: 'anna@example.com', data2: '1', data3: null, is_primary: '1' });
|
||||
expect(cells(rows['emails:k2'], PRIMARY)).toEqual({ data1: 'anna@work.example', data2: '2', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['phones:k1'], TYPED)).toMatchObject({ data2: '2' });
|
||||
expect(cells(rows['phones:k2'], TYPED)).toMatchObject({ data2: '3' });
|
||||
expect(cells(rows['phones:k3'], TYPED)).toMatchObject({ data2: '4' });
|
||||
expect(cells(rows['addresses:k1'], POSTAL)).toEqual({
|
||||
data1: '1 Infinite Loop, 95014 Cupertino, CA, USA', data2: '2', data3: null, data4: '1 Infinite Loop', data5: null,
|
||||
data6: null, data7: 'Cupertino', data8: 'CA', data9: '95014', data10: 'USA',
|
||||
});
|
||||
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '1980-05-04', data2: '3', data3: null });
|
||||
expect(Object.keys(rows).some((k) => k.startsWith('onlineServices'))).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a Google card: custom display name, legacy cell phone, profile link, department list, relation by name', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(googleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(cells(rows.name, NAME)).toEqual({
|
||||
data1: 'Groß, Günther', data2: 'Günther', data3: 'Groß', data4: 'Prof.', data5: 'Karl', data6: 'PhD',
|
||||
data7: null, data8: null, data9: null,
|
||||
});
|
||||
expect(cells(rows['nicknames:k1'], ['data1'])).toEqual({ data1: 'Gü' });
|
||||
expect(cells(rows['nicknames:k2'], ['data1'])).toEqual({ data1: 'GKG' });
|
||||
expect(cells(rows['phones:k1'], TYPED)).toEqual({ data1: '0171 2345678', data2: '2', data3: null });
|
||||
// The label names a type Android has.
|
||||
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://example.org/~guenther', data2: '3', data3: null });
|
||||
expect(cells(rows['organizations:k1|titles:k1,'], ORG)).toEqual({ data1: 'Uni Beispiel', data4: 'Professor', data5: 'Informatik, AG Sync', data6: null });
|
||||
expect(cells(rows['relatedTo:Maria Groß'], TYPED)).toEqual({ data1: 'Maria Groß', data2: '14', data3: null });
|
||||
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '--08-15', data2: '1', data3: null });
|
||||
});
|
||||
|
||||
it('writes a name with only `full` as the display name and lets the provider split it', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed({ name: { full: 'Madonna' } });
|
||||
expect(cells(h.rowsByKey(rawId).name, ['data1', 'data2', 'data3'])).toEqual({ data1: 'Madonna', data2: 'Madonna', data3: null });
|
||||
});
|
||||
|
||||
it('writes text longer than the provider keeps already cut, and never uploads the cut column', async () => {
|
||||
const h = new Harness();
|
||||
const long = 'x'.repeat(12_000);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Long Note' }, notes: { n1: { note: long } }, phones: { p1: { number: '1'.repeat(1200) } } });
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect((rows['notes:n1'].data1 as string).length).toBe(10 * 1024);
|
||||
expect((rows['phones:p1'].data1 as string).length).toBe(1000);
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
h.device.user.updateData(rows['notes:n1']._id as number, { data1: `${rows['notes:n1'].data1 as string}!` });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('marks a card of read-only books read-only', async () => {
|
||||
const h = new Harness();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
expect(Number(h.rawContact(rawId)[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts download: echoes and updates', () => {
|
||||
it.each([['probe', probeCard], ['apple', appleCard], ['google', googleCard]])('writes nothing for an unchanged %s card', async (_n, make) => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(make());
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0 });
|
||||
expect(plan.ops.ops).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes nothing when the shadow itself comes back (a re-plan after a group change)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planDownload(local.shadow!, local, h.ctx);
|
||||
expect(plan.effect).toBe('none');
|
||||
});
|
||||
|
||||
it('rewrites only the row of the entry the server changed, behind VERSION and DIRTY', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k2/address': 'anna@new-work.example' });
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planDownload(h.card(id), local, h.ctx);
|
||||
const [assert, raw, ...data] = plan.ops.ops;
|
||||
expect(assert).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version, dirty: 0 }, expectCount: 1 });
|
||||
expect(raw).toMatchObject({ op: 'update', table: 'raw_contacts', values: { [RawContacts.SYNC2]: expect.any(String) } });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toMatchObject({ op: 'update', table: 'data', expectCount: 1, values: { data1: 'anna@new-work.example', data_sync1: 'emails:k2' } });
|
||||
await h.applyOk(plan.ops);
|
||||
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new-work.example');
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('inserts entries the server added and deletes the ones it removed', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'phones/k3': null, 'links': { k1: { uri: 'https://anna.example' } } });
|
||||
await h.download(id);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['phones:k3']).toBeUndefined();
|
||||
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://anna.example', data2: '7', data3: null });
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('follows keys Stalwart shifted (a CardDAV insert at the front) by content, with no rewrite of the rows', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
const before = h.rowsByKey(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, {
|
||||
emails: {
|
||||
k1: { address: 'first@example.com' },
|
||||
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k3: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
});
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
const data = plan.ops.ops.filter((o) => o.op !== 'assert' && o.op !== 'syncState' && o.table === 'data');
|
||||
expect(data).toHaveLength(3);
|
||||
const renames = data.filter((o) => o.op === 'update');
|
||||
for (const r of renames) expect(Object.keys((r as { values: object }).values).sort()).toEqual(['data_sync1', 'data_sync3']);
|
||||
await h.applyOk(plan.ops);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k2']._id).toBe(before['emails:k1']._id);
|
||||
expect(rows['emails:k3']._id).toBe(before['emails:k2']._id);
|
||||
expect(rows['emails:k1'].data1).toBe('first@example.com');
|
||||
});
|
||||
|
||||
it('fails as a whole when the user edits the contact between read and write', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/k1/note': 'from the server' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
const phone = h.rowsByKey(rawId)['phones:k1']._id as number;
|
||||
h.device.beforeNextBatch(() => h.device.user.updateData(phone, { [Data.DATA1]: '+1 555 0199' }));
|
||||
const res = await h.apply(plan.ops);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.rowsByKey(rawId)['notes:k1'].data1).toBe('Met at WWDC');
|
||||
});
|
||||
|
||||
it('adopts its own create whose identity was never written, keeping the local rows', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact('usera@example.org', [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: 'New Person', [Data.DATA2]: 'New', [Data.DATA3]: 'Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'new@example.org', [Data.DATA2]: 1 },
|
||||
]);
|
||||
const plan = await h.planUpload(rawId);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'create') throw new Error(`expected a create, got ${plan.kind}`);
|
||||
const cardId = h.send(plan.actions)!;
|
||||
// The response was lost: the next download finds the card and the pending uid.
|
||||
const local = await h.contact(rawId);
|
||||
const adopt = h.planner.planDownload(h.card(cardId), local, h.ctx);
|
||||
expect(adopt.effect).toBe('update');
|
||||
expect(adopt.stillDirty).toBe(true);
|
||||
await h.applyOk(adopt.ops);
|
||||
const after = await h.contact(rawId);
|
||||
expect(after).toMatchObject({ sourceId: `${JMAP}/${cardId}`, pending: null, dirty: true });
|
||||
expect(h.planner.planUpload(after, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Server cards for the contacts planner tests. `probeCard` is what the local
|
||||
* Stalwart returned for a card created over JMAP with client keys (live probe
|
||||
* output); the others are shaped like cards synced in over CardDAV from
|
||||
* Apple and Google clients, whose keys Stalwart generates by position.
|
||||
*/
|
||||
|
||||
/** A 1×1 JPEG. */
|
||||
export const JPEG = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=';
|
||||
/** Another picture's bytes (any base64 does for the fakes). */
|
||||
export const JPEG2 = '/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
|
||||
|
||||
export function probeCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'urn:uuid:probe-1790463389675',
|
||||
kind: 'individual',
|
||||
name: {
|
||||
full: 'Dr. Probe Person',
|
||||
phoneticSystem: 'ipa',
|
||||
components: [
|
||||
{ kind: 'title', value: 'Dr.' },
|
||||
{ kind: 'given', value: 'Probe', phonetic: 'Proob' },
|
||||
{ kind: 'surname', value: 'Person' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
nicknames: { nk: { name: 'Probby' } },
|
||||
emails: {
|
||||
work1: { address: 'probe@work.example', contexts: { work: true }, pref: 1, label: 'Office' },
|
||||
'x/y': { address: 'slash@example.org' },
|
||||
zz9: { address: 'private@example.org', contexts: { private: true } },
|
||||
},
|
||||
phones: {
|
||||
'p-a': { number: '+49 30 1234', contexts: { private: true }, features: { voice: true, mobile: true } },
|
||||
fax7: { number: '+49 30 9999', contexts: { work: true }, features: { fax: true } },
|
||||
},
|
||||
addresses: {
|
||||
home: {
|
||||
contexts: { private: true },
|
||||
coordinates: 'geo:52.5,13.4',
|
||||
timeZone: 'Europe/Berlin',
|
||||
countryCode: 'DE',
|
||||
full: 'Main St 12, 10115 Berlin, Germany',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Main St' },
|
||||
{ kind: 'separator', value: ' ' },
|
||||
{ kind: 'number', value: '12' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'postcode', value: '10115' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
{ kind: 'apartment', value: '4b' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
},
|
||||
organizations: { o1: { name: 'ACME', units: [{ name: 'R&D' }] } },
|
||||
titles: {
|
||||
t1: { name: 'Chief Prober', kind: 'title', organizationId: 'o1' },
|
||||
r1: { name: 'Lead', kind: 'role', organizationId: 'o1' },
|
||||
},
|
||||
links: { l1: { uri: 'https://probe.example', contexts: { work: true } } },
|
||||
onlineServices: { im1: { uri: 'xmpp:probe@example.org', service: 'XMPP' } },
|
||||
anniversaries: {
|
||||
bday: { date: { '@type': 'PartialDate', month: 3, day: 14 }, kind: 'birth' },
|
||||
wed: { date: { '@type': 'PartialDate', year: 2010, month: 6, day: 1 }, kind: 'wedding' },
|
||||
},
|
||||
relatedTo: { 'urn:uuid:other-person': { relation: { friend: true } } },
|
||||
notes: { n1: { note: 'first note' }, n2: { note: 'second note' } },
|
||||
media: { ph: { uri: `data:image/jpeg;base64,${JPEG}`, kind: 'photo', mediaType: 'image/jpeg' } },
|
||||
keywords: { vip: true },
|
||||
personalInfo: { pi1: { value: 'probing', kind: 'hobby' } },
|
||||
preferredLanguages: { lang1: { language: 'de', pref: 1 } },
|
||||
speakToAs: { grammaticalGender: 'neuter' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Apple Contacts over CardDAV: no PROP-IDs, so `k1…kN`; an address without `full`. */
|
||||
export function appleCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'urn:uuid:5F2C9B1E-APPLE',
|
||||
kind: 'individual',
|
||||
prodId: '-//Apple Inc.//iPhone OS 18.0//EN',
|
||||
name: {
|
||||
components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Apfel' }],
|
||||
isOrdered: true,
|
||||
full: 'Anna Apfel',
|
||||
},
|
||||
emails: {
|
||||
k1: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k2: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
phones: {
|
||||
k1: { number: '+1 555 0100', features: { mobile: true, voice: true } },
|
||||
k2: { number: '+1 555 0101', contexts: { work: true }, features: { voice: true } },
|
||||
k3: { number: '+1 555 0102', contexts: { work: true }, features: { fax: true } },
|
||||
},
|
||||
addresses: {
|
||||
k1: {
|
||||
components: [
|
||||
{ kind: 'name', value: '1 Infinite Loop' },
|
||||
{ kind: 'locality', value: 'Cupertino' },
|
||||
{ kind: 'region', value: 'CA' },
|
||||
{ kind: 'postcode', value: '95014' },
|
||||
{ kind: 'country', value: 'USA' },
|
||||
],
|
||||
isOrdered: true,
|
||||
contexts: { work: true },
|
||||
},
|
||||
},
|
||||
anniversaries: { k1: { kind: 'birth', date: { '@type': 'PartialDate', year: 1980, month: 5, day: 4 } } },
|
||||
notes: { k1: { note: 'Met at WWDC' } },
|
||||
onlineServices: { k1: { service: 'Skype', user: 'anna.apfel' } },
|
||||
keywords: { friends: true },
|
||||
};
|
||||
}
|
||||
|
||||
/** Google Contacts over CardDAV: a legacy `cell` feature, a custom display name, two nicknames. */
|
||||
export function googleCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'google-8a7d2f',
|
||||
kind: 'individual',
|
||||
name: {
|
||||
components: [
|
||||
{ kind: 'title', value: 'Prof.' },
|
||||
{ kind: 'given', value: 'Günther' },
|
||||
{ kind: 'given2', value: 'Karl' },
|
||||
{ kind: 'surname', value: 'Groß' },
|
||||
{ kind: 'credential', value: 'PhD' },
|
||||
],
|
||||
isOrdered: true,
|
||||
full: 'Groß, Günther',
|
||||
},
|
||||
nicknames: { k1: { name: 'Gü' }, k2: { name: 'GKG' } },
|
||||
phones: { k1: { number: '0171 2345678', features: { cell: true } } },
|
||||
links: { k1: { uri: 'https://example.org/~guenther', label: 'profile' } },
|
||||
organizations: { k1: { name: 'Uni Beispiel', units: [{ name: 'Informatik' }, { name: 'AG Sync' }] } },
|
||||
titles: { k1: { name: 'Professor', kind: 'title', organizationId: 'k1' } },
|
||||
relatedTo: { 'Maria Groß': { relation: { spouse: true } } },
|
||||
anniversaries: { k1: { kind: 'wedding', date: { '@type': 'PartialDate', month: 8, day: 15 } } },
|
||||
speakToAs: { grammaticalGender: 'masculine' },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, Groups, MimeType } from '../../android-columns';
|
||||
import type { ContactCardWire, OpGroup, UploadPlan } from '../../planner';
|
||||
import { appleCard, googleCard } from './fixtures';
|
||||
import { ACCOUNT, Harness, JMAP } from './harness';
|
||||
|
||||
function actionsOf(plan: UploadPlan<ContactCardWire>) {
|
||||
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${plan.kind}`);
|
||||
return plan.actions;
|
||||
}
|
||||
|
||||
function opsOf(plan: UploadPlan<ContactCardWire>): OpGroup {
|
||||
if (plan.kind === 'upload' || plan.kind === 'skip') throw new Error(`expected ops, got ${plan.kind}`);
|
||||
return plan.ops;
|
||||
}
|
||||
|
||||
async function localGroup(h: Harness, groupId: string) {
|
||||
const g = (await h.groups()).find((x) => x.sourceId === `${JMAP}/${groupId}`);
|
||||
if (!g) throw new Error(`no group row for ${groupId}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** A group card in an address book this user may only read, on the device. */
|
||||
async function readOnlyGroup(h: Harness, members: Record<string, boolean> = {}) {
|
||||
const shared = h.server.addAddressBook(JMAP, { name: 'Shared' });
|
||||
h.readOnlyBooks.add(shared);
|
||||
const gid = h.addCard({ uid: 'urn:uuid:group-shared', kind: 'group', name: { full: 'Shared' }, members, addressBookIds: { [shared]: true } });
|
||||
await h.download(gid);
|
||||
return gid;
|
||||
}
|
||||
|
||||
/** A group card holding Anna (apple card), both on the device. */
|
||||
async function withGroup(members: Record<string, boolean> = { [appleCard().uid as string]: true }) {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:group-friends', kind: 'group', name: { full: 'Friends' }, members });
|
||||
await h.download(gid);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
return { h, gid, id, rawId };
|
||||
}
|
||||
|
||||
describe('contacts groups: download', () => {
|
||||
it('writes a group card as a visible group row, and nothing for its echo', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const row = h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])[0];
|
||||
expect(row).toMatchObject({ [Groups.TITLE]: 'Friends', [Groups.GROUP_VISIBLE]: 1, [Groups.DIRTY]: 0 });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', ops: { ops: [] } });
|
||||
});
|
||||
|
||||
it('writes one membership row per group card that lists the contact', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)?.key).toBe(`members:${JMAP}/${gid}`);
|
||||
expect(local.shadow?.['~memberOf']).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('leaves memberships alone when the engine has no group index', async () => {
|
||||
const h = new Harness();
|
||||
h.groupIndex = false;
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g', kind: 'group', name: { full: 'G' }, members: { [appleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect((await h.contact(rawId)).shadow?.['~memberOf']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows the group card when members change on the server (a re-plan of the unchanged contact)', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { [`members/${appleCard().uid as string}`]: null });
|
||||
await h.download(gid);
|
||||
let local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [appleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('lets the server win when both sides renamed the group', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mates' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { 'name/full': 'Pals' });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
await h.applyOk(plan.ops);
|
||||
expect((await localGroup(h, gid)).title).toBe('Pals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts groups: membership edits', () => {
|
||||
it('adds the contact to a group card with `members/<uid>: true`, keeping it dirty until the server has it', async () => {
|
||||
const h = new Harness();
|
||||
const other = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(other);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const g = await localGroup(h, other);
|
||||
h.device.user.addToGroup(rawId, g.groupId);
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: other, patch: { [`members/${appleCard().uid as string}`]: true } }]);
|
||||
h.send(actions);
|
||||
const accepted = h.planner.planGroupAccepted(g, h.card(other), h.ctx);
|
||||
await h.applyOk(accepted.ops);
|
||||
await h.upload(rawId);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rename the server does not have when a membership patch of the group is accepted', async () => {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.addToGroup(rawId, g.groupId);
|
||||
await h.upload(rawId);
|
||||
// Renamed after the group's own upload was planned, before the membership phase read it.
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Colleagues' });
|
||||
const read = await localGroup(h, gid);
|
||||
h.send(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx));
|
||||
await h.applyOk(h.planner.planGroupAccepted(read, h.card(gid), h.ctx).ops);
|
||||
expect(await localGroup(h, gid)).toMatchObject({ title: 'Colleagues', dirty: true, shadow: { members: { [appleCard().uid as string]: true } } });
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Colleagues' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the groups of a contact created on the device and adds it to their cards', async () => {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g7', kind: 'group', name: { full: 'Team' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const g = await localGroup(h, gid);
|
||||
const rawId = h.device.user.insertContact(ACCOUNT, [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'New Person', data2: 'New', data3: 'Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: g.groupId },
|
||||
]);
|
||||
await h.upload(rawId);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
const uid = (await h.contact(rawId)).shadow!.uid as string;
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${uid}`]: true } }]);
|
||||
h.send(actions);
|
||||
await h.applyOk(h.planner.planGroupAccepted(g, h.card(gid), h.ctx).ops);
|
||||
await h.upload(rawId);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('sends the whole map for the first member of an empty group', async () => {
|
||||
const h = new Harness();
|
||||
const empty = h.addCard({ uid: 'urn:uuid:g3', kind: 'group', name: { full: 'Empty' } });
|
||||
await h.download(empty);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, empty)).groupId);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: empty, patch: { members: { [appleCard().uid as string]: true } } }]);
|
||||
});
|
||||
|
||||
it('removes the contact with `members/<uid>: null` when its membership row was deleted in place', async () => {
|
||||
const { h, gid, rawId } = await withGroup({ [appleCard().uid as string]: true, 'urn:uuid:someone': true });
|
||||
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
|
||||
h.device.user.deleteData(membership.id);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: null } }]);
|
||||
});
|
||||
|
||||
it('uploads nothing when an editor re-inserted the same memberships without keys', async () => {
|
||||
const { h, rawId } = await withGroup();
|
||||
const rows = h.device.rows('data', 'raw_contact_id = ?', [rawId]).filter((r) => r[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP);
|
||||
h.device.user.fossifySave(rawId, null, rows.map((r) => ({ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, data1: r.data1 })));
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts back an addition to a group in read-only books instead of keeping the contact dirty', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h, { 'urn:uuid:someone': true });
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts back a removal from a group in read-only books', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h, { [appleCard().uid as string]: true });
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
|
||||
h.device.user.deleteData(membership.id);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('puts back an addition to a group of another JMAP account', async () => {
|
||||
const h = new Harness();
|
||||
h.server.addAccount('t', { name: 'Team' });
|
||||
const book = h.server.addAddressBook('t', { name: 'Team contacts' });
|
||||
const team = h.server.addCard('t', { uid: 'urn:uuid:team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [book]: true } });
|
||||
const card = h.server.get('ContactCard', 't', team) as unknown as ContactCardWire;
|
||||
await h.applyOk(h.planner.planGroupDownload(card, null, { ...h.ctx, jmapAccountId: 't' }).ops);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await h.groups()).find((g) => g.sourceId === `t/${team}`)!.groupId);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`t/${team}`]);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('still uploads a read-only contact joining a writable group (the group card is what changes)', async () => {
|
||||
const h = new Harness();
|
||||
const other = h.server.addAddressBook(JMAP, { name: 'Shared' });
|
||||
h.readOnlyBooks.add(other);
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g5', kind: 'group', name: { full: 'Mine' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed({ ...appleCard(), addressBookIds: { [other]: true } });
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
const plan = await h.upload(rawId);
|
||||
expect(plan.kind).toBe('revert');
|
||||
// Nothing of its own was put back: nothing to report.
|
||||
expect(plan).not.toHaveProperty('reason');
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([
|
||||
{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads the edits it can next to a membership it puts back, and settles', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'anna@new.example' });
|
||||
const plan = await h.upload(rawId);
|
||||
expect(actionsOf(plan)).toEqual([{ kind: 'update', id, patch: { 'emails/k1/address': 'anna@new.example' } }]);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not take a server-side addition for a local removal while the contact is dirty', async () => {
|
||||
const { h, gid } = await withGroup({});
|
||||
const { rawId } = await h.seed(googleCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['phones:k1']._id as number, { data1: '0171 999' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [googleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
const local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts groups: device edits of groups', () => {
|
||||
it('uploads a rename as `name/full`', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Close friends' });
|
||||
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toEqual({ kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Close friends' } }] });
|
||||
h.send(actionsOf(plan));
|
||||
const accepted = h.planner.planGroupAccepted(await localGroup(h, gid), h.card(gid), h.ctx);
|
||||
await h.applyOk(accepted.ops);
|
||||
expect(await localGroup(h, gid)).toMatchObject({ dirty: false, title: 'Close friends' });
|
||||
expect(h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('claims and creates a group made on the device', async () => {
|
||||
const h = new Harness();
|
||||
const groupId = h.device.user.insertGroup('usera@example.org', 'Book club');
|
||||
const decode = async () => (await h.groups()).find((g) => g.groupId === groupId)!;
|
||||
const claim = h.planner.planGroupUpload(await decode(), h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
await h.applyOk(opsOf(claim));
|
||||
const pending = (await decode()).pending!;
|
||||
const plan = h.planner.planGroupUpload(await decode(), h.ctx);
|
||||
expect(plan).toEqual({
|
||||
kind: 'upload',
|
||||
actions: [{
|
||||
kind: 'create', uid: pending.uid, collectionId: h.book,
|
||||
object: { '@type': 'Card', version: '1.0', uid: pending.uid, kind: 'group', addressBookIds: { [h.book]: true }, name: { full: 'Book club' } },
|
||||
}],
|
||||
});
|
||||
const id = h.send(actionsOf(plan))!;
|
||||
await h.applyOk(h.planner.planGroupAccepted(await decode(), h.card(id), h.ctx).ops);
|
||||
expect(await decode()).toMatchObject({ sourceId: `${JMAP}/${id}`, dirty: false, pending: null });
|
||||
});
|
||||
|
||||
it('destroys a group deleted on the device', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'destroy', id: gid, uid: 'urn:uuid:group-friends' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('only leaves the synced book when the server filed the group in an unsynced one after the device deleted it', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
|
||||
h.unselected.add(`${JMAP}/${archive}`);
|
||||
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { [`addressBookIds/${archive}`]: true, 'name/full': 'Old friends' });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
// The delete wins over the rename; only the shadow follows the card.
|
||||
expect(await localGroup(h, gid)).toMatchObject({ deleted: true, title: 'Friends' });
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { [`addressBookIds/${h.book}`]: null } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('reverts a rename of a read-only group', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mine now' });
|
||||
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
await h.applyOk(opsOf(plan));
|
||||
expect(await localGroup(h, gid)).toMatchObject({ title: 'Friends', dirty: false });
|
||||
});
|
||||
|
||||
it('deletes the group row for a server delete', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
await h.applyOk(h.planner.planGroupLocalDelete(await localGroup(h, gid)));
|
||||
expect(h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])).toEqual([]);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Test rig for the contacts planner: the fake device providers, the fake
|
||||
* Stalwart, and a ContactsContext wired between them. Plans are applied the
|
||||
* way the engine applies them (one op group per batch); uploads go through
|
||||
* the fake server's patch handling and come back through its normalisation.
|
||||
*/
|
||||
import { GroupMembership, Groups, RawContacts } from '../../android-columns';
|
||||
import { makeKeyMinter, parseObjectRef, uuidFrom } from '../../common/ids';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import type {
|
||||
ContactCardWire,
|
||||
ContactsContext,
|
||||
LocalContact,
|
||||
LocalGroup,
|
||||
OpGroup,
|
||||
UploadAction,
|
||||
UploadPlan,
|
||||
} from '../../planner';
|
||||
import { CONTACTS_AUTHORITY, type BatchResult, type ProviderPort, type Row } from '../../types';
|
||||
import { FakeJmapServer } from '../fakes/fake-jmap-server';
|
||||
import { FakeDeviceProviders } from '../fakes/fake-provider';
|
||||
|
||||
export const ACCOUNT = 'usera@example.org';
|
||||
export const JMAP = 'c';
|
||||
|
||||
export function seeded(seed = 7): () => number {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
function rowsOf(res: { columns: string[]; rows: unknown[][] }): Row[] {
|
||||
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])) as Row);
|
||||
}
|
||||
|
||||
export class Harness {
|
||||
readonly device = new FakeDeviceProviders();
|
||||
readonly port: ProviderPort = this.device.port(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
readonly server = new FakeJmapServer();
|
||||
readonly book: string;
|
||||
readonly planner = contactsPlanner;
|
||||
/** Base64 of blob-backed photos by blobId, and the device photo per raw contact. */
|
||||
readonly blobs = new Map<string, string>();
|
||||
readonly devicePhotos = new Map<number, string>();
|
||||
readonly names = new Map<string, string>();
|
||||
readonly readOnlyBooks = new Set<string>();
|
||||
readonly unselected = new Set<string>();
|
||||
/** When false the context has no `groupsOf` (memberships not synced). */
|
||||
groupIndex = true;
|
||||
readonly ctx: ContactsContext;
|
||||
|
||||
constructor() {
|
||||
this.server.addAccount(JMAP, { name: ACCOUNT });
|
||||
this.book = this.server.addAddressBook(JMAP, { name: 'Personal' });
|
||||
const random = seeded();
|
||||
const mintKey = makeKeyMinter(random);
|
||||
const self = this;
|
||||
this.ctx = {
|
||||
jmapAccountId: JMAP,
|
||||
now: 1_800_000_000_000,
|
||||
mintKey,
|
||||
mintUid: () => `urn:uuid:${uuidFrom(random)}`,
|
||||
selectedCollections: (card) =>
|
||||
Object.keys(card.addressBookIds ?? {}).map((id) => `${JMAP}/${id}`).filter((k) => !self.unselected.has(k)),
|
||||
isReadOnly: (card) => Object.keys(card.addressBookIds ?? {}).every((id) => self.readOnlyBooks.has(id)),
|
||||
isSelected: (key) => !self.unselected.has(key),
|
||||
createTarget: () => `${JMAP}/${self.book}`,
|
||||
photoBytes: (entry) => (entry.blobId ? self.blobs.get(entry.blobId) ?? null : null),
|
||||
devicePhoto: (id) => self.devicePhotos.get(id) ?? null,
|
||||
nameForUid: (uid) => self.names.get(uid) ?? null,
|
||||
groupRowIdBySourceId: (sourceId) => {
|
||||
const row = self.device.rows('groups', 'sourceid = ?', [sourceId])[0];
|
||||
return row ? Number(row[Groups._ID]) : null;
|
||||
},
|
||||
groupsOf(uid: string) {
|
||||
return self.server
|
||||
.all('ContactCard', JMAP)
|
||||
.filter((c) => c.kind === 'group' && (c.members as Record<string, boolean> | undefined)?.[uid] === true)
|
||||
.map((c) => `${JMAP}/${c.id as string}`);
|
||||
},
|
||||
groupReadOnly(sourceId: string) {
|
||||
const ref = parseObjectRef(sourceId);
|
||||
const card = ref ? self.server.get('ContactCard', ref.accountId, ref.id) : undefined;
|
||||
const books = Object.keys((card?.addressBookIds as Record<string, boolean> | undefined) ?? {});
|
||||
return books.length > 0 && books.every((id) => self.readOnlyBooks.has(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The context as the engine may build it without a group index. */
|
||||
context(): ContactsContext {
|
||||
if (this.groupIndex) return this.ctx;
|
||||
const { groupsOf: _drop, ...rest } = this.ctx;
|
||||
return rest as ContactsContext;
|
||||
}
|
||||
|
||||
async apply(group: OpGroup): Promise<BatchResult> {
|
||||
if (!group.ops.length) return { ok: true, results: [] };
|
||||
return this.port.applyBatch(group.ops);
|
||||
}
|
||||
|
||||
async applyOk(group: OpGroup): Promise<void> {
|
||||
const res = await this.apply(group);
|
||||
if (!res.ok) throw new Error(`batch failed: ${res.reason} ${res.message}`);
|
||||
}
|
||||
|
||||
async contacts(): Promise<LocalContact[]> {
|
||||
const raw = rowsOf(await this.port.query({ table: 'raw_contacts', columns: [...this.planner.rawContactColumns] }));
|
||||
const data = rowsOf(await this.port.query({ table: 'data', columns: [...this.planner.dataColumns] }));
|
||||
return raw.map((r) => this.planner.decodeContact(r, data.filter((d) => d.raw_contact_id === r._id)));
|
||||
}
|
||||
|
||||
async contact(rawContactId: number): Promise<LocalContact> {
|
||||
const found = (await this.contacts()).find((c) => c.rawContactId === rawContactId);
|
||||
if (!found) throw new Error(`no raw contact ${rawContactId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async bySource(cardId: string): Promise<LocalContact | null> {
|
||||
return (await this.contacts()).find((c) => c.sourceId === `${JMAP}/${cardId}`) ?? null;
|
||||
}
|
||||
|
||||
async groups(): Promise<LocalGroup[]> {
|
||||
const rows = rowsOf(await this.port.query({ table: 'groups', columns: [...this.planner.groupColumns] }));
|
||||
return rows.map((r) => this.planner.decodeGroup(r));
|
||||
}
|
||||
|
||||
card(id: string): ContactCardWire {
|
||||
const card = this.server.get('ContactCard', JMAP, id);
|
||||
if (!card) throw new Error(`no card ${id}`);
|
||||
return card as ContactCardWire;
|
||||
}
|
||||
|
||||
addCard(card: Record<string, unknown>): string {
|
||||
return this.server.addCard(JMAP, { addressBookIds: { [this.book]: true }, ...card });
|
||||
}
|
||||
|
||||
/** Downloads a server card into its local contact (or a new one); returns the raw contact id. */
|
||||
async download(cardId: string): Promise<number> {
|
||||
const card = this.card(cardId);
|
||||
const local = await this.bySource(cardId);
|
||||
const plan = card.kind === 'group'
|
||||
? this.planner.planGroupDownload(card, (await this.groups()).find((g) => g.sourceId === `${JMAP}/${cardId}`) ?? null, this.context())
|
||||
: this.planner.planDownload(card, local, this.context());
|
||||
await this.applyOk(plan.ops);
|
||||
if (card.kind === 'group') return -1;
|
||||
const after = await this.bySource(cardId);
|
||||
if (!after) throw new Error('download wrote no contact');
|
||||
const heal = this.planner.planBaselineHeal(after);
|
||||
if (heal) await this.applyOk(heal);
|
||||
return after.rawContactId;
|
||||
}
|
||||
|
||||
/** A card on the server and on the device, clean. */
|
||||
async seed(card: Record<string, unknown>): Promise<{ id: string; rawId: number }> {
|
||||
const id = this.addCard(card);
|
||||
return { id, rawId: await this.download(id) };
|
||||
}
|
||||
|
||||
/** Sends upload actions to the fake server the way the engine would; returns the touched card id. */
|
||||
send(actions: UploadAction<ContactCardWire>[]): string | null {
|
||||
let touched: string | null = null;
|
||||
for (const a of actions) {
|
||||
if (a.kind === 'update') {
|
||||
this.server.serverUpdate('ContactCard', JMAP, a.id, a.patch);
|
||||
touched = a.id;
|
||||
} else if (a.kind === 'create') {
|
||||
touched = this.server.addCard(JMAP, a.object as Record<string, unknown>);
|
||||
} else if (a.id) {
|
||||
this.server.serverDestroy('ContactCard', JMAP, a.id);
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/** Plans an upload for a contact, claiming it first when it is new. */
|
||||
async planUpload(rawContactId: number): Promise<UploadPlan<ContactCardWire>> {
|
||||
let plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
|
||||
if (plan.kind === 'claim') {
|
||||
await this.applyOk(plan.ops);
|
||||
plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* One upload round for a contact: plan, send, re-read the card, apply the
|
||||
* accepted plan (or its keep-dirty fallback). Returns the plan.
|
||||
*/
|
||||
async upload(rawContactId: number, between?: () => void): Promise<UploadPlan<ContactCardWire>> {
|
||||
const plan = await this.planUpload(rawContactId);
|
||||
const local = await this.contact(rawContactId);
|
||||
if (plan.kind === 'upload') {
|
||||
const touched = this.send(plan.actions);
|
||||
between?.();
|
||||
if (local.deleted) {
|
||||
const accepted = this.planner.planAccepted(local, {} as ContactCardWire, this.context());
|
||||
await this.applyOk(accepted.ops);
|
||||
return plan;
|
||||
}
|
||||
const server = this.card(touched!);
|
||||
const accepted = this.planner.planAccepted(local, server, this.context());
|
||||
const res = await this.apply(accepted.ops);
|
||||
if (!res.ok) await this.applyOk(accepted.keepDirtyOps);
|
||||
} else if (plan.kind !== 'skip') {
|
||||
between?.();
|
||||
await this.applyOk(plan.ops);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
cardIdOf(local: LocalContact): string {
|
||||
return parseObjectRef(local.sourceId)!.id;
|
||||
}
|
||||
|
||||
/** Data rows of a raw contact as the provider holds them, keyed by DATA_SYNC1 (or `row:<id>`). */
|
||||
rowsByKey(rawContactId: number): Record<string, Row> {
|
||||
const out: Record<string, Row> = {};
|
||||
for (const r of this.device.rows('data', 'raw_contact_id = ?', [rawContactId])) {
|
||||
out[(r.data_sync1 as string | null) ?? `row:${r._id}`] = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
rawContact(rawContactId: number): Row {
|
||||
return this.device.row('raw_contacts', rawContactId)!;
|
||||
}
|
||||
|
||||
dirty(rawContactId: number): boolean {
|
||||
return Number(this.rawContact(rawContactId)[RawContacts.DIRTY]) === 1;
|
||||
}
|
||||
|
||||
membershipGroups(rawContactId: number): string[] {
|
||||
return this.device
|
||||
.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawContactId, 'vnd.android.cursor.item/group_membership'])
|
||||
.map((r) => r[GroupMembership.GROUP_SOURCE_ID] as string)
|
||||
.sort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType } from '../../android-columns';
|
||||
import { MAX_PHOTO_BASE64 } from '../../contacts/photo';
|
||||
import type { LocalContact } from '../../planner';
|
||||
import { JPEG, JPEG2, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** The engine's view after every write: a clean contact whose rows the server card describes. */
|
||||
async function expectSettled(h: Harness, id: string, rawId: number): Promise<LocalContact> {
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
return local;
|
||||
}
|
||||
|
||||
describe('contacts lifecycle', () => {
|
||||
it('settles after device edits, an editor rewrite and server edits in turn', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
let rows = h.rowsByKey(rawId);
|
||||
|
||||
// Several fields at once in AOSP Contacts.
|
||||
h.device.user.updateData(rows.name._id as number, { data2: 'Probert', data1: 'Dr. Probert Person' });
|
||||
h.device.user.updateData(rows['emails:zz9']._id as number, { data1: 'home@example.org' });
|
||||
h.device.user.updateData(rows['addresses:home']._id as number, { data9: '10117' });
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 30 7777', data2: 3 });
|
||||
await h.upload(rawId);
|
||||
const card = h.card(id);
|
||||
expect(card.name).toMatchObject({ full: 'Dr. Probert Person' });
|
||||
expect(card.emails!.zz9.address).toBe('home@example.org');
|
||||
expect(card.addresses!.home.components).toContainEqual({ kind: 'postcode', value: '10117' });
|
||||
expect(Object.values(card.phones!).map((p) => p.number)).toContain('+49 30 7777');
|
||||
expect(card.onlineServices).toEqual(probeCard().onlineServices);
|
||||
expect(card.notes!.n2).toEqual({ note: 'second note' });
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// Fossify rewrites everything but the name; nothing uploads, the rows come back whole.
|
||||
rows = h.rowsByKey(rawId);
|
||||
h.device.user.fossifySave(rawId, null, Object.values(rows)
|
||||
.filter((r) => r[Data.MIMETYPE] === MimeType.EMAIL || r[Data.MIMETYPE] === MimeType.PHONE)
|
||||
.map((r) => ({ [Data.MIMETYPE]: r[Data.MIMETYPE], data1: r.data1, data2: r.data2 })));
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
await expectSettled(h, id, rawId);
|
||||
expect(h.rowsByKey(rawId)['organizations:o1|titles:t1,r1'].data5).toBe('R&D');
|
||||
|
||||
// The server edits and removes things.
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/nk/name': 'Probo', 'links/l1': null, 'addresses/home/components': [{ kind: 'locality', value: 'Hamburg' }] });
|
||||
await h.download(id);
|
||||
await expectSettled(h, id, rawId);
|
||||
rows = h.rowsByKey(rawId);
|
||||
expect(rows['nicknames:nk'].data1).toBe('Probo');
|
||||
expect(rows['links:l1']).toBeUndefined();
|
||||
expect(rows['addresses:home']).toMatchObject({ data7: 'Hamburg', data4: null });
|
||||
});
|
||||
|
||||
it('follows Stalwart re-keying the whole card by position, without rewriting a row', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const before = h.rowsByKey(rawId);
|
||||
h.server.rekeyPositionally(JMAP, id, 'ContactCard');
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
for (const op of plan.ops.ops) {
|
||||
if (op.op === 'update' && op.table === 'data') expect(Object.keys(op.values).every((c) => c.startsWith('data_sync'))).toBe(true);
|
||||
expect(op.op === 'insert' || op.op === 'delete').toBe(false);
|
||||
}
|
||||
await h.applyOk(plan.ops);
|
||||
const after = h.rowsByKey(rawId);
|
||||
expect(after['organizations:k1|titles:k1,k2']._id).toBe(before['organizations:o1|titles:t1,r1']._id);
|
||||
expect(after['emails:k2']._id).toBe(before['emails:x/y']._id);
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// The next device edit patches the entry under its new key.
|
||||
h.device.user.updateData(after['emails:k2']._id as number, { data1: 'slash3@example.org' });
|
||||
const upload = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(upload).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/k2/address': 'slash3@example.org' } }] });
|
||||
});
|
||||
|
||||
it('writes a blob-backed photo from the bytes the engine fetched, and nothing for its echo', async () => {
|
||||
const h = new Harness();
|
||||
h.blobs.set('B1', JPEG);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } } });
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
expect((await h.contact(rawId)).shadow?.media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('keeps the photo row when the photo bytes could not be fetched, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
h.blobs.set('B1', JPEG);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, emails: { e1: { address: 'a@example.org' } }, media: { p: { kind: 'photo', blobId: 'B1' } } });
|
||||
const photoRow = h.rowsByKey(rawId)['media:p']._id;
|
||||
h.blobs.delete('B1');
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'b@example.org' });
|
||||
await h.download(id);
|
||||
expect(h.rowsByKey(rawId)['media:p']?._id).toBe(photoRow);
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'c@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/e1/address': 'c@example.org' } }] });
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(plan.kind === 'upload' && Object.keys((plan.actions[0] as { patch: object }).patch)).toEqual(['emails/e1/address']);
|
||||
});
|
||||
|
||||
it('keeps a photo too large for a provider batch on the server only, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
// About 900 KB of JPEG: the fake provider refuses a batch over 1 MB, as Binder does.
|
||||
const big = 'A'.repeat(1_200_000);
|
||||
const { id, rawId } = await h.seed({
|
||||
name: { full: 'Big Photo' },
|
||||
emails: { e1: { address: 'a@example.org' } },
|
||||
media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } },
|
||||
});
|
||||
// The contact is there, without the photo; its echo writes nothing.
|
||||
expect(Object.keys(h.rowsByKey(rawId)).sort()).toEqual(['emails:e1', 'name']);
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual({ p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } });
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// A photo set on the device replaces it.
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/p/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/p/mediaType': 'image/jpeg' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.device.photo(Number(h.rowsByKey(rawId)['media:p'].data14))).toBe(JPEG2);
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('writes a photo up to the budget of a provider batch, and none beyond it', async () => {
|
||||
const h = new Harness();
|
||||
const seedPhoto = (b64: string) => h.seed({ name: { full: 'Sized' }, media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${b64}` } } });
|
||||
const fits = 'A'.repeat(MAX_PHOTO_BASE64);
|
||||
const within = await seedPhoto(fits);
|
||||
expect(h.device.photo(Number(h.rowsByKey(within.rawId)['media:p'].data14))).toBe(fits);
|
||||
await expectSettled(h, within.id, within.rawId);
|
||||
const beyond = await seedPhoto('A'.repeat(MAX_PHOTO_BASE64 + 4));
|
||||
expect(h.rowsByKey(beyond.rawId)['media:p']).toBeUndefined();
|
||||
await expectSettled(h, beyond.id, beyond.rawId);
|
||||
});
|
||||
|
||||
it('never reads a photo whose bytes could not be fetched as deleted, and writes it once they can be', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({
|
||||
name: { full: 'Blob Photo' },
|
||||
emails: { e1: { address: 'a@example.org' } },
|
||||
media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } },
|
||||
});
|
||||
expect(h.rowsByKey(rawId)['media:p']).toBeUndefined();
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
|
||||
|
||||
// With the bytes at hand, the next write of the card brings the photo.
|
||||
h.blobs.set('B1', JPEG);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'c@example.org' });
|
||||
await h.download(id);
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('does not read a server photo it could not write as deleted after merging it into an edited contact', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Merge Photo' }, emails: { e1: { address: 'a@example.org' } }, phones: { p1: { number: '1' } } });
|
||||
// The server adds a photo too large to write while the device edits the phone.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['phones:p1']._id as number, { data1: '2' });
|
||||
const big = 'A'.repeat(1_200_000);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}` } } });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'phones/p1/number': '2' } }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('a small photo the provider keeps as its thumbnail only (no PHOTO_FILE_ID)', () => {
|
||||
const photo = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
|
||||
const small = () => ({ name: { full: 'Photo Smalltest' }, emails: { e1: { address: 'small@example.org' } }, notes: { n1: { note: 'v1' } }, media: photo });
|
||||
const photos = (h: Harness, rawId: number) => h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.PHOTO]);
|
||||
|
||||
it('keeps one row through downloads and merges, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(small());
|
||||
expect(photos(h, rawId)).toMatchObject([{ data14: null, data_sync1: 'media:ph' }]);
|
||||
await expectSettled(h, id, rawId);
|
||||
for (const note of ['v2', 'v3']) {
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': note });
|
||||
await h.download(id);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
}
|
||||
// A merge into an edited contact.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'small2@example.org' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': 'v4' });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
// An edit of another field uploads alone.
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'small2@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual(photo);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
await expectSettled(h, id, rawId);
|
||||
// A photo set on the device (it gets a display photo) replaces it.
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/ph/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/ph/mediaType': 'image/jpeg' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('heals the photo rows the same photo was inserted as again, keeping one and the server photo', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(small());
|
||||
const [row] = photos(h, rawId);
|
||||
const again = { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG }, data_sync1: row.data_sync1, data_sync2: row.data_sync2, data_sync3: row.data_sync3 };
|
||||
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
|
||||
expect(photos(h, rawId)).toHaveLength(3);
|
||||
// An edit on the device uploads without touching the photo, and its accepted write keeps one row.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'v2 edited on device' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'notes/n1/note': 'v2 edited on device' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
expect(h.card(id).media).toEqual(photo);
|
||||
await expectSettled(h, id, rawId);
|
||||
// An unchanged card downloaded again (a full reconcile) heals too.
|
||||
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('heals a baseline the provider stored differently from the prediction', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Dr. Jane Q. Public' } });
|
||||
const name = h.rowsByKey(rawId).name;
|
||||
// The real NameSplitter knows titles and middle names; the prediction does not.
|
||||
await h.applyOk({ ref: 'split', ops: [{ op: 'update', table: 'data', id: name._id as number, values: { data4: 'Dr.', data2: 'Jane', data5: 'Q.', data3: 'Public' } }] });
|
||||
const heal = h.planner.planBaselineHeal(await h.contact(rawId));
|
||||
expect(heal?.ops.map((o) => o.op)).toEqual(['assert', 'update']);
|
||||
await h.applyOk(heal!);
|
||||
await expectSettled(h, id, rawId);
|
||||
h.device.user.star(rawId);
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('does not heal a dirty contact: its edit must still count', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'edited' });
|
||||
expect(h.planner.planBaselineHeal(await h.contact(rawId))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType, RawContacts } from '../../android-columns';
|
||||
import type { ContactCardWire } from '../../planner';
|
||||
import { appleCard, googleCard, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** A seeded card, edited on the device (in place, like AOSP) and on the server. */
|
||||
async function bothEdited(
|
||||
make: () => Record<string, unknown>,
|
||||
device: (h: Harness, rawId: number) => void,
|
||||
server: Record<string, unknown>,
|
||||
) {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(make());
|
||||
device(h, rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, server);
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
return { h, id, rawId, plan };
|
||||
}
|
||||
|
||||
const edit = (key: string, values: Record<string, string | number | null>) => (h: Harness, rawId: number) =>
|
||||
h.device.user.updateData(h.rowsByKey(rawId)[key]._id as number, values);
|
||||
|
||||
async function patchOf(h: Harness, rawId: number) {
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
|
||||
}
|
||||
|
||||
describe('contacts merge: a dirty contact meets a new server version', () => {
|
||||
it('lets the server win a real conflict and counts it', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/address': 'theirs@example.com' });
|
||||
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false, effect: 'update' });
|
||||
expect(h.rowsByKey(rawId)['emails:k1'].data1).toBe('theirs@example.com');
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('keeps a local edit of one unit and takes the server edit of another', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k2/address': 'anna@new.example' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new.example');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
|
||||
});
|
||||
|
||||
it('treats the same change on both sides as converged', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'same@example.com' }), { 'emails/k1/address': 'same@example.com' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('does not count a server change to a sub-field Android does not show', async () => {
|
||||
const { h, id, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/contexts': { private: true, school: true } });
|
||||
expect(plan.conflicts).toBe(0);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'mine@example.com' });
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).emails!.k1).toMatchObject({ address: 'mine@example.com', contexts: { private: true, school: true } });
|
||||
});
|
||||
|
||||
it('deletes a row the server deleted, over a local edit (counted)', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k3', { data1: '+1 555 0177' }), { 'phones/k3': null });
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId)['phones:k3']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts back an entry deleted on the device that the server changed (counted)', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
|
||||
{ 'phones/k3/number': '+1 555 0188' },
|
||||
);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId)['phones:k3'].data1).toBe('+1 555 0188');
|
||||
});
|
||||
|
||||
it('keeps a device deletion of an entry the server left alone', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
|
||||
{ 'emails/k2/address': 'anna@new.example' },
|
||||
);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const patch = await patchOf(h, rawId) as Record<string, unknown>;
|
||||
expect(Object.keys(patch)).toEqual(['phones']);
|
||||
expect(Object.keys(patch.phones as object)).toEqual(['k1', 'k2']);
|
||||
});
|
||||
|
||||
it('still uploads a photo deleted on the device after merging a server edit of another entry', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
probeCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['media:ph']._id as number),
|
||||
{ 'emails/zz9/address': 'new@example.org' },
|
||||
);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(await patchOf(h, rawId)).toEqual({ media: null });
|
||||
});
|
||||
|
||||
it('matches an entry both sides added instead of duplicating it', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.insertData(raw, { [Data.MIMETYPE]: MimeType.EMAIL, data1: 'both@example.com', data2: 3 }),
|
||||
{ 'emails/k3': { address: 'both@example.com' } },
|
||||
);
|
||||
expect(plan.conflicts).toBe(0);
|
||||
const emails = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.EMAIL]);
|
||||
expect(emails.filter((r) => r.data1 === 'both@example.com')).toHaveLength(1);
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('inserts what the server added next to a pending local edit', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k3': { address: 'third@example.com' } });
|
||||
expect(plan.stillDirty).toBe(true);
|
||||
expect(h.rowsByKey(rawId)['emails:k3'].data1).toBe('third@example.com');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
|
||||
});
|
||||
|
||||
it('follows shifted keys and uploads the local edit under the new key', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k2', { data1: 'anna@work2.example' }), {
|
||||
emails: {
|
||||
k1: { address: 'first@example.com' },
|
||||
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k3: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
});
|
||||
expect(plan.conflicts).toBe(0);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k3'].data1).toBe('anna@work2.example');
|
||||
expect(rows['emails:k1'].data1).toBe('first@example.com');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k3/address': 'anna@work2.example' });
|
||||
});
|
||||
|
||||
it('merges the name as one unit: a typed name on the server beats a local component edit', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('name', { data2: 'Annie', data1: 'Annie Apfel' }), { 'name/full': 'A. Apfel' });
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'A. Apfel', data2: 'Anna' });
|
||||
});
|
||||
|
||||
it('never gives keys to a kind an editor rewrote, so a nickname it could not show is not deleted later', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(googleCard());
|
||||
// Fossify keeps one nickname, re-inserted without its key.
|
||||
h.device.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.NICKNAME, data1: 'Gü' }]);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/k1/name': 'Guenther' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
const nick = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.NICKNAME]);
|
||||
expect(nick.map((r) => [r.data1, r.data_sync1])).toEqual([['Guenther', null]]);
|
||||
const patch = await patchOf(h, rawId);
|
||||
expect(typeof patch === 'object' && Object.keys(patch).some((k) => k.startsWith('nicknames'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the rows of a contact deleted on the device alone: the delete wins and uploads', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k1/address': 'x@example.com' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
// Only the shadow follows the card, for the deletion's upload.
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
expect(plan.ops.ops.filter((o) => o.op !== 'assert')).toEqual([{
|
||||
op: 'update', table: 'raw_contacts', id: rawId, expectCount: 1,
|
||||
values: { [RawContacts.SYNC2]: expect.any(String) },
|
||||
}]);
|
||||
await h.applyOk(plan.ops);
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx)).toMatchObject({ effect: 'none', ops: { ops: [] } });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ kind: 'destroy', id }] });
|
||||
});
|
||||
|
||||
it('replaces the rows of a different object behind the same id (another uid)', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
edit('emails:k1', { data1: 'mine@example.com' })(h, rawId);
|
||||
const other = { ...(googleCard() as object), id, addressBookIds: { [h.book]: true } } as unknown as ContactCardWire;
|
||||
const plan = h.planner.planDownload(other, await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k1']).toBeUndefined();
|
||||
expect(rows['nicknames:k1'].data1).toBe('Gü');
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user