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.
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.
AOSP Contacts, and the apps built on its account-type parser, check the
EditSchema in `xml/contacts.xml` and drop the whole account type when it
breaks one of their rules: every data kind with a type column needs
`<Type>` children, the other kinds must have none, and name, photo,
nickname, organization, note and group membership need maxOccurs="1".
The schema from ed2525e listed phone, email, postal, event and
relationship without types, so the editors would not have offered the
Bulwark account at all.
The kinds now list the types device sync maps to JSContact, and the
rules are written down in the file so the next edit keeps them.
Checked on the API 32 emulator (AOSP Contacts): with a Bulwark account
present, "Create new contact" offers it and opens the editor saving to
it.
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`.
Issue #34 asks for contacts and calendars that show up in Android's own
apps and whose edits there reach the server. docs/device-sync.md is the
design the implementation follows: Android's sync framework drives the
runs, a thin Kotlin shell starts a headless JS task, and the engine is
TypeScript on a detached JMAP client, so token refresh, mTLS and session
discovery keep a single owner. A pure-Kotlin engine was rejected: it
would have had to redeem rotating refresh tokens outside that owner and
re-implement the client.
It pins down what every provider column holds, the JSContact and
JSCalendar mapping tables, how device edits are told apart from provider
normalisation (a baseline per row), how rows are matched when an editor
re-inserts them, the merge and conflict rules, uploads as patches with
`ifInState`, the native API, the failure matrix and the limitations.
The platform and server facts it relies on come from AOSP's providers
and calendar/contacts apps, Fossify, Etar and Stalwart v0.16.23's
source. These were checked on the local test server:
- map keys written through JMAP survive patches; `uid` duplicates are
refused; unknown and plural recurrence properties are rejected
- a start or `until` in a DST gap or overlap is silently dropped (a
daily rule came back unbounded); `Etc/GMT-2` keeps both
- `ifInState` yields `stateMismatch`; `relatedTo/<key>: null` does not
delete; result references to `/set` are refused
An adversarial review of the first draft found 20 paths that could lose
data, duplicate items or loop (own creates duplicated after a lost
response, unsynced edits dropped on deselection or eviction, Fossify
rewrites uploaded as edits, Etar moves turned into destroy + create,
and more); the design was changed for each before any engine code.
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
The code that starts React Native for a headless JS task, when the
process was woken without any UI, lived inside BulwarkPushJobService.
The device sync adapters need the same start, so it moves to a shared
HeadlessJs object, with a small startTask helper next to it.
Push behaviour does not change:
- onStartJob still calls the start code on the main thread, and the
listener-then-ready-check order is the same code, moved verbatim
- the task still starts from `UiThreadUtil.runOnUiThread`, its id is
still recorded in that same UI-thread turn, and a refusal because the
app came to the foreground still logs and finishes the job
- the start code now returns React Native's start task, so a caller can
notice a start that fails (a debug build with no Metro); the push job
ignores it
The helper documents that it must run on the main thread: the ReactHost
is created on first use without a lock, so a background caller racing
MainActivity could create a second one.
Checked with `gradlew assembleDebug -PreactNativeArchitectures=x86_64`.
Release APKs are obfuscated since R8 was turned on, and nothing kept
the mapping that decodes their stack traces. The workflow now copies
`android/app/build/outputs/mapping/release/mapping.txt` to
`bulwark-mobile-<version>-<commit>-mapping.txt`, uploads it as a
workflow artifact and attaches it to the release next to the APK. The
in-app updater only picks `.apk` assets, so it ignores the new file.
If R8 is on (`android.enableMinifyInReleaseBuilds=true`) and no mapping
was written, the step fails instead of publishing an APK whose crashes
cannot be read. docs/android-release.md explains how to decode a trace
with `retrace`.
The splash background was #09090b in both modes, so in light mode a
cold start went from a near-black splash to the white first frame.
It is now #ffffff by day and #09090b at night (`values-night`), as
`splash.backgroundColor` and `splash.dark.backgroundColor` in
app.config.js already ask for, so a later `expo prebuild` writes the
same colours. Checked on the API 35 emulator: light mode stays white
from splash to login screen, dark mode is unchanged.
Every background push kept the app awake for the full 30 s timeout of
the BulwarkPushTask headless task, however quickly the task was done.
AppRegistry reports a finished task through the native
HeadlessJsTaskSupport module, and React Native 0.81 registers that
module only for the old bridge (CoreModulesPackage); the bridgeless
runtime's CoreReactPackage leaves it out. JS could never say it was
done, so the service, or the job, and its wake lock waited for the
timeout. BulwarkHeadlessJsTaskSupportModule provides the module under
the same name when the app runs bridgeless.
The push path also leaked a wake lock now and then. startHeadlessTask
called HeadlessJsTaskService.acquireWakeLockNow on the FCM worker
thread while the service took the same static lock on the main thread.
The method is not thread-safe, so both could create a lock, and the one
that was overwritten stayed held. The FCM thread now takes its own
wake lock, timed at 10 s, and the service drops it as soon as it holds
React Native's.
Measured on the API 35 emulator with the R8 release build:
- push handled by the job: 30,000 ms before, 75-155 ms now
- push handled by the service: the service ran 31 s before, it is
gone within a second now
- `com.facebook.react.i` wake lock still held 5 s after a push: 2 of
4 pushes before, 0 of 8 now
A push that arrived while the app was in the background started
BulwarkPushTaskService with `startService`. Android 8+ allows that only
while the app is temporarily allowlisted, which a high-priority FCM
message grants. The relay does send FCM data messages at HIGH priority,
but FCM delivers some at normal priority anyway (it downgrades apps
whose high-priority messages rarely end in a notification), and a
UnifiedPush distributor may not raise the app at all. The call then
threw `BackgroundServiceStartNotAllowedException` and the app crashed,
from the FCM thread or, for UnifiedPush, on the main thread.
The service start is kept as the first choice, so a high-priority push
works as before. When Android refuses it, the same headless task now
runs from a JobScheduler job (BulwarkPushJobService): expedited on
Android 12+, which runs at once while the app has expedited-job quota
and as a regular job after that, and a regular job on Android 8-11.
JobScheduler is a platform API, so nothing is added to the build;
WorkManager is not in the dependency tree. A generic "new mail"
notification built natively was the other option, but the relay's
payload cannot tell new mail from other changes and the native side
knows neither the notification settings nor what was already shown.
Checked on the API 35 emulator with the R8 release build, the process
killed and the push delivered past the allowlist:
- FCM data message: before, FATAL EXCEPTION on
Firebase-Messaging-Intent-Handle; now the job is scheduled 18 ms
after the refusal and runs the task
- UnifiedPush message (connector 3.3.5, sent the way a distributor
does): before, FATAL EXCEPTION on main; now the same job path
- allowlisted FCM message: still starts BulwarkPushTaskService
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
The files screen filled `files.selected_count` and
`files.delete_items_title` with its own `{name}` substitution, a
leftover from before t() took arguments, so they could not be
plurals. Both are ICU plurals now and every message on the screen gets
its values through t(). The upload progress line is one message
instead of "Uploading..." followed by the numbers.
The bottom tabs showed their route names ("Mail", "Calendar", …) in
every language; they now use the webmail's navigation names, and an
unavailable tab says so with one translated message instead of an
English "(unavailable)" suffix. The first loading screen is
translated as well.
The Unified Inbox selection toolbar, its back and clear-search buttons,
and the drawer's close, expand/collapse and remove-account buttons get
screen reader labels. Its unknown-sender fallback, selection count and
"account(s) could not be loaded" banner are proper translations and
plurals now.
The key harvest and the translation coverage test did not look at
App.tsx, which sits next to src/; they do now.
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
The week view's "all-day" label and its "Untitled" fallback were
hard-coded; they now use the catalog's "All day" and "(No title)" like
the scrolling time grid. The calendar's menu, tasks, view switch, new
event and previous/next buttons get screen reader labels (the view
buttons also say which one is selected), and a calendar i18n test
covers the calendar views.
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
A quick reply held by the undo-send delay could not be undone: the
undo bar lived only on the mail list, and the quick reply never
recorded the held send for it.
- The undo bar is now hosted on every stack screen through the
navigator's `screenLayout`, next to the toasts, and only the focused
screen's renders, so it shows wherever the send started and never
twice. The list's own instance is gone; archive, delete, move and
spam undo use the same bar as before. The composer gets none, since
its Undo reopens the composer.
- The quick reply records a held send through the same store action as
the composer, including the account it went out of, so Undo and
Send now reach a shared account's submission and Undo restores the
reply into that account's Drafts.
- The quick reply dismisses the keyboard after sending, which would
otherwise cover the bar.
Release builds had R8 and resource shrinking off, so every class of
every dependency went into the APK unminified: the published 0.1.62 is
42.8 MB for arm64, 25.85 MB of it dex (PF9 in the audit).
Turn both on through the properties build.gradle already reads
(`android.enableMinifyInReleaseBuilds` and
`android.enableShrinkResourcesInReleaseBuilds`). The consumer rules of
React Native, Expo, Firebase, ML Kit and the other libraries cover what
they load by reflection. The app's own package is kept whole, which
costs a few KB and keeps its frames readable in an obfuscated stack
trace.
Measured on a local x86_64 release build:
- APK: 44.7 MB -> 38.0 MB (download size 23.8 MB -> 17.2 MB)
- dex: 30.4 MB in 4 files -> 11.3 MB in 2 files
- classes: 27,631 -> 16,724
With `-dontobfuscate` the APK would be 38.8 MB (13.5 MB of dex), so
obfuscation stays on. Library frames in a release stack trace are now
obfuscated; retrace them with that build's
`android/app/build/outputs/mapping/release/mapping.txt`.
Checked on the emulator: cold start in light and dark mode, German and
Russian strings, SENDTO and VIEW mailto and SEND share intents, every
sign-in screen, the QR scanner with ML Kit, and an FCM data message
starting the headless push task.
Tick the parity items the fix pass finished (server-side recurrence,
filters and vacation for shared accounts, in-app file preview, the
trusted-senders toggle, UnifiedPush, sender taps in the viewer), and
note on the already ticked ones what this pass changed, such as the tag
view and search reaching shared accounts or contact photos as avatars.
Items that only moved forward say what is left.
Fix hints and "verified" lines that named removed functions
(`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`,
`buildRecurrenceOverridePatch`) or the old whole-map keyword writes now
point at what replaced them.
PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are
done, 36 open.
Apply the audit's "Stale parity checklist" section to docs/parity:
- ticked items the audit found broken keep their tick and name the
commit that fixed them; the date-locale item is unticked again, since
there is still no `dateLocale` setting
- the items that were done but unticked are ticked with where they
landed
- natural-language event input, print contact and the Sieve editor
gutter move to the N/A lists
- the text about linking, push types, RTL, `useTypography`, the Sieve
"Keep" and `FileNode/get ids:null` now describes main
The audit gains a "Fix pass, 2026-09-24" section after the summary. It
says how the pass was done and tested, and lists what the fix agents
could not settle from the code alone:
- the device and server checks that matter before a release
- the product calls, starting with the release signing key and secrets
that have to exist before the next version bump
- the follow-ups the agents left, one line each
Every audit item a commit on main fixed is ticked with the commit that
fixed it, including the "fix these ten first" list and the stale parity
entries that the fixes resolved. Items fixed only in part keep their box
and say what is left; U8, PF9, B2 and the product calls stay open.
The filter rule reorder, delete and remove-condition/action buttons,
the filter, Sieve and folder editor close buttons and the attachment
keyword remove buttons had no label. The Sieve editor's line count now
uses an ICU plural, and the settings i18n test covers every pane.
The Notifications pane shows a push setup failure as "<phase>: <message>"
with a translated phase but an English message, and the open-link
confirmation and the new-mail notification title were English too.
They now go through the locale store's t() (translate() in the
background task), with the technical detail passed as an argument.
The sign-in screen showed the auth store's raw English error ("Session
expired", "Invalid username or password") as the notice title. It now
runs it through describeLoginError like every other sign-in failure,
which also learns to explain an expired session with the webmail's
login.session_expired copy. The back and close buttons of the sign-in
steps get screen reader labels, and the email placeholders use the
webmail's translated example address.
Group recipient chips, the group size in suggestions, the upload
progress and the upload/send failure fallbacks were English or glued
together from a translated word and a number; they now use ICU
messages. The close, attach, schedule and sender-options buttons, the
recipient and attachment remove buttons and the From override switch
get screen reader labels (audit U8).
The filter sheet, the .eml import result, the selection count, the
folder totals and the offline sync banner were English literals or
built by gluing translated words to numbers. They now use t() with ICU
arguments and plurals; filter chips go through a "{field}: {value}"
message so translators control the order. The invitation trust
warnings used keys that no catalog had, so they are now literal t()
calls and harvested.
For screen readers (audit U8) the hamburger, select-all checkbox,
filter, sort and compose buttons, the selection toolbar, search and
chip clear buttons, and the close buttons of the move, tag, template,
identity, QR and action sheets get translated labels and roles.
TemplateSettings calls the translate function `tr` because `t` is a
template variable there, and both the key harvester and the coverage
test only looked for `t('...')`. Ten of its keys were never added to
locales/rn/en.json, so they could not be translated and the test did
not notice. Both now also match `tr('...')`, and the missing keys are
added.
The share sheet asked for files.share_role_* keys that no catalog has,
so Viewer, Editor and Manager stayed English everywhere; it now uses
the webmail's sharing.preset names and its "Share \"{name}\"" title.
The per-file menu, back and clear-selection buttons get screen reader
labels, and the "sharing is not available" error is translated.
The contact list, detail and edit screens, the group screen, the
import, picker and tag sheets and the contacts drawer were written in
English, including the activity panel ("Unknown", "Upcoming events"),
alert titles and every form label and option. They now go through
t(), reusing the webmail's contacts.* keys where the meaning matches,
with ICU plurals for member, contact and import counts instead of
`contact${n === 1 ? '' : 's'}`. JSContact context, phone feature,
personal-info and gender values on the detail screen are shown with
their translated labels instead of the raw keys.
Icon-only buttons on these screens (back, edit, more, bulk actions,
remove, close, clear search) get screen reader labels, and chips,
checkboxes and collapsible sections report their state.
A settings switch had a role and a checked state but no label, so
TalkBack only announced "switch, off". A switch inside a SettingItem now
takes that row's label, and the switches outside one (filter rules,
expanded view, template and sidebar app editors, filter rule options)
get their visible label passed explicitly.
The Contacts and Content & Senders panes, the swipe behaviour row in
Layout, the device time zone option in Calendar and the identity editor
placeholders were written as English literals, so they stayed English
in every language. They now go through t(), reusing the webmail's
settings.contacts, contacts.address_books and
settings.email_behavior.trusted_senders keys where the wording matches.
Counts use ICU plurals instead of `contact${n === 1 ? '' : 's'}`, and the
address book icon buttons get screen reader labels.
The settings i18n test now parses each pane with the TypeScript compiler
and also flags JSX text, alerts and accessibility labels, and covers
the newly cleaned panes.