vojo/docs/ai/android.md

180 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.