13 KiB
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 inandroid/local.properties
Config
capacitor.config.ts—appId: chat.vojo.app,webDir: distandroid/— generated Android Studio project,targetSdkVersion 36,compileSdkVersion 36,minSdkVersion 24
Build scripts
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, mirroring resolveAppVersion() in 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.
resolveServiceWorkerRequestsdefaulttrue. - Edge-to-edge.
EdgeToEdge.enable()inMainActivity.java+windowLayoutInDisplayCutoutMode: shortEdges. - External links. Opened via
@capacitor/browserplugin — seesrc/app/utils/capacitor.ts. - Safe-area coloring.
bodybackground-color is bound to the folds theme variablevar(--oq6d070)for consistent safe-area coloring. - Safe-area insets. Applied on
#root(notbody) so the theme background extends behind the system bars.
VSCode tasks
See .vscode/tasks.json:
Deploy to vojo.chat(Ctrl+Shift+D) — web deployDeploy 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 for the
full rationale of why this is the only viable shape).
Components:
| Layer | File | Role |
|---|---|---|
| Worker | 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 |
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 |
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 |
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 |
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 |
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), 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:
saveSessionseedsKEY_LAST_SEEN_TStoSystem.currentTimeMillis() - 60son first write, so the Worker doesn't render every historical unread/notificationsentry as a fresh push. The 60s buffer tolerates device-clock drift ahead of the homeserver (eventtsis 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 guardnm.notifywould throwSecurityExceptionper 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_RUNbefore reaching the watermark, the Worker saves the leftovernext_tokenasKEY_DRAIN_CURSORAND snapshots the head ts of the first run asKEY_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.CONNECTEDconstraint 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
/notificationsand 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_userissuesORDER BY stream_ordering DESC). The Matrix spec for/notificationsdoes not formally mandate this ordering, so if Vojo ever migrates to a homeserver implementation that paginates oldest- first (Conduit, Dendrite, …) thets < watermarkbreak would clip new events. Revisit the Worker before any such migration. - Already-read events (user read on another client) are skipped via the
readfield on each/notificationsentry; their ts still advances the watermark so they don't get re-walked next poll. - Muted rooms:
actionsarray on each/notificationsentry is consulted; events withoutnotify(i.e.dont_notifyfrom 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:
readAllaccumulates 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 theSessionLoggedOutlistener inClientRoot.tsxall callpolling.cancel()+polling.clearSession()synchronously beforewindow.location.replace, so the Worker can't fire one more time with the stale access_token.cancel()awaits the WorkManagerOperationso a fast disable → re-enable cycle doesn't race theKEEPpolicy. 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
- On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
adb pair <ip>:<pair-port> <code>adb connect <ip>:<connect-port>
The pair port and the connect port are different — don't mix them up.