Files
Toju/agents-docs/features/mobile-capacitor.md

13 KiB

Mobile Capacitor

Cross-context mobile shell for the Angular product client (toju-app/). Wraps the existing SPA in Ionic Capacitor native projects (toju-app/android/, toju-app/ios/) while keeping Capacitor APIs behind toju-app/src/app/infrastructure/mobile/.

Responsibilities

  • Detect runtime shell (browser, capacitor, electron) without importing native plugins in domain code.
  • Expose facades for notifications, in-call controls, media/attachments, stream pop-out, background audio session, CallKit, and native persistence.
  • Integrate with direct-call, voice-workspace, and chat composer flows.

Boundaries

Layer Owns
infrastructure/mobile/ Platform detection, plugin lazy-loading, web/Capacitor adapters
infrastructure/persistence/ DatabaseService routing (browser / capacitor-sqlite / electron)
Domains (direct-call, chat, voice-session) Business orchestration; inject mobile facades only
core/platform/PlatformService Adds isCapacitor flag for persistence routing
Capacitor native projects OS permissions, push certificates, store packaging

Build & run

# Production web bundle (Capacitor webDir)
npm run build:prod

# Copy web assets into native projects
npm run cap:sync

# Open IDE
npm run cap:open:android
npm run cap:open:ios

### Linux: Android Studio path

Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.

# Convenience (build + sync + open)
npm run cap:build:android
npm run cap:build:ios

# CI / Linux: production web bundle + Capacitor sync + Gradle debug APK
npm run cap:apk:android
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk

Config: toju-app/capacitor.config.ts (webDir: ../dist/client/browser).

CI (Gitea)

Workflow .gitea/workflows/build-android-apk.yml runs automatically on push to main / master when mobile client paths change (toju-app/**, root lockfile, or the workflow itself). Use workflow_dispatch to build on demand from any branch.

The job installs JDK 21 and Android SDK platform 36 inside the node:22 container, runs tools/build-android-apk.sh, and uploads metoyou-android-debug-apk (app-debug.apk) as a workflow artifact. No signing keystore is configured — output is a debug APK suitable for sideloading and QA.

Optional google-services.json is not injected in CI; push registration in artifact builds follows the same optional-Firebase behavior as local unsigned debug builds.

After dependency or plugin changes, run npm run build:prod && npm run cap:sync so native projects register @capacitor/app, @capacitor-community/sqlite, push plugins, and MetoyouMobile.

Feature status

Feature Status Notes
Push/local notifications Working (partial) Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without google-services.json
Server push dispatch Working (configured) Tokens persist in server SQLite; outbound FCM/APNs via env credentials
In-call notifications Working (Capacitor) Persistent notification with answer/mute/hang-up actions
Stream pop-out (PiP) Working (partial) Document PiP when WebView supports it; Android native PiP fallback via MetoyouMobile plugin
Background voice Working (partial) Android foreground service; iOS UIBackgroundModes audio + CallKit active-call bridge
iOS CallKit Working (partial) MetoyouMobile.startCallKitSession reports active calls; requires Xcode target wiring after cap:sync
Screensharing Limited Disabled on iOS WebView; Android getDisplayMedia may work
Composer attachments Working Mobile attachment button + hidden file input
Camera sharing Working Existing getUserMedia camera path in WebRTC stack
Speakerphone Working (partial) Android AudioManager via MetoyouMobile; iOS @capgo/capacitor-audio-session; direct-call speaker toggle on native mobile
Local DB (SQLite) Working DatabaseService routes Capacitor shells to CapacitorDatabaseService (native SQLite CRUD)

Platform limitations

  • iOS background WebRTC: OS may still suspend peer connections when backgrounded despite audio background mode and CallKit reporting.
  • iOS CallKit: Plugin Swift source ships in ios/App/App/MetoyouMobilePlugin.swift; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
  • iOS screenshare: getDisplayMedia is not available in WKWebView.
  • Android PiP: Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
  • Production discovery: signal.toju.app may not expose /api/servers/featured or /trending; client skips those calls for known hosts.
  • Push delivery: Requires FCM service account and APNs key configuration on the signaling server.

Push notification setup (FCM / APNs)

Android (FCM)

The app starts without Firebase. MobilePushRegistrationService probes MetoyouMobile.isRemotePushConfigured() (Firebase FirebaseApp on Android) before calling PushNotifications.register(); when unconfigured it logs a single warning and skips registration.

  1. Create a Firebase project and add an Android app with package com.metoyou.app.
  2. Copy toju-app/android/app/google-services.json.example to google-services.json (gitignored) and fill in your Firebase values.
  3. Run npm run cap:sync so the Google Services Gradle plugin applies when the file is present (build.gradle applies it only when the JSON exists).
  4. Rebuild with npm run cap:build:android.
  5. Ensure POST_NOTIFICATIONS, RECORD_AUDIO, and foreground-service permissions are granted on Android 13+.
  6. Verify MobilePushRegistrationService logs a registration token after login.

iOS (APNs)

  1. Enable Push Notifications capability in Xcode for the App target.
  2. Upload your APNs key/certificate in Apple Developer portal.
  3. Info.plist includes remote-notification, audio, and voip background modes.
  4. Run on a physical device; simulator push registration is limited.

Server token storage & dispatch

Clients POST:

POST /api/users/device-tokens
Content-Type: application/json

{ "userId": "<uuid>", "platform": "android|ios", "token": "<fcm-or-apns-token>" }

Tokens persist in server SQLite (device_tokens table). Outbound push uses repository-root .env credentials:

Variable Purpose
FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON Android FCM HTTP v1
APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID iOS APNs HTTP/2
APNS_BUNDLE_ID Defaults to com.metoyou.app
APNS_USE_SANDBOX true for development builds

Manual dispatch (ops/testing):

POST /api/users/device-tokens/:userId/dispatch
{ "title": "Incoming call", "body": "Alice is calling" }

Android foreground service

VoiceCallForegroundService starts when MobileCallSessionService begins an active call. Required manifest permissions:

  • FOREGROUND_SERVICE
  • FOREGROUND_SERVICE_MICROPHONE
  • RECORD_AUDIO
  • POST_NOTIFICATIONS

The service shows a low-importance ongoing notification while a call is active.

SQLite persistence (Capacitor)

  • Schema rules: infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts (mirrors Electron entities).
  • Statement execution: infrastructure/mobile/logic/mobile-sqlite-execute.rules.ts@capacitor-community/sqlite execute() accepts one SQL statement per call; migrations run each DDL statement separately (never concatenated).
  • Row mapping: infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts.
  • CRUD service: infrastructure/persistence/capacitor-database.service.ts.
  • Routing: infrastructure/persistence/database-backend.rules.ts — Capacitor uses SQLite, not IndexedDB.
  • Per-user database files: metoyou__<userId> via mobile-sqlite-database-name.rules.ts.
  • First launch runs DDL migrations stored in the meta table. Schema init failures are cached per database file so the client does not retry in a loop.

Capacitor plugin loading

  • infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts uses static @capacitor/* imports and Capacitor.isPluginAvailable() before returning a plugin. Do not import() plugin modules dynamically or await plugin objects (Capacitor proxies expose a throwing .then() stub).
  • After adding or upgrading Capacitor plugins, run npm run build:prod && npm run cap:sync so Android/iOS native projects register App, LocalNotifications, push, and SQLite.

Safe area (Android)

  • Capacitor SystemBars injects --safe-area-inset-* CSS variables into document.documentElement. index.html sets viewport-fit=cover and default inset values; main.ts calls applyMobileSafeAreaDefaults() so injection never hits a missing root element after the WebView loads.
  • capacitor.config.ts sets plugins.SystemBars.insetsHandling: 'css' so Android WebView versions that mis-report env(safe-area-inset-*) still receive correct insets.
  • Global styles.scss applies inset padding on html (with env() fallback) and sizes app-root to height: 100% so content stays below the status bar and above the navigation bar in edge-to-edge mode.

Self-hosted HTTPS signal servers (Android)

Electron and desktop browsers accept the repo's self-signed .certs/localhost.crt because Electron runs with ignore-certificate-errors when SSL=true, and browsers let users bypass the warning once. Android WebView does neither — it only trusts system CAs (release) or system + user-installed CAs (debug builds).

Runtime Trust behavior
Electron (SSL=true) Ignores certificate errors (electron/app/flags.ts)
Browser User accepts warning or imports CA
Android debug APK System CAs + user-installed CAs (src/debug/res/xml/network_security_config.xml)
Android release APK System CAs only — use Let's Encrypt or another public CA

Certificate requirements

  1. Trust: Install .certs/localhost.crt on the Android device as a CA certificate (Settings → Security → Encryption & credentials → Install a certificate → CA certificate). Debug APKs pick this up automatically; release builds ignore user CAs.

  2. SAN: The cert must list every host clients use. Regenerate with the server IP in the SAN when connecting by IP:

    rm -rf .certs
    SERVER_IP=46.59.68.77 ./generate-cert.sh
    

    Restart the signaling server after regenerating certs.

  3. HTTPS only: AndroidManifest.xml sets android:usesCleartextTraffic="false". Server URLs must use https:// (matching environment.ts / saved server endpoints).

Troubleshooting

Symptom Likely cause
ERR_CERT_AUTHORITY_INVALID / silent fetch failure CA not installed on device, or testing a release APK with a self-signed cert
ERR_CERT_COMMON_NAME_INVALID Cert SAN missing the IP/hostname (regenerate with SERVER_IP)
ERR_CONNECTION_REFUSED Port unreachable from the phone (firewall, NAT, server not listening on 0.0.0.0) — verify with curl -k https://46.59.68.77:3001/api/health from the device browser first
Works in Chrome on phone, fails in app Chrome may use a different trust store path; ensure the CA is installed at the system level, not only per-browser

Network security configs:

  • android/app/src/main/res/xml/network_security_config.xml — release (system CAs, no cleartext)
  • android/app/src/debug/res/xml/network_security_config.xml — debug (+ user CAs for dev)

Do not commit .certs/*.crt, .certs/*.key, or device-specific credential files.

Integration points

  • DirectCallService — incoming/active call notifications, ring-queue on user hydration, notification action routing.
  • PrivateCallComponent — speakerphone toggle on native mobile shells.
  • ChatMessageComposerComponentshouldShowAttachmentButton + pickAttachmentsFromDevice().
  • VoiceWorkspaceStreamTileComponent — PiP when focused stream tile backgrounds.
  • MobileCallSessionService — CallKit + foreground service + in-call notifications.
  • App bootstrap — initializes mobile persistence, lifecycle, call-session, and push registration wiring.

Phase 3 completion notes

Phase 3 delivered:

  1. Full CapacitorDatabaseService CRUD with DatabaseService routing on isCapacitor.
  2. Server SQLite persistence for device tokens plus FCM/APNs outbound dispatch.
  3. iOS CallKit bridge (partial) via MetoyouMobile plugin and MobileCallKitService.
  4. Android Firebase Gradle wiring with google-services.json.example (real file gitignored).
  5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
  6. Discovery endpoint skip for production signal hosts without featured/trending routes.

Remaining work:

  • Wire CallKit answer/end actions back into DirectCallService.
  • Migrate legacy IndexedDB mobile data into SQLite where needed.
  • Deploy featured/trending routes to production signal servers or add capability negotiation in health checks.