229 lines
13 KiB
Markdown
229 lines
13 KiB
Markdown
# 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
|
|
|
|
```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)
|
|
|
|
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:
|
|
|
|
```http
|
|
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):
|
|
|
|
```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`
|
|
- `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:
|
|
|
|
```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, 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.
|