# 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. Capacitor packages and adapters load only on `capacitor` shells via dynamic `import()` so Electron/desktop startup never evaluates `@capacitor/*` modules. - 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 ```bash # 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) Release workflow `.gitea/workflows/release-draft.yml` builds a debug Android APK on every push to `main` / `master` (job `build-android`), stages it as `Toju--android-debug.apk`, and uploads it to the same draft Gitea release as the desktop `.exe` / `.deb` assets via `tools/gitea-release.js`. Manual-only workflow `.gitea/workflows/build-android-apk.yml` (**workflow_dispatch**) repeats the same build and release upload on demand from any branch. Both jobs install JDK 21 and Android SDK platform 36 inside the `node:22` container and run `tools/build-android-apk.sh`. 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`, `@capawesome/capacitor-app-update`, 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) | | Store app updates | **Working (partial)** | `@capawesome/capacitor-app-update` via `MobileAppUpdateService`; Android in-app updates when Play allows, iOS opens App Store | ## 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`, `MODIFY_AUDIO_SETTINGS`, `CAMERA`, and foreground-service permissions are granted on Android 13+. 6. Verify `MobilePushRegistrationService` logs a registration token after login. ### Android runtime permissions (voice / camera) Capacitor's WebView requests `RECORD_AUDIO` **and** `MODIFY_AUDIO_SETTINGS` together for microphone capture. If `MODIFY_AUDIO_SETTINGS` is missing from `AndroidManifest.xml`, users can accept the prompt and `getUserMedia` still fails. Declared in `toju-app/android/app/src/main/AndroidManifest.xml`: | Permission | Purpose | |------------|---------| | `RECORD_AUDIO` | Microphone capture for voice calls and channels | | `MODIFY_AUDIO_SETTINGS` | Required by Capacitor WebChromeClient alongside `RECORD_AUDIO` | | `CAMERA` | WebRTC camera sharing and WebView file capture | | `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) | | `POST_NOTIFICATIONS` | Incoming/active call notifications | | `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session | Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. ### 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: ```http POST /api/users/device-tokens Content-Type: application/json { "userId": "", "platform": "android|ios", "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): ```http 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` - `MODIFY_AUDIO_SETTINGS` - `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__` 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`, `AppUpdate`, `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: ```bash 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. - `ChatMessageComposerComponent` — `shouldShowAttachmentButton` + `pickAttachmentsFromDevice()`. - `VoiceWorkspaceStreamTileComponent` — PiP when focused stream tile backgrounds. - `MobileCallSessionService` — CallKit + foreground service + in-call notifications. - `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring. - `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests. ## 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.