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
audiobackground 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:
getDisplayMediais 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.appmay not expose/api/servers/featuredor/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.
- Create a Firebase project and add an Android app with package
com.metoyou.app. - Copy
toju-app/android/app/google-services.json.exampletogoogle-services.json(gitignored) and fill in your Firebase values. - Run
npm run cap:syncso the Google Services Gradle plugin applies when the file is present (build.gradleapplies it only when the JSON exists). - Rebuild with
npm run cap:build:android. - Ensure
POST_NOTIFICATIONS,RECORD_AUDIO, and foreground-service permissions are granted on Android 13+. - Verify
MobilePushRegistrationServicelogs a registration token after login.
iOS (APNs)
- Enable Push Notifications capability in Xcode for the
Apptarget. - Upload your APNs key/certificate in Apple Developer portal.
Info.plistincludesremote-notification,audio, andvoipbackground modes.- 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_SERVICEFOREGROUND_SERVICE_MICROPHONERECORD_AUDIOPOST_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/sqliteexecute()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>viamobile-sqlite-database-name.rules.ts. - First launch runs DDL migrations stored in the
metatable. 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.tsuses static@capacitor/*imports andCapacitor.isPluginAvailable()before returning a plugin. Do notimport()plugin modules dynamically orawaitplugin objects (Capacitor proxies expose a throwing.then()stub).- After adding or upgrading Capacitor plugins, run
npm run build:prod && npm run cap:syncso Android/iOS native projects registerApp,LocalNotifications, push, and SQLite.
Safe area (Android)
- Capacitor
SystemBarsinjects--safe-area-inset-*CSS variables intodocument.documentElement.index.htmlsetsviewport-fit=coverand default inset values;main.tscallsapplyMobileSafeAreaDefaults()so injection never hits a missing root element after the WebView loads. capacitor.config.tssetsplugins.SystemBars.insetsHandling: 'css'so Android WebView versions that mis-reportenv(safe-area-inset-*)still receive correct insets.- Global
styles.scssapplies inset padding onhtml(withenv()fallback) and sizesapp-roottoheight: 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
-
Trust: Install
.certs/localhost.crton 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. -
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.shRestart the signaling server after regenerating certs.
-
HTTPS only:
AndroidManifest.xmlsetsandroid:usesCleartextTraffic="false". Server URLs must usehttps://(matchingenvironment.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.ChatMessageComposerComponent—shouldShowAttachmentButton+pickAttachmentsFromDevice().VoiceWorkspaceStreamTileComponent— PiP when focused stream tile backgrounds.MobileCallSessionService— CallKit + foreground service + in-call notifications.Appbootstrap — initializes mobile persistence, lifecycle, call-session, and push registration wiring.
Phase 3 completion notes
Phase 3 delivered:
- Full
CapacitorDatabaseServiceCRUD withDatabaseServicerouting onisCapacitor. - Server SQLite persistence for device tokens plus FCM/APNs outbound dispatch.
- iOS CallKit bridge (partial) via
MetoyouMobileplugin andMobileCallKitService. - Android Firebase Gradle wiring with
google-services.json.example(real file gitignored). - Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
- 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.