180 lines
13 KiB
Markdown
180 lines
13 KiB
Markdown
# 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.
|