# 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/<variant>/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<String,String>`, 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<PollingPluginIface>('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 <ip>:<pair-port> <code>`
3. `adb connect <ip>:<connect-port>`

The pair port and the connect port are different — don't mix them up.
