feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions

View File

@@ -124,6 +124,7 @@ Behavioral changes to any of these qualify as a feature-doc update under the rul
- `release-draft.yml` — queues release builds on push to `main` / `master`
- `publish-draft-release.yml` — publishes draft releases
- `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs
- `build-android-apk.yml` — builds a debug Capacitor Android APK on push (mobile-related paths) or manual dispatch; uploads `app-debug.apk` as a workflow artifact
- All checks must pass before merging a PR
- Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs

View File

@@ -9,6 +9,7 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
## Feature list (alphabetical)
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.

View File

@@ -0,0 +1,228 @@
# 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.