# Android (Capacitor) ## Requirements - Node >= 22 - JDK 17+ (21 used in practice) - Android SDK with platform 36 + build-tools 36.0.0 - SDK location: `/usr/lib/android-sdk`, also set in `android/local.properties` ## Config - [`capacitor.config.ts`](../../capacitor.config.ts) — `appId: chat.vojo.app`, `webDir: dist` - `android/` — generated Android Studio project, `targetSdkVersion 36`, `compileSdkVersion 36`, `minSdkVersion 24` ## Build scripts ```bash npm run build:android:debug # full chain: build → sync → debug APK npm run build:android:release # full chain: build → sync → release APK npm run build:android:aab # full chain: build → sync → release AAB npm run android:sync # sync dist/ → android assets npm run android:apk:debug # gradle debug build only ``` **APK output**: `android/app/build/outputs/apk/debug/app-debug.apk` ## Versioning `versionCode` and `versionName` are derived from `git describe --tags --match 'v*'` in [`android/app/build.gradle`](../../android/app/build.gradle), mirroring `resolveAppVersion()` in [`vite.config.js`](../../vite.config.js) so the APK's `versionName` matches `__APP_VERSION__` shown in About. Tag is `v0.2.0`; `patch` is the commit count since that tag (e.g. `v0.2.0-87-g…` → versionName `0.2.87`). When git is unavailable, falls back to `package.json` `version`. ``` versionCode = major * 1_000_000 + minor * 1_000 + patch ``` ## Key architecture decisions - **Bundled build.** `dist/` is copied into the APK — not loaded remotely in a WebView. - **Service Worker stays active.** Critical for authenticated Matrix media (MSC3916 / Matrix spec v1.11+). DO NOT disable. `resolveServiceWorkerRequests` default `true`. - **Edge-to-edge.** `EdgeToEdge.enable()` in `MainActivity.java` + `windowLayoutInDisplayCutoutMode: shortEdges`. - **External links.** Opened via `@capacitor/browser` plugin — see [`src/app/utils/capacitor.ts`](../../src/app/utils/capacitor.ts). - **Safe-area coloring.** `body` background-color is bound to the folds theme variable `var(--oq6d070)` for consistent safe-area coloring. - **Safe-area insets.** Applied on `#root` (not `body`) so the theme background extends behind the system bars. ## VSCode tasks See [`.vscode/tasks.json`](../../.vscode/tasks.json): - `Deploy to vojo.chat` (Ctrl+Shift+D) — web deploy - `Deploy to Android (ADB)` (Ctrl+Shift+A) — build + `adb install` ## Push string resources (generated) Push notification text for Android is generated from `public/locales/{en,ru}.json` (namespace `Push`) by `scripts/gen-push-strings.mjs`. The Gradle build runs this automatically via `GeneratePushStringsTask` registered in `android/app/build.gradle` through AGP `addGeneratedSourceDirectory` — output goes to `build/generated/res/push//values{,-ru}/push_strings.xml`. No manual step needed; `./gradlew assembleDebug` handles it. The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`). ## Push polling fallback (WorkManager) Users on networks that block FCM (`mtalk.google.com:5228` — corporate, school and government whitelist intranets, ~5% of our audience) get zero pushes from the primary channel. To cover them we run a WorkManager periodic poll of `/_matrix/client/v3/notifications` as a parallel best-effort delivery channel. Always on whenever push is enabled — there's no smart-detect-and-switch (FCM gives no client-visible delivery receipts; see [push_unifiedpush_phase1.md §11](../plans/push_unifiedpush_phase1.md) for the full rationale of why this is the only viable shape). Components: | Layer | File | Role | |---|---|---| | Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. | | Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. | | Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. `eventId.hashCode()` slot — Android replaces in place when both paths deliver the same event AND both surfaces are still visible. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. | | Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. | | JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. | | Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. | Why polling is rendered as **missed call** (not CallStyle) for ring events: the `m.rtc.notification` lifetime is 30 seconds; polling runs at the 15-minute floor of `PeriodicWorkRequest`. Every ring observed by the Worker is already stale and the live call long over — rendering CallStyle with ringtone would phantom-ring a dead call. Missed-call style preserves the "you missed a call from X" signal without the wrong UX. Live-call delivery for whitelist users remains a gap; closing it requires a non-FCM live channel (UnifiedPush, see the stale plan above). Why we do not need a refresh-token flow: Vojo's homeserver is vanilla Synapse without MAS/OIDC (see [server-side.md](server-side.md)), so access tokens are long-lived. A 401 from the Worker logs out the credentials slot and waits for the next foreground app launch to re-bridge — no native refresh-token logic required. If we ever migrate to MAS, the Worker needs a refresh path. Why our source manifest does not declare `RECEIVE_BOOT_COMPLETED`: WorkManager's library manifest already declares the permission and the `RescheduleReceiver`, which the manifest merger folds into the merged manifest. Reboot persistence works end-to-end without our app re-declaring anything. Apps only need to add the permission themselves when they listen for `BOOT_COMPLETED` for their own purposes. Edge cases handled: - Token rotation (post-MAS migration): currently not bridged from JS to native on token-rotate events. JS re-saves credentials on every lifecycle re-mount AND on visibilitychange → visible, so user-driven re-open recovers within seconds. After a 401 the Worker clears its credentials slot; after a 403 it leaves credentials alone and just skips the cycle (403 is most often a transient rate-limit, not a dead token). - First fire after install / re-login: `saveSession` seeds `KEY_LAST_SEEN_TS` to `System.currentTimeMillis() - 60s` on first write, so the Worker doesn't render every historical unread `/notifications` entry as a fresh push. The 60s buffer tolerates device-clock drift ahead of the homeserver (event `ts` is server-side); without it a fast-clock device would silently skip fresh events as "older than watermark". - POST_NOTIFICATIONS revoked at runtime: Worker bails early on `NotificationManagerCompat.areNotificationsEnabled() == false`. Without this guard `nm.notify` would throw `SecurityException` per event, leave the LRU and watermark unadvanced, and re-walk the same backlog every 15 minutes until the user re-grants permission. - Worker > 10 minutes (Android kill timer): bounded by `MAX_PAGES_PER_RUN=5` × `PAGE_LIMIT=50` + 30s HTTP timeout per call. Cannot exceed ~3 minutes in normal operation. Most polls touch only a single page because the ts watermark short-circuits the loop. - Large backlog (>250 events accumulated while offline): when a single fire hits `MAX_PAGES_PER_RUN` before reaching the watermark, the Worker saves the leftover `next_token` as `KEY_DRAIN_CURSOR` AND snapshots the head ts of the first run as `KEY_DRAIN_TARGET_TS`. Subsequent fires resume from that cursor instead of head; the target ts is the fast-forward destination for the watermark when drain finally completes — without it, the bounded LRU could evict head events and let the post-drain normal run re-render them. - Network unavailable: `NetworkType.CONNECTED` constraint skips the run; next cycle retries. - Doze: WorkManager honours maintenance windows. No catch-up — only the next scheduled fire delivers the accumulated backlog. The Worker walks from the head of `/notifications` and stops as soon as it reaches the watermark, so a Doze-extended gap just produces a larger first-page walk. - Pagination assumes newest-first ordering (Vojo runs vanilla Synapse, whose `get_push_actions_for_user` issues `ORDER BY stream_ordering DESC`). The Matrix spec for `/notifications` does not formally mandate this ordering, so if Vojo ever migrates to a homeserver implementation that paginates oldest- first (Conduit, Dendrite, …) the `ts < watermark` break would clip new events. Revisit the Worker before any such migration. - Already-read events (user read on another client) are skipped via the `read` field on each `/notifications` entry; their ts still advances the watermark so they don't get re-walked next poll. - Muted rooms: `actions` array on each `/notifications` entry is consulted; events without `notify` (i.e. `dont_notify` from a mute push rule) are skipped. Without this, the mute toggle wouldn't actually mute polling- delivered notifications even though Sygnal honours it for FCM. - User in foreground: Worker doesn't render system notifications while `MainActivity.isInForeground` (live timeline owns UX). State still advances so events don't replay on the next backgrounded poll. - FCM + polling double delivery: NotificationDedup is the single source of truth — FCM service and Worker both write to it after successful render, both read it before posting. Even if the user dismisses an FCM-delivered notification before polling fires, the Worker skips it. - UTF-8 multi-byte boundaries: `readAll` accumulates raw bytes and decodes the full buffer once, never per-chunk; otherwise a Cyrillic character straddling an 8 KB read boundary would become U+FFFD. - Logout race: `initMatrix.ts::logoutClient`, `clearLocalSessionAndReload`, and the `SessionLoggedOut` listener in `ClientRoot.tsx` all call `polling.cancel()` + `polling.clearSession()` synchronously before `window.location.replace`, so the Worker can't fire one more time with the stale access_token. `cancel()` awaits the WorkManager `Operation` so a fast disable → re-enable cycle doesn't race the `KEEP` policy. The lifecycle effect's unmount cleanup repeats the same calls as belt-and-suspenders. Cleanups invoked symmetrically across every logout path: `useDisablePushNotifications`, `logoutClient`, `clearLocalSessionAndReload`, the `SessionLoggedOut` listener, and the lifecycle effect's unmount all call `polling.cancel()` + `polling.clearSession()`. ## ADB wireless workflow 1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code. 2. `adb pair : ` 3. `adb connect :` The pair port and the connect port are different — don't mix them up.