Commit Graph
7 Commits
Author SHA1 Message Date
Linus Rath e67e240840 feat: ask android to sync when contacts or calendars change #34
With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:

- The app's own contact and calendar edits, StateChanges on the live
  stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
  and the app coming back to the foreground request a sync of that
  account and authority. Requests within 5 s fold into one, and a
  StateChange the engine caused itself (its own upload echoing back) is
  skipped.
- While the app is closed, the account's push subscription carries
  those types too. The FCM and UnifiedPush services hand every push to
  the native router from f743ab0, which requests the syncs, and start
  the mail push task only when a push has `EmailDelivery`; the task also
  returns early for such pushes, so a contact change can never show the
  "newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
  checks Android's accounts against its own: an account removed in
  Android Settings shows sync off and is not recreated, and one whose app
  account is gone stops syncing and posts "Sign in again", keeping its
  data.

The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.

Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
2026-09-27 04:36:51 +02:00
Linus Rath a948738506 feat: map contacts and groups to android's contacts provider #34
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.
2026-09-27 04:23:22 +02:00
Linus Rath 3ed5fa53af feat: map calendars and events to android's calendar provider #34
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.
2026-09-27 04:21:30 +02:00
Linus Rath 96a6b38321 feat: sync the device's contacts and calendars with the server in the background #34
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.
2026-09-27 04:19:21 +02:00
Linus Rath f743ab0f7d feat: give device sync scoped access to the contacts and calendar providers #34
The engine plans its provider reads and writes in JS, but only native
code can reach ContactsProvider and CalendarProvider. This adds that
bridge to the BulwarkDeviceSync module, with the rest of what the engine
and the app need from it:

- `query`, `applyBatch`, `readSyncState` and `readPhoto` take the
  `ProviderQuery`/`ProviderOp[]` JSON of `src/device-sync/types.ts`,
  check it (`sync/ProviderOps.kt`) and run it for one Android account of
  our type (`sync/ProviderScope.kt`, `sync/ProviderIo.kt`). Every
  selection gets the account ANDed in, every row an op names by id or
  points to as a parent must be the account's, and every URI carries
  `caller_is_syncadapter`, so nothing the sync writes marks rows dirty or
  schedules an upload. The calls run one at a time on the module's own
  thread through an unstable provider client, so a long batch never
  stalls other module calls and a provider that dies fails the call
  instead of the app.
- `showSyncProblem` and `clearSyncProblem` post and clear the one
  notification per account that needs the user, e.g. "Sign in again".
- `setPushRoutes` stores which Android accounts each JMAP account feeds,
  for the push router in `sync/PushRouting.kt`; the push services start
  using it in a later commit.
- A run now ends exactly once and refuses a report that comes after
  that. A sync requested while a run is open makes the run ask for
  another sync when it ends, because SyncManager drops such requests as
  duplicates. `onPerformSync` lets nothing escape any more: an exception
  there killed the whole process, UI included.
- `ensureAccount` no longer hands an existing Android account to another
  app account (`conflict`): its rows and SyncState belong to the first.

Payloads cross as JSON strings, which the bridge moves much faster than
nested maps. `src/device-sync/native.ts` is the typed JS side. The fake
provider follows the rules the emulator runs turned up: contacts
`data1`..`data14` read back as text, events and calendars the sync
adapter inserts have DIRTY NULL, attendee rows are addressed by id, an
op by id expects one row, and a vanished parent fails with `assert`
while another account's fails with `scope`.

Checked: 54 JVM tests (`gradlew :app:testDebugUnitTest`), debug and
release (R8) builds, `npm run typecheck` and `npm test` on this commit,
and on the API 32 emulator through the debug receiver: inserts with
back-references and group memberships, SyncState, photo writes and
scaled reads, asserts and expected counts, both scope refusals, the
499-op yield boundary, `tooLarge` for a 1.3 MB batch, and the
notification.
2026-09-27 04:04:00 +02:00
Linus Rath 59fcf386b9 test: add a fake device provider and jmap server for the sync tests #34
The device sync engine reaches Android's providers through a native
bridge and the server through a detached JMAP client, and neither runs
in vitest. This adds the stand-ins the engine and the mappers are tested
against, and the contracts they implement:
- `src/device-sync/types.ts`, `planner.ts`, `wire.ts`,
  `android-columns.ts`: the bridge, the provider op model, the JMAP
  port, and the interface between the engine and the contacts and
  calendar planners
- a fake ContactsProvider and CalendarProvider with the behaviour the
  design relies on: the DIRTY and VERSION rules, soft and hard deletes,
  exception linking, normalisation, asserts, the yield-point limit, and
  a "user" face that edits the way AOSP Contacts, Fossify and Etar do
- a fake Stalwart with `/changes` paging and compaction,
  `cannotCalculateChanges`, `ifInState`, uid checks behind a lagging
  search index, positional re-keying and fault injection (lost
  responses, 429, auth, SetErrors)
- shared pure helpers (canonical JSON, PatchObjects with Stalwart's
  missing-parent rule, key and uid minting) and a test that walks the
  import graph, so no mapper or engine module pulls in React Native
- `src/stores/device-sync-store.ts`: the per-account preferences and
  last run status the engine and the settings will share

Checked: `npx vitest run src/device-sync` (91 tests), the full suite
(2209 tests) and `npm run typecheck`.
2026-09-27 02:53:22 +02:00
Linus Rath ed2525e5cb feat: register a bulwark account with android's contacts and calendar sync #34
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
2026-09-27 01:53:40 +02:00