This is the planner for contacts (`src/device-sync/contacts`), the last
piece the sync task was waiting for: it turns JSContact cards into raw
contacts and data rows, and device edits back into JMAP writes.
- Names, nicknames, organizations and titles, emails, phones, postal
addresses, websites, birthdays and anniversaries (also without a
year), relations, notes and photos map both ways, with their types and
custom labels. Group cards become groups, and their members become
group memberships.
- Device edits upload as patches of the fields whose rows changed; a
whole map goes up only where Stalwart needs one. Whatever a card holds
that Android can't show (pronouns, online services, keywords, a second
note, an address's coordinates) is never touched, so an edit on the
phone leaves it intact on the server.
- Rows are matched to their card's entries by the keys they carry and,
where an editor re-inserted them without (Fossify saves that way), by
their values. An entry the editor didn't show is never read as
deleted, and the keys Stalwart assigns by position to cards from other
clients are followed when they shift.
- Contacts in read-only address books are put back when the device
changes them. Text beyond what the provider stores is cut on the device
and never uploaded.
Checked: 151 contacts tests (mappings both ways, one test per edited
field, Fossify and AOSP editing, re-keyed cards, merges, groups,
read-only books, a full lifecycle), the engine's tests with both real
planners, and `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit.
The engine hands every event it syncs to a planner that turns it into
CalendarContract rows, and device edits back into JMAP writes. This is
that planner for calendars (`src/device-sync/calendar`), now used by the
sync task:
- Calendars become calendar rows with what the apps need (SYNC_EVENTS=1,
an owner, colours, the reminders allowed); calendars of shared accounts
carry the account's name.
- Events keep their time zone, floating and all-day times, recurrence
rules, excluded and changed instances, reminders, attendees and the
user's own reply. A rule Android can't hold (RSCALE, SKIP, leap months)
stays on the server untouched, and local times Stalwart can't resolve
(a start in a repeated hour, an UNTIL in a skipped one) are written in
a form it keeps.
- Device edits upload as patches of what differs from the last synced
state, never a whole event, and only what the device can show is ever
sent. A single-instance edit or deletion becomes an override of its
series; "this and following" caps the series and creates a new one.
How Etar and Fossify save (a cancelled instance instead of an
exclusion, attendee rows deleted and re-inserted) is read as the edit
it stands for, not as a deletion. Invitations go out on the changes a
calendar app would notify about.
- Events in read-only calendars and subscribed feeds are put back when
the device changes them; only the user's own reply goes up, where the
server allows it. Tasks, task-only calendars and the birthday calendar
never reach the device.
- Reminders are written as Reminders rows when the calendar app is to
remind the user, and left out when Bulwark does.
Checked: 179 calendar tests (download, upload, exceptions, the edits
Etar, Fossify and CONTENT_EXCEPTION_URI make, echoes that write nothing,
time zone and DST edges), and `npm run typecheck`, `npm test` and `npm
run i18n:check` on this commit.
The sync adapters from ed2525e start a headless task for every sync, but
the task only reported "disabled". This adds the engine it runs
(`src/device-sync/engine`, `src/device-sync/jmap`): for one Android
account and authority it downloads the server's changes, uploads what
the device changed, and hands a report back to SyncManager.
- Downloads follow `/changes` from the state kept in the provider's
SyncState. Rows and the state they reach are written in one batch, so
a killed run resumes where it stopped. Without a state, after
`cannotCalculateChanges` or after the user discarded deletions, a full
reconcile compares ids instead.
- Uploads go in three phases across accounts (creates, updates,
deletions), so moving an item to another account never destroys it
before the copy exists. A create carries a uid written to the device
first, and the server is asked for that uid before creating, so a lost
response or a killed run never makes a duplicate. Updates are patches
guarded by `ifInState`; nothing is replaced whole.
- More than 50 deletions that are also more than a fifth of the synced
items hold every deletion back and report `tooManyDeletions`, so
Android asks the user; "undo" restores the items from the server.
- An item the server refuses is marked and not sent again until it
changes or its back-off (1 hour, doubling to a day) ends. Such
refusals and planner errors stay with their item; the rest of the run
goes on.
- Runs of one account and authority take a lock, stop at checkpoints
when cancelled or close to the deadline, and never throw: every
failure becomes an outcome, and a rejected sign-in posts one "Sign in
again" notification.
- The JMAP side uses a client of its own per app account, never the
UI's, since a run can share the JS runtime with the app.
Turning sync off or signing out runs the same upload first
(`teardownAuthority`) and deletes the rows only once nothing is waiting,
or when the user agreed to lose it.
The planners that map items to rows land in the next commits; until
then a run fails before it touches a row.
Checked: 94 engine tests against the fake provider and JMAP server
(sync flows, failures, crashes and retries, teardown), and `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit.
Android syncs the contacts and calendars of other services through
accounts and sync adapters. This adds the Android side of device sync:
a "Bulwark" account type (`${applicationId}.account`, set in
build.gradle), an authenticator that sends Settings → Add account to
the app instead of asking for credentials, and one sync adapter each
for the contacts and the calendar provider.
An adapter does no syncing itself. It starts the BulwarkDeviceSync
headless JS task from the main thread, through the helper the push job
uses, waits for the task's report and hands it to SyncManager as the
SyncResult. The engine that will write the report runs in JS, so the
existing client stays the only owner of token refresh, mTLS and
session discovery; for now the task only reports that device sync is
off. Nothing in the app creates an account yet, so the feature stays
unreachable until its settings land. Debug builds get an adb hook for
the device tests (DeviceSyncDebugReceiver, shell only).
Hard errors are reported without progress: SyncManager retries a
failed sync at once when it made progress, which would loop an auth
failure.
Checked on the API 34 and API 32 emulators with the debug build, from
logcat and `dumpsys content`:
- app in the foreground: report after 319-334 ms
- app in the background: 320-327 ms
- process killed: SyncManager starts it for the sync service, React
Native starts headless, and the report arrives after 16.2 s (API 34)
and 8.5 s (API 32), most of it the debug bundle download from Metro
- an io report is retried with back-off and an auth report is not
retried; too many deletions shows the system's "Attempted to delete
too many Contacts." notification
- with Metro unreachable the adapter gives up after 2 s instead of
waiting for its deadline
- `gradlew :app:testDebugUnitTest`: 10 JVM tests for the report mapping