vojo/docs/ai/android.md

13 KiB
Raw Blame History

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.tsappId: chat.vojo.app, webDir: dist
  • android/ — 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. 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.
  • 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:

  • 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 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: 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.