diff --git a/.env.example b/.env.example index 8d5b43c..32aa19d 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,15 @@ # When false: plain HTTP everywhere (only works on localhost) # Overrides server/data/variables.json for local development only SSL=true + +# --- Mobile push dispatch (signaling server) --- +# Android FCM HTTP v1 (choose one) +# FCM_SERVICE_ACCOUNT_PATH=/absolute/path/to/firebase-service-account.json +# FCM_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."} + +# iOS APNs HTTP/2 (.p8 key from Apple Developer) +# APNS_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +# APNS_KEY_ID=XXXXXXXXXX +# APNS_TEAM_ID=XXXXXXXXXX +# APNS_BUNDLE_ID=com.metoyou.app +# APNS_USE_SANDBOX=true diff --git a/.gitea/workflows/build-android-apk.yml b/.gitea/workflows/build-android-apk.yml new file mode 100644 index 0000000..89ea573 --- /dev/null +++ b/.gitea/workflows/build-android-apk.yml @@ -0,0 +1,73 @@ +name: Build Android APK + +on: + push: + branches: [main, master] + paths: + - 'toju-app/**' + - 'package.json' + - 'package-lock.json' + - 'tools/build-android-apk.sh' + - '.gitea/workflows/build-android-apk.yml' + workflow_dispatch: + +jobs: + build-android-apk: + runs-on: ubuntu-latest + container: node:22 + + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + + - name: Restore npm cache + uses: https://github.com/actions/cache@v4 + with: + path: /root/.npm + key: npm-android-${{ hashFiles('package-lock.json') }} + restore-keys: npm-android- + + - name: Restore Gradle cache + uses: https://github.com/actions/cache@v4 + with: + path: | + /root/.gradle/caches + /root/.gradle/wrapper + key: gradle-android-${{ hashFiles('toju-app/android/**/*.gradle*', 'toju-app/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-android- + + - name: Install Android build toolchain + run: | + apt-get update + apt-get install -y --no-install-recommends openjdk-21-jdk wget unzip + + export ANDROID_SDK_ROOT=/opt/android-sdk + mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" + cd /tmp + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + unzip -q commandlinetools-linux-11076708_latest.zip + mv cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest" + export PATH="$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" + + yes | sdkmanager --licenses >/dev/null + sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0" + + echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64" >> "$GITHUB_ENV" + echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV" + + - name: Install dependencies + env: + NODE_ENV: development + run: npm ci + + - name: Build debug APK + run: bash tools/build-android-apk.sh + + - name: Upload APK artifact + uses: https://github.com/actions/upload-artifact@v4 + with: + name: metoyou-android-debug-apk + path: toju-app/android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error diff --git a/agents-docs/ENGINEERING.md b/agents-docs/ENGINEERING.md index 1a1244f..3ff240d 100644 --- a/agents-docs/ENGINEERING.md +++ b/agents-docs/ENGINEERING.md @@ -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 diff --git a/agents-docs/FEATURES.md b/agents-docs/FEATURES.md index b4cf167..67fa312 100644 --- a/agents-docs/FEATURES.md +++ b/agents-docs/FEATURES.md @@ -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. diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md new file mode 100644 index 0000000..3a25acc --- /dev/null +++ b/agents-docs/features/mobile-capacitor.md @@ -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": "", "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` +- `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`, `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. diff --git a/generate-cert.sh b/generate-cert.sh index 42375a3..33b8195 100755 --- a/generate-cert.sh +++ b/generate-cert.sh @@ -15,13 +15,21 @@ fi mkdir -p "$CERT_DIR" +# Optional: include a LAN/public IP in the certificate SAN (required when clients connect by IP). +# Example: SERVER_IP=46.59.68.77 ./generate-cert.sh +SAN="DNS:localhost,IP:127.0.0.1,IP:0.0.0.0" +if [ -n "${SERVER_IP:-}" ]; then + SAN="$SAN,IP:$SERVER_IP" + echo "Including SERVER_IP=$SERVER_IP in certificate SAN." +fi + echo "Generating self-signed certificate..." openssl req -x509 -nodes -days 3650 \ -newkey rsa:2048 \ -keyout "$CERT_DIR/localhost.key" \ -out "$CERT_DIR/localhost.crt" \ -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0" + -addext "subjectAltName=$SAN" echo "Done. Certificate written to:" echo " $CERT_DIR/localhost.crt" diff --git a/package-lock.json b/package-lock.json index 6b79486..7b4a957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,15 @@ "@angular/forms": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", + "@capacitor-community/sqlite": "^8.1.0", + "@capacitor/app": "^8.1.0", + "@capacitor/camera": "^8.2.0", + "@capacitor/core": "^8.4.0", + "@capacitor/device": "^8.0.2", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/local-notifications": "^8.2.0", + "@capacitor/push-notifications": "^8.1.1", + "@capgo/capacitor-audio-session": "^8.0.40", "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-json": "^6.0.2", @@ -61,6 +70,9 @@ "@angular/build": "^21.0.4", "@angular/cli": "^21.0.4", "@angular/compiler-cli": "^21.0.0", + "@capacitor/android": "^8.4.0", + "@capacitor/cli": "^8.4.0", + "@capacitor/ios": "^8.4.0", "@eslint/js": "^9.39.3", "@playwright/test": "^1.59.1", "@stylistic/eslint-plugin-js": "^4.4.1", @@ -2737,6 +2749,268 @@ "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@capacitor-community/sqlite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-8.1.0.tgz", + "integrity": "sha512-yhKZDAVPDPcM3QE6UGB3LXyV25a6Rve1SjZ1aUpTE0E2isnYTVM0PG9+JOI241f+NdsHzPTE7ESJiYSqKsKnuA==", + "license": "MIT", + "dependencies": { + "jeep-sqlite": "^2.7.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/android": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", + "integrity": "sha512-K1ZPkQzvRzPEALz9nBdLx5p5nAPzp5fsTYWk7LRiKZeH/NXqjDvqfTv7lrLgrziQNoDeaL6ijg64oBREzXiV+g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.4.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", + "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/camera": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.0.tgz", + "integrity": "sha512-hYfrT6xpL936qoEkIpJzSnb0fQCaTkOux1cXzGBfH8QLOGqr6gSLiWZlZz/fqMPmMKJMNRBqlTQkj5fuMhVZog==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.0.tgz", + "integrity": "sha512-5Z9RKHxiqJYRTLrfMeZmzR4qrlg5B85MxsWZ5goyXsLkO3bgpW9a1qV/6fR1SX9s5gwLza5y7PZVwITl/hDJ7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@capacitor/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@capacitor/cli/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.0.tgz", + "integrity": "sha512-LrS1xPIrqLtJABBIPDGXxxKmI9OyesrzWw8DiHbxhSC9JoiLUleUAJlX1a0LWIVLRbuY4Szgf9huFeRqYH2SAQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/device": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/device/-/device-8.0.2.tgz", + "integrity": "sha512-fIqSXnG0s6bz5A/0xFgSXDkbU+Xl65ti80LhucNvLI4kGhJzcNn6SwWVwpXN9SJTOFWXblXknHNppheP8X1frQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/filesystem": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz", + "integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.0.tgz", + "integrity": "sha512-tnwstEdbTJ2nHAfoAwnurXgYRscWeLY+IIGdz69o24gN2Crfj9Xc0TWo8L5uFLF1LmpbUywH1IT0U1oHV8c+CA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.4.0" + } + }, + "node_modules/@capacitor/local-notifications": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz", + "integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.1.tgz", + "integrity": "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, + "node_modules/@capgo/capacitor-audio-session": { + "version": "8.0.40", + "resolved": "https://registry.npmjs.org/@capgo/capacitor-audio-session/-/capacitor-audio-session-8.0.40.tgz", + "integrity": "sha512-A8suDS9TssaG9QFO9oLvyAysGa8v4G+7pL9QmJ327/jaEzCiUyTe9irAQBPPpjDZfZI17yYURMBkrnnLqUsOtw==", + "license": "MPL-2.0", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", @@ -5696,6 +5970,318 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-fs/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@ionic/utils-fs/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ionic/utils-terminal/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@ionic/utils-terminal/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -11948,6 +12534,133 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stencil/core": { + "version": "4.43.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", + "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.10.0" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" + } + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@stylistic/eslint-plugin-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin-js/-/eslint-plugin-js-4.4.1.tgz", @@ -12846,6 +13559,13 @@ "@types/node": "*" } }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -14938,7 +15658,6 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -15254,6 +15973,16 @@ "node": ">=14.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -15385,6 +16114,19 @@ "license": "MIT", "optional": true }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -15407,6 +16149,12 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz", + "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==", + "license": "Apache-2.0" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -18819,6 +19567,26 @@ "dev": true, "license": "MIT" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/elementtree/node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -22385,6 +23153,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", @@ -22925,6 +23699,19 @@ "node": ">=10" } }, + "node_modules/jeep-sqlite": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jeep-sqlite/-/jeep-sqlite-2.8.0.tgz", + "integrity": "sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==", + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.20.0", + "browser-fs-access": "^0.35.0", + "jszip": "^3.10.1", + "localforage": "^1.10.0", + "sql.js": "^1.11.0" + } + }, "node_modules/jest-diff": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", @@ -23234,6 +24021,48 @@ ], "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/katex": { "version": "0.16.33", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", @@ -23295,6 +24124,16 @@ "node": ">=0.10.0" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -23539,6 +24378,15 @@ } } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -23977,6 +24825,24 @@ "pathe": "^2.0.3" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/localforage/node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -25854,6 +26720,42 @@ "dev": true, "license": "MIT" }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/native-run/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -27053,6 +27955,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -28941,6 +29849,30 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -30817,6 +31749,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -31092,6 +32030,13 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", @@ -31328,6 +32273,16 @@ "wbuf": "^1.7.3" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -32364,6 +33319,16 @@ "tslib": "^2" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -35878,6 +36843,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 4a6ad06..5f401da 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,13 @@ "test:e2e": "cd e2e && npx playwright test", "test:e2e:ui": "cd e2e && npx playwright test --ui", "test:e2e:debug": "cd e2e && npx playwright test --debug", - "test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report" + "test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report", + "cap:sync": "cd toju-app && npx cap sync", + "cap:open:android": "node tools/cap-open-android.js", + "cap:open:ios": "cd toju-app && npx cap open ios", + "cap:apk:android": "bash tools/build-android-apk.sh", + "cap:build:android": "npm run build:prod && npm run cap:sync && npm run cap:open:android", + "cap:build:ios": "npm run build:prod && npm run cap:sync && npm run cap:open:ios" }, "private": true, "packageManager": "npm@10.9.2", @@ -65,6 +71,15 @@ "@angular/forms": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", + "@capacitor-community/sqlite": "^8.1.0", + "@capacitor/app": "^8.1.0", + "@capacitor/camera": "^8.2.0", + "@capacitor/core": "^8.4.0", + "@capacitor/device": "^8.0.2", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/local-notifications": "^8.2.0", + "@capacitor/push-notifications": "^8.1.1", + "@capgo/capacitor-audio-session": "^8.0.40", "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-json": "^6.0.2", @@ -112,6 +127,9 @@ "@angular/build": "^21.0.4", "@angular/cli": "^21.0.4", "@angular/compiler-cli": "^21.0.0", + "@capacitor/android": "^8.4.0", + "@capacitor/cli": "^8.4.0", + "@capacitor/ios": "^8.4.0", "@eslint/js": "^9.39.3", "@playwright/test": "^1.59.1", "@stylistic/eslint-plugin-js": "^4.4.1", diff --git a/server/README.md b/server/README.md index 2c038ce..b23f538 100644 --- a/server/README.md +++ b/server/README.md @@ -22,6 +22,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi - `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, `serverTag`, and `linkPreview`. When `serverTag` is empty, `GET /api/health` falls back to the server's public URL. - `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled. - `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota. +- Mobile push dispatch uses optional credentials from the repository-root `.env` file (see `.env.example`): `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` for Android, and `APNS_KEY_PATH` / `APNS_KEY_ID` / `APNS_TEAM_ID` (+ optional `APNS_BUNDLE_ID`, `APNS_USE_SANDBOX`) for iOS. Device tokens are stored in the SQLite `device_tokens` table via `POST /api/users/device-tokens`. - Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory. diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 526f210..b632409 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -20,7 +20,8 @@ import { ServerPluginEventDefinitionEntity, PluginDataEntity, ServerPluginSettingsEntity, - PluginUserMetadataEntity + PluginUserMetadataEntity, + DeviceTokenEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { @@ -270,7 +271,8 @@ export async function initDatabase(): Promise { ServerPluginEventDefinitionEntity, PluginDataEntity, ServerPluginSettingsEntity, - PluginUserMetadataEntity + PluginUserMetadataEntity, + DeviceTokenEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', diff --git a/server/src/entities/DeviceTokenEntity.ts b/server/src/entities/DeviceTokenEntity.ts new file mode 100644 index 0000000..38862e1 --- /dev/null +++ b/server/src/entities/DeviceTokenEntity.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryColumn, + Column, + Index +} from 'typeorm'; + +@Entity('device_tokens') +@Index('idx_device_tokens_user_id', ['userId']) +export class DeviceTokenEntity { + @PrimaryColumn('text') + id!: string; + + @Column('text') + userId!: string; + + @Column('text') + platform!: 'ios' | 'android'; + + @Column('text') + token!: string; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index b857605..267ba76 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -17,3 +17,4 @@ export type { ServerPluginEventDirection, ServerPluginEventScope } from './Serve export { PluginDataEntity } from './PluginDataEntity'; export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity'; export { PluginUserMetadataEntity } from './PluginUserMetadataEntity'; +export { DeviceTokenEntity } from './DeviceTokenEntity'; diff --git a/server/src/migrations/1000000000010-DeviceTokens.ts b/server/src/migrations/1000000000010-DeviceTokens.ts new file mode 100644 index 0000000..7e502fa --- /dev/null +++ b/server/src/migrations/1000000000010-DeviceTokens.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DeviceTokens1000000000010 implements MigrationInterface { + name = 'DeviceTokens1000000000010'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "device_tokens" ( + "id" TEXT PRIMARY KEY NOT NULL, + "userId" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "token" TEXT NOT NULL, + "updatedAt" INTEGER NOT NULL + ) + `); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_device_tokens_user_id" ON "device_tokens" ("userId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "device_tokens"`); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 1545a3d..3aea51e 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -8,6 +8,7 @@ import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; import { PluginSupport1000000000007 } from './1000000000007-PluginSupport'; import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata'; import { ServerIcons1000000000009 } from './1000000000009-ServerIcons'; +import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens'; export const serverMigrations = [ InitialSchema1000000000000, @@ -19,5 +20,6 @@ export const serverMigrations = [ GameMatchMisses1000000000006, PluginSupport1000000000007, ServerPluginInstallMetadata1000000000008, - ServerIcons1000000000009 + ServerIcons1000000000009, + DeviceTokens1000000000010 ]; diff --git a/server/src/routes/device-tokens.ts b/server/src/routes/device-tokens.ts new file mode 100644 index 0000000..ce5e70a --- /dev/null +++ b/server/src/routes/device-tokens.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { + dispatchPushToUser, + listDeviceTokensForUser, + upsertDeviceToken +} from '../services/push-dispatch.service'; + +export interface DeviceTokenRecord { + userId: string; + platform: 'ios' | 'android'; + token: string; + updatedAt: number; +} + +const router = Router(); + +router.post('/', async (req, res) => { + const { userId, platform, token } = req.body as Partial; + + if (!userId || !token || (platform !== 'ios' && platform !== 'android')) { + return res.status(400).json({ error: 'Missing or invalid userId/platform/token' }); + } + + await upsertDeviceToken({ userId, platform, token }); + + res.status(201).json({ ok: true }); +}); + +router.get('/:userId', async (req, res) => { + const records = await listDeviceTokensForUser(req.params.userId); + + res.json({ + tokens: records.map((record) => ({ + userId: record.userId, + platform: record.platform, + token: record.token, + updatedAt: record.updatedAt + })) + }); +}); + +router.post('/:userId/dispatch', async (req, res) => { + const { title, body, data } = req.body as { + title?: string; + body?: string; + data?: Record; + }; + + if (!title || !body) { + return res.status(400).json({ error: 'Missing title/body' }); + } + + await dispatchPushToUser(req.params.userId, { title, body, data }); + + res.json({ ok: true }); +}); + +export default router; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 8c6ba7c..0da0d34 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -5,6 +5,7 @@ import linkMetadataRouter from './link-metadata'; import gamesRouter from './games'; import proxyRouter from './proxy'; import usersRouter from './users'; +import deviceTokensRouter from './device-tokens'; import serversRouter from './servers'; import pluginSupportRouter from './plugin-support'; import openApiDocsRouter from './openapi-docs'; @@ -18,6 +19,7 @@ export function registerRoutes(app: Express): void { app.use('/api/games', gamesRouter); app.use('/api', proxyRouter); app.use('/api/users', usersRouter); + app.use('/api/users/device-tokens', deviceTokensRouter); app.use('/api', openApiDocsRouter); app.use('/api/servers', pluginSupportRouter); app.use('/api/servers', serversRouter); diff --git a/server/src/services/push-dispatch.service.ts b/server/src/services/push-dispatch.service.ts new file mode 100644 index 0000000..5c9d597 --- /dev/null +++ b/server/src/services/push-dispatch.service.ts @@ -0,0 +1,283 @@ +import fs from 'fs'; +import http2 from 'http2'; +import path from 'path'; +import { + createPrivateKey, + createSign, + sign +} from 'crypto'; +import { getDataSource } from '../db'; +import { DeviceTokenEntity } from '../entities/DeviceTokenEntity'; + +export interface PushNotificationPayload { + title: string; + body: string; + data?: Record; +} + +interface FcmServiceAccount { + project_id: string; + client_email: string; + private_key: string; +} + +let cachedFcmAccessToken: { token: string; expiresAt: number } | null = null; + +function readJsonFile(filePath: string): unknown { + const resolved = path.resolve(filePath); + const raw = fs.readFileSync(resolved, 'utf8'); + + return JSON.parse(raw); +} + +function resolveFcmServiceAccount(): FcmServiceAccount | null { + const inlineJson = process.env.FCM_SERVICE_ACCOUNT_JSON?.trim(); + + if (inlineJson) { + return JSON.parse(inlineJson) as FcmServiceAccount; + } + + const filePath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim(); + + if (filePath) { + return readJsonFile(filePath) as FcmServiceAccount; + } + + return null; +} + +function base64UrlEncode(value: string | Buffer): string { + return Buffer.from(value) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +async function getFcmAccessToken(serviceAccount: FcmServiceAccount): Promise { + if (cachedFcmAccessToken && cachedFcmAccessToken.expiresAt > Date.now() + 60_000) { + return cachedFcmAccessToken.token; + } + + const now = Math.floor(Date.now() / 1000); + const header = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const claimSet = base64UrlEncode(JSON.stringify({ + iss: serviceAccount.client_email, + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600 + })); + const unsigned = `${header}.${claimSet}`; + const signer = createSign('RSA-SHA256'); + + signer.update(unsigned); + signer.end(); + + const signature = base64UrlEncode(signer.sign(serviceAccount.private_key)); + const assertion = `${unsigned}.${signature}`; + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }) + }); + + if (!response.ok) { + throw new Error(`FCM OAuth token request failed with status ${response.status}`); + } + + const payload = await response.json() as { access_token: string; expires_in: number }; + + cachedFcmAccessToken = { + token: payload.access_token, + expiresAt: Date.now() + payload.expires_in * 1000 + }; + + return payload.access_token; +} + +async function sendFcmMessage(token: string, notification: PushNotificationPayload): Promise { + const serviceAccount = resolveFcmServiceAccount(); + + if (!serviceAccount) { + console.warn('[push] FCM credentials are not configured; skipping Android dispatch'); + return; + } + + const accessToken = await getFcmAccessToken(serviceAccount); + const response = await fetch( + `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: { + token, + notification: { + title: notification.title, + body: notification.body + }, + data: notification.data ?? {} + } + }) + } + ); + + if (!response.ok) { + const errorBody = await response.text(); + + throw new Error(`FCM dispatch failed (${response.status}): ${errorBody}`); + } +} + +function resolveApnsConfig(): { + key: string; + keyId: string; + teamId: string; + bundleId: string; +} | null { + const keyPath = process.env.APNS_KEY_PATH?.trim(); + const keyId = process.env.APNS_KEY_ID?.trim(); + const teamId = process.env.APNS_TEAM_ID?.trim(); + const bundleId = process.env.APNS_BUNDLE_ID?.trim() || 'com.metoyou.app'; + + if (!keyPath || !keyId || !teamId) { + return null; + } + + return { + key: fs.readFileSync(path.resolve(keyPath), 'utf8'), + keyId, + teamId, + bundleId + }; +} + +function buildApnsJwt(config: { key: string; keyId: string; teamId: string }): string { + const now = Math.floor(Date.now() / 1000); + const header = base64UrlEncode(JSON.stringify({ alg: 'ES256', kid: config.keyId })); + const claims = base64UrlEncode(JSON.stringify({ iss: config.teamId, iat: now })); + const unsigned = `${header}.${claims}`; + const privateKey = createPrivateKey(config.key); + const signature = sign('sha256', Buffer.from(unsigned), { + key: privateKey, + dsaEncoding: 'ieee-p1363' + }); + + return `${unsigned}.${base64UrlEncode(signature)}`; +} + +async function sendApnsMessage(deviceToken: string, notification: PushNotificationPayload): Promise { + const config = resolveApnsConfig(); + + if (!config) { + console.warn('[push] APNs credentials are not configured; skipping iOS dispatch'); + return; + } + + const host = process.env.APNS_USE_SANDBOX === 'true' + ? 'api.sandbox.push.apple.com' + : 'api.push.apple.com'; + const jwt = buildApnsJwt(config); + const payload = JSON.stringify({ + aps: { + alert: { + title: notification.title, + body: notification.body + }, + sound: 'default' + }, + ...notification.data + }); + + await new Promise((resolve, reject) => { + const client = http2.connect(`https://${host}`); + const request = client.request({ + ':method': 'POST', + ':path': `/3/device/${deviceToken}`, + authorization: `bearer ${jwt}`, + 'apns-topic': config.bundleId, + 'apns-push-type': 'alert', + 'content-type': 'application/json' + }); + + request.setEncoding('utf8'); + request.on('response', (headers) => { + const status = Number(headers[':status'] ?? 0); + + if (status >= 200 && status < 300) { + resolve(); + return; + } + + let body = ''; + + request.on('data', (chunk) => { + body += chunk; + }); + + request.on('end', () => { + reject(new Error(`APNs dispatch failed (${status}): ${body}`)); + }); + }); + + request.on('error', reject); + request.end(payload); + client.on('error', reject); + request.on('close', () => client.close()); + }); +} + +export async function upsertDeviceToken(input: { + userId: string; + platform: 'ios' | 'android'; + token: string; +}): Promise { + const repository = getDataSource().getRepository(DeviceTokenEntity); + const id = `${input.userId}:${input.platform}:${input.token}`; + const record = repository.create({ + id, + userId: input.userId, + platform: input.platform, + token: input.token, + updatedAt: Date.now() + }); + + await repository.save(record); +} + +export async function listDeviceTokensForUser(userId: string): Promise { + return getDataSource() + .getRepository(DeviceTokenEntity) + .find({ where: { userId } }); +} + +export async function dispatchPushToUser( + userId: string, + notification: PushNotificationPayload +): Promise { + const tokens = await listDeviceTokensForUser(userId); + + await Promise.all(tokens.map(async (record) => { + try { + if (record.platform === 'android') { + await sendFcmMessage(record.token, notification); + return; + } + + await sendApnsMessage(record.token, notification); + } catch (error) { + console.error('[push] Failed to dispatch to token', { + userId, + platform: record.platform, + error + }); + } + })); +} diff --git a/toju-app/CONTEXT.md b/toju-app/CONTEXT.md index 066fea4..ae9a0ec 100644 --- a/toju-app/CONTEXT.md +++ b/toju-app/CONTEXT.md @@ -17,7 +17,9 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra |------|------------|------------------| | **Domain** | A bounded context under `src/app/domains//` that owns its own models, services, NgRx slice, and components — e.g. *chat*, *voice-session*, *plugins*. | "module", "feature" (Angular reserves these for different things) | | **Shared kernel** | Cross-domain contracts in `src/app/shared-kernel/` — wire-format models, P2P transfer utilities, plugin contracts, signaling contracts — imported by multiple domains. | "common", "core" | -| **Infrastructure** | Technical-runtime concerns shared by domains: `persistence/` (client-side store wiring) and `realtime/` (WebSocket adapter). Not a domain. | "shared", "lib" | +| **Infrastructure** | Technical-runtime concerns shared by domains: `persistence/` (client-side store wiring), `realtime/` (WebSocket adapter), and `mobile/` (Capacitor/native facades). Not a domain. | "shared", "lib" | +| **Mobile facade** | An injectable service under `infrastructure/mobile/` that wraps Capacitor or web adapters so domains never import `@capacitor/*` directly. | "Capacitor service", "native plugin" | +| **Capacitor SQLite backend** | Native persistence path selected by `DatabaseService` when `PlatformService.isCapacitor` is true; uses `@capacitor-community/sqlite` instead of IndexedDB. | "mobile database", "Capacitor DB" | | **Rules file** | A pure-function module suffixed `*.rules.ts` that encodes domain logic without Angular or NgRx dependencies — easy to unit-test. | "helpers", "utils" | | **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" | diff --git a/toju-app/android/.gitignore b/toju-app/android/.gitignore new file mode 100644 index 0000000..af7c5c8 --- /dev/null +++ b/toju-app/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/toju-app/android/app/.gitignore b/toju-app/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/toju-app/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/toju-app/android/app/build.gradle b/toju-app/android/app/build.gradle new file mode 100644 index 0000000..4b98197 --- /dev/null +++ b/toju-app/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "com.metoyou.app" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.metoyou.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/toju-app/android/app/capacitor.build.gradle b/toju-app/android/app/capacitor.build.gradle new file mode 100644 index 0000000..16cd606 --- /dev/null +++ b/toju-app/android/app/capacitor.build.gradle @@ -0,0 +1,25 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-community-sqlite') + implementation project(':capacitor-app') + implementation project(':capacitor-camera') + implementation project(':capacitor-device') + implementation project(':capacitor-filesystem') + implementation project(':capacitor-local-notifications') + implementation project(':capacitor-push-notifications') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/toju-app/android/app/google-services.json.example b/toju-app/android/app/google-services.json.example new file mode 100644 index 0000000..2e05a66 --- /dev/null +++ b/toju-app/android/app/google-services.json.example @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "your-firebase-project-id", + "storage_bucket": "your-firebase-project-id.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "com.metoyou.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "YOUR_FIREBASE_ANDROID_API_KEY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/toju-app/android/app/proguard-rules.pro b/toju-app/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/toju-app/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/toju-app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/toju-app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/toju-app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/toju-app/android/app/src/debug/res/xml/network_security_config.xml b/toju-app/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..af5dfb3 --- /dev/null +++ b/toju-app/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/toju-app/android/app/src/main/AndroidManifest.xml b/toju-app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..df3c780 --- /dev/null +++ b/toju-app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toju-app/android/app/src/main/java/com/metoyou/app/MainActivity.java b/toju-app/android/app/src/main/java/com/metoyou/app/MainActivity.java new file mode 100644 index 0000000..1058344 --- /dev/null +++ b/toju-app/android/app/src/main/java/com/metoyou/app/MainActivity.java @@ -0,0 +1,13 @@ +package com.metoyou.app; + +import android.os.Bundle; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(MetoyouMobilePlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java b/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java new file mode 100644 index 0000000..fb1fbdd --- /dev/null +++ b/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java @@ -0,0 +1,115 @@ +package com.metoyou.app; + +import android.app.Activity; +import android.app.PictureInPictureParams; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.os.Build; +import android.util.Rational; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin(name = "MetoyouMobile") +public class MetoyouMobilePlugin extends Plugin { + @PluginMethod + public void setSpeakerphoneEnabled(PluginCall call) { + Boolean enabled = call.getBoolean("enabled", false); + AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); + + if (audioManager != null) { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setSpeakerphoneOn(enabled); + } + + call.resolve(); + } + + @PluginMethod + public void startVoiceForegroundService(PluginCall call) { + Context context = getContext(); + Intent intent = new Intent(context, VoiceCallForegroundService.class); + intent.setAction(VoiceCallForegroundService.ACTION_START); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + + call.resolve(); + } + + @PluginMethod + public void stopVoiceForegroundService(PluginCall call) { + Context context = getContext(); + Intent intent = new Intent(context, VoiceCallForegroundService.class); + intent.setAction(VoiceCallForegroundService.ACTION_STOP); + context.startService(intent); + call.resolve(); + } + + @PluginMethod + public void enterNativePictureInPicture(PluginCall call) { + Activity activity = getActivity(); + JSObject result = new JSObject(); + + if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + result.put("supported", false); + call.resolve(result); + return; + } + + PictureInPictureParams params = new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(16, 9)) + .build(); + + boolean entered = activity.enterPictureInPictureMode(params); + result.put("supported", entered); + call.resolve(result); + } + + @PluginMethod + public void startCallKitSession(PluginCall call) { + JSObject result = new JSObject(); + result.put("supported", false); + call.resolve(result); + } + + @PluginMethod + public void endCallKitSession(PluginCall call) { + call.resolve(); + } + + @PluginMethod + public void exitNativePictureInPicture(PluginCall call) { + Activity activity = getActivity(); + + if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode()) { + activity.moveTaskToBack(false); + } + + call.resolve(); + } + + @PluginMethod + public void isRemotePushConfigured(PluginCall call) { + JSObject result = new JSObject(); + boolean configured = false; + + try { + Class firebaseAppClass = Class.forName("com.google.firebase.FirebaseApp"); + Object app = firebaseAppClass.getMethod("getInstance").invoke(null); + configured = app != null; + } catch (Exception ignored) { + configured = false; + } + + result.put("configured", configured); + call.resolve(result); + } +} diff --git a/toju-app/android/app/src/main/java/com/metoyou/app/VoiceCallForegroundService.java b/toju-app/android/app/src/main/java/com/metoyou/app/VoiceCallForegroundService.java new file mode 100644 index 0000000..6daefd0 --- /dev/null +++ b/toju-app/android/app/src/main/java/com/metoyou/app/VoiceCallForegroundService.java @@ -0,0 +1,76 @@ +package com.metoyou.app; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; + +public class VoiceCallForegroundService extends Service { + public static final String ACTION_START = "com.metoyou.app.action.START_VOICE_FOREGROUND"; + public static final String ACTION_STOP = "com.metoyou.app.action.STOP_VOICE_FOREGROUND"; + private static final String CHANNEL_ID = "toju-voice-call"; + private static final int NOTIFICATION_ID = 4101; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || ACTION_STOP.equals(intent.getAction())) { + stopForeground(true); + stopSelf(); + return START_NOT_STICKY; + } + + createNotificationChannel(); + Notification notification = buildNotification(); + startForeground(NOTIFICATION_ID, notification); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Voice calls", + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription("Keeps active voice calls alive while the app is backgrounded."); + + NotificationManager manager = getSystemService(NotificationManager.class); + + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + + private Notification buildNotification() { + Intent launchIntent = new Intent(this, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("MetoYou call in progress") + .setContentText("Voice call is active") + .setSmallIcon(android.R.drawable.stat_sys_phone_call) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_CALL) + .build(); + } +} diff --git a/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/toju-app/android/app/src/main/res/drawable/ic_launcher_background.xml b/toju-app/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/toju-app/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toju-app/android/app/src/main/res/drawable/splash.png b/toju-app/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/toju-app/android/app/src/main/res/drawable/splash.png differ diff --git a/toju-app/android/app/src/main/res/layout/activity_main.xml b/toju-app/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/toju-app/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/toju-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/values/ic_launcher_background.xml b/toju-app/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/toju-app/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/toju-app/android/app/src/main/res/values/strings.xml b/toju-app/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5ea1bd5 --- /dev/null +++ b/toju-app/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + MetoYou + MetoYou + com.metoyou.app + com.metoyou.app + Voice calls and microphone access + diff --git a/toju-app/android/app/src/main/res/values/styles.xml b/toju-app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/toju-app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/toju-app/android/app/src/main/res/xml/file_paths.xml b/toju-app/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/toju-app/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/toju-app/android/app/src/main/res/xml/network_security_config.xml b/toju-app/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..db800e8 --- /dev/null +++ b/toju-app/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/toju-app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/toju-app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/toju-app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/toju-app/android/build.gradle b/toju-app/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/toju-app/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/toju-app/android/capacitor.settings.gradle b/toju-app/android/capacitor.settings.gradle new file mode 100644 index 0000000..08c11ad --- /dev/null +++ b/toju-app/android/capacitor.settings.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor') + +include ':capacitor-community-sqlite' +project(':capacitor-community-sqlite').projectDir = new File('../../node_modules/@capacitor-community/sqlite/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android') + +include ':capacitor-camera' +project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android') + +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../../node_modules/@capacitor/device/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android') + +include ':capacitor-local-notifications' +project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android') diff --git a/toju-app/android/gradle.properties b/toju-app/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/toju-app/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/toju-app/android/gradle/wrapper/gradle-wrapper.jar b/toju-app/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/toju-app/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/toju-app/android/gradle/wrapper/gradle-wrapper.properties b/toju-app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/toju-app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/toju-app/android/gradlew b/toju-app/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/toju-app/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/toju-app/android/gradlew.bat b/toju-app/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/toju-app/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/toju-app/android/settings.gradle b/toju-app/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/toju-app/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/toju-app/android/variables.gradle b/toju-app/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/toju-app/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/toju-app/capacitor.config.ts b/toju-app/capacitor.config.ts new file mode 100644 index 0000000..aeb0528 --- /dev/null +++ b/toju-app/capacitor.config.ts @@ -0,0 +1,30 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.metoyou.app', + appName: 'MetoYou', + webDir: '../dist/client/browser', + server: { + androidScheme: 'https' + }, + plugins: { + SystemBars: { + insetsHandling: 'css', + style: 'DARK' + }, + LocalNotifications: { + smallIcon: 'ic_stat_icon_config_sample', + iconColor: '#488AFF', + sound: 'call.wav' + }, + PushNotifications: { + presentationOptions: [ + 'badge', + 'sound', + 'alert' + ] + } + } +}; + +export default config; diff --git a/toju-app/ios/.gitignore b/toju-app/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/toju-app/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/toju-app/ios/App/App.xcodeproj/project.pbxproj b/toju-app/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d2fbc98 --- /dev/null +++ b/toju-app/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.metoyou.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.metoyou.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/toju-app/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/toju-app/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/toju-app/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/toju-app/ios/App/App/AppDelegate.swift b/toju-app/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..c3cd83b --- /dev/null +++ b/toju-app/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..adf6ba0 Binary files /dev/null and b/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/toju-app/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/toju-app/ios/App/App/Assets.xcassets/Contents.json b/toju-app/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/toju-app/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000..d7d96a6 --- /dev/null +++ b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000..33ea6c9 Binary files /dev/null and b/toju-app/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/toju-app/ios/App/App/Base.lproj/LaunchScreen.storyboard b/toju-app/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e7ae5d7 --- /dev/null +++ b/toju-app/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toju-app/ios/App/App/Base.lproj/Main.storyboard b/toju-app/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b44df7b --- /dev/null +++ b/toju-app/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/toju-app/ios/App/App/Info.plist b/toju-app/ios/App/App/Info.plist new file mode 100644 index 0000000..c3643ea --- /dev/null +++ b/toju-app/ios/App/App/Info.plist @@ -0,0 +1,57 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + MetoYou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UIBackgroundModes + + audio + remote-notification + voip + + + diff --git a/toju-app/ios/App/App/MetoyouMobilePlugin.swift b/toju-app/ios/App/App/MetoyouMobilePlugin.swift new file mode 100644 index 0000000..9a337d4 --- /dev/null +++ b/toju-app/ios/App/App/MetoyouMobilePlugin.swift @@ -0,0 +1,92 @@ +import Foundation +import Capacitor +import CallKit + +@objc(MetoyouMobilePlugin) +public class MetoyouMobilePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "MetoyouMobilePlugin" + public let jsName = "MetoyouMobile" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "startCallKitSession", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "endCallKitSession", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "isRemotePushConfigured", returnType: CAPPluginReturnPromise) + ] + + private let provider: CXProvider + private let callController = CXCallController() + private var activeCallUuid: UUID? + + public override init() { + let configuration = CXProviderConfiguration(localizedName: "MetoYou") + configuration.supportsVideo = true + configuration.maximumCallsPerCallGroup = 1 + configuration.supportedHandleTypes = [.generic] + provider = CXProvider(configuration: configuration) + super.init() + provider.setDelegate(self, queue: nil) + } + + @objc func startCallKitSession(_ call: CAPPluginCall) { + guard let callId = call.getString("callId"), + let displayName = call.getString("displayName") else { + call.reject("Missing callId/displayName") + return + } + + let uuid = UUID(uuidString: callId) ?? UUID() + activeCallUuid = uuid + + let handle = CXHandle(type: .generic, value: displayName) + let startAction = CXStartCallAction(call: uuid, handle: handle) + startAction.isVideo = false + let transaction = CXTransaction(action: startAction) + + callController.request(transaction) { error in + if let error = error { + call.resolve(["supported": false, "error": error.localizedDescription]) + return + } + + let update = CXCallUpdate() + update.remoteHandle = handle + update.localizedCallerName = displayName + update.hasVideo = false + self.provider.reportCall(with: uuid, updated: update) + call.resolve(["supported": true]) + } + } + + @objc func endCallKitSession(_ call: CAPPluginCall) { + guard let uuid = activeCallUuid else { + call.resolve() + return + } + + let endAction = CXEndCallAction(call: uuid) + let transaction = CXTransaction(action: endAction) + + callController.request(transaction) { _ in + self.activeCallUuid = nil + call.resolve() + } + } + + @objc func isRemotePushConfigured(_ call: CAPPluginCall) { + call.resolve(["configured": true]) + } +} + +extension MetoyouMobilePlugin: CXProviderDelegate { + public func providerDidReset(_ provider: CXProvider) { + activeCallUuid = nil + } + + public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + activeCallUuid = nil + action.fulfill() + } +} diff --git a/toju-app/ios/App/CapApp-SPM/.gitignore b/toju-app/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/toju-app/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/toju-app/ios/App/CapApp-SPM/Package.swift b/toju-app/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..dcbf975 --- /dev/null +++ b/toju-app/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.0"), + .package(name: "CapacitorCommunitySqlite", path: "../../../../node_modules/@capacitor-community/sqlite"), + .package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"), + .package(name: "CapacitorCamera", path: "../../../../node_modules/@capacitor/camera"), + .package(name: "CapacitorDevice", path: "../../../../node_modules/@capacitor/device"), + .package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"), + .package(name: "CapacitorLocalNotifications", path: "../../../../node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorPushNotifications", path: "../../../../node_modules/@capacitor/push-notifications"), + .package(name: "CapgoCapacitorAudioSession", path: "../../../../node_modules/@capgo/capacitor-audio-session") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorCommunitySqlite", package: "CapacitorCommunitySqlite"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorCamera", package: "CapacitorCamera"), + .product(name: "CapacitorDevice", package: "CapacitorDevice"), + .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), + .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), + .product(name: "CapgoCapacitorAudioSession", package: "CapgoCapacitorAudioSession") + ] + ) + ] +) diff --git a/toju-app/ios/App/CapApp-SPM/README.md b/toju-app/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..03964db --- /dev/null +++ b/toju-app/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/toju-app/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/toju-app/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/toju-app/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/toju-app/ios/debug.xcconfig b/toju-app/ios/debug.xcconfig new file mode 100644 index 0000000..53ce18d --- /dev/null +++ b/toju-app/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/toju-app/package.json b/toju-app/package.json new file mode 100644 index 0000000..41c0d97 --- /dev/null +++ b/toju-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@metoyou/client", + "private": true, + "version": "1.0.0", + "description": "MetoYou Angular product client and Capacitor shell", + "dependencies": { + "@capacitor-community/sqlite": "^8.1.0", + "@capacitor/android": "^8.4.0", + "@capacitor/app": "^8.1.0", + "@capacitor/camera": "^8.2.0", + "@capacitor/core": "^8.4.0", + "@capacitor/device": "^8.0.2", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/ios": "^8.4.0", + "@capacitor/local-notifications": "^8.2.0", + "@capacitor/push-notifications": "^8.1.1", + "@capgo/capacitor-audio-session": "^8.0.40" + } +} diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 64666a8..77c6eb8 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -1,6 +1,6 @@
void) | null = null; private themeStudioControlsDragOffset: { x: number; y: number } | null = null; private themeStudioControlsBounds: { width: number; height: number } | null = null; @@ -331,6 +339,9 @@ export class App implements OnInit, OnDestroy { } void this.notifications.initialize().catch(() => {}); + void this.mobilePersistence.initialize().catch(() => {}); + void this.mobileLifecycle.initialize().catch(() => {}); + this.mobileCallSession.initialize(); void this.setupDesktopDeepLinks().catch(() => {}); this.userStatus.start(); diff --git a/toju-app/src/app/core/platform/platform.service.ts b/toju-app/src/app/core/platform/platform.service.ts index 549634f..42bbbdc 100644 --- a/toju-app/src/app/core/platform/platform.service.ts +++ b/toju-app/src/app/core/platform/platform.service.ts @@ -1,15 +1,23 @@ import { Injectable, inject } from '@angular/core'; +import { detectRuntimePlatform, isCapacitorNativeRuntime } from '../../infrastructure/mobile/logic/platform-detection.rules'; import { ElectronBridgeService } from './electron/electron-bridge.service'; @Injectable({ providedIn: 'root' }) export class PlatformService { readonly isElectron: boolean; + readonly isCapacitor: boolean; readonly isBrowser: boolean; private readonly electronBridge = inject(ElectronBridgeService); constructor() { this.isElectron = this.electronBridge.isAvailable; - this.isBrowser = !this.isElectron; + const runtime = detectRuntimePlatform({ + hasElectronApi: this.isElectron, + capacitorIsNative: isCapacitorNativeRuntime() + }); + + this.isCapacitor = runtime === 'capacitor'; + this.isBrowser = runtime === 'browser'; } } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index a02e90f..78b4510 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -155,53 +155,147 @@ } - @if (klipyEnabled()) { - - } - -
- - - @if (showEmojiPicker()) { -
- +
+ + + @if (showComposerMediaMenu()) { + +
+ @for (option of composerMediaMenuOptions(); track option.action) { + + } +
+
+ } + + @if (showEmojiPicker()) { + + + + } +
+ } @else { + @if (shouldShowAttachmentButton()) { + } -
+ + @if (klipyEnabled()) { + + } + +
+ + + @if (showEmojiPicker()) { +
+ +
+ } +
+ } - } -
+
+ @if (!inline()) { +
+

Emoji

+ @if (compact()) { + + } +
+ }
diff --git a/toju-app/src/app/features/direct-call/private-call.component.ts b/toju-app/src/app/features/direct-call/private-call.component.ts index 0494f37..061d81b 100644 --- a/toju-app/src/app/features/direct-call/private-call.component.ts +++ b/toju-app/src/app/features/direct-call/private-call.component.ts @@ -43,6 +43,7 @@ import { import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session'; import { ScreenShareQualityDialogComponent } from '../../shared'; import { ViewportService } from '../../core/platform'; +import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import { UsersActions } from '../../store/users/users.actions'; import { User } from '../../shared-kernel'; @@ -87,11 +88,15 @@ export class PrivateCallComponent { private readonly playback = inject(VoicePlaybackService); private readonly screenShare = inject(ScreenShareFacade); private readonly viewport = inject(ViewportService); + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly mobileMedia = inject(MobileMediaService); private chatResizing = false; readonly allUsers = this.store.selectSignal(selectAllUsers); readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly isMobile = this.viewport.isMobile; + readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile()); + readonly speakerphoneEnabled = signal(true); readonly callIdInput = input(null); readonly overlayMode = input(false); readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), { @@ -342,6 +347,13 @@ export class PrivateCallComponent { this.broadcastLocalVoiceState(); } + async toggleSpeakerphone(): Promise { + const nextEnabled = !this.speakerphoneEnabled(); + + this.speakerphoneEnabled.set(nextEnabled); + await this.mobileMedia.setSpeakerphoneEnabled(nextEnabled); + } + toggleDeafen(): void { const nextDeafened = !this.isDeafened(); diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts index 4b8bf74..e500053 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts @@ -25,6 +25,10 @@ import { import { UserAvatarComponent } from '../../../../shared'; import { ViewportService } from '../../../../core/platform'; +import { + MobileAppLifecycleService, + MobilePictureInPictureService +} from '../../../../infrastructure/mobile'; import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service'; import { VoiceWorkspaceStreamItem } from '../voice-workspace.models'; @@ -55,6 +59,8 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models'; export class VoiceWorkspaceStreamTileComponent implements OnDestroy { private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService); private readonly viewport = inject(ViewportService); + private readonly mobileLifecycle = inject(MobileAppLifecycleService); + private readonly mobilePictureInPicture = inject(MobilePictureInPictureService); private fullscreenHeaderHideTimeoutId: ReturnType | null = null; readonly item = input.required(); @@ -74,6 +80,10 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy { readonly muted = signal(false); constructor() { + void this.mobileLifecycle.initialize(); + this.mobileLifecycle.onAppStateChange((isActive) => { + void this.handleAppStateChange(isActive); + }); effect(() => { const ref = this.videoRef(); const item = this.item(); @@ -150,6 +160,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy { ngOnDestroy(): void { this.clearFullscreenHeaderHideTimeout(); + void this.mobilePictureInPicture.exit(); const tile = this.tileRef()?.nativeElement; @@ -160,6 +171,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy { this.unlockOrientation(); } + private async handleAppStateChange(isActive: boolean): Promise { + if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) { + if (isActive) { + await this.mobilePictureInPicture.exit(); + } + + return; + } + + const video = this.videoRef()?.nativeElement; + + if (!video || !this.item().stream) { + return; + } + + await this.mobilePictureInPicture.enter(video); + } + canToggleFullscreen(): boolean { return !this.mini() && !this.compact(); } diff --git a/toju-app/src/app/infrastructure/mobile/README.md b/toju-app/src/app/infrastructure/mobile/README.md new file mode 100644 index 0000000..ec30a23 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/README.md @@ -0,0 +1,27 @@ +# Mobile infrastructure + +Loosely coupled Capacitor/native bridge for the Angular product client. Domains depend on facades in this folder — never on `@capacitor/*` imports directly. + +## Facades + +| Service | Responsibility | +|---------|----------------| +| `MobilePlatformService` | Runtime detection (`browser` / `capacitor` / `electron`) and mobile UX flags | +| `MobileNotificationsService` | Local/push notifications for calls | +| `MobileCallSessionService` | In-call notification actions, background audio session, stream video hand-off | +| `MobileMediaService` | Attachment picker, speakerphone route, screen-share/PiP capability probes | +| `MobilePictureInPictureService` | Stream pop-out while backgrounded | +| `MobilePersistenceService` | Native SQLite schema init (`@capacitor-community/sqlite`) | +| `MobileSqliteConnectionService` | Shared SQLite connection for persistence + `DatabaseService` | +| `MobileCallKitService` | iOS CallKit active-call reporting for background voice | +| `MobilePushRegistrationService` | FCM/APNs token registration with signaling server; skips `PushNotifications.register()` when Firebase/APNs is not configured | +| `MobileAppLifecycleService` | Foreground/background lifecycle | + +## Adapters + +- `adapters/web/*` — browser fallbacks (Notification API, hidden file input, Document PiP). +- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts`. + +## Rules + +Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular. diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-lifecycle.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-lifecycle.adapter.ts new file mode 100644 index 0000000..c567912 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-lifecycle.adapter.ts @@ -0,0 +1,23 @@ +import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts'; +import { loadCapacitorAppPlugin } from './capacitor-plugin-loader'; + +/** Capacitor App plugin lifecycle bridge. */ +export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter { + private handler: ((isActive: boolean) => void) | null = null; + + async initialize(): Promise { + const App = loadCapacitorAppPlugin(); + + if (!App) { + return; + } + + await App.addListener('appStateChange', (state) => { + this.handler?.(state.isActive); + }); + } + + onAppStateChange(handler: (isActive: boolean) => void): void { + this.handler = handler; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-callkit.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-callkit.adapter.ts new file mode 100644 index 0000000..0988658 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-callkit.adapter.ts @@ -0,0 +1,25 @@ +import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts'; +import { MetoyouMobile } from './metoyou-mobile.plugin'; + +/** iOS CallKit bridge via the MetoyouMobile native plugin. */ +export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter { + async startActiveCall(callId: string, displayName: string): Promise { + try { + const result = await MetoyouMobile.startCallKitSession({ callId, displayName }); + + if (!result.supported) { + console.info('[mobile] CallKit is unavailable on this iOS build'); + } + } catch (error) { + console.info('[mobile] CallKit start skipped', error); + } + } + + async endActiveCall(callId: string): Promise { + try { + await MetoyouMobile.endCallKitSession({ callId }); + } catch (error) { + console.info('[mobile] CallKit end skipped', error); + } + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-media.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-media.adapter.ts new file mode 100644 index 0000000..143b9a1 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-media.adapter.ts @@ -0,0 +1,68 @@ +import type { MobileMediaAdapter } from '../../contracts/mobile.contracts'; +import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader'; +import { MetoyouMobile } from './metoyou-mobile.plugin'; +import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter'; + +/** Capacitor media adapter with native speaker routing and background voice session hooks. */ +export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implements MobileMediaAdapter { + private backgroundSessionActive = false; + + override async setSpeakerphoneEnabled(enabled: boolean): Promise { + try { + await MetoyouMobile.setSpeakerphoneEnabled({ enabled }); + return; + } catch { + // Android plugin unavailable in web builds; fall through to iOS audio session. + } + + const AudioSession = loadCapacitorAudioSessionPlugin(); + + if (!AudioSession) { + return; + } + + await AudioSession.overrideOutput(enabled ? 'speaker' : 'default'); + } + + override async startBackgroundAudioSession(): Promise { + if (this.backgroundSessionActive) { + return; + } + + this.backgroundSessionActive = true; + + try { + await MetoyouMobile.startVoiceForegroundService(); + } catch (error) { + console.info('[mobile] background voice foreground service unavailable', error); + } + } + + override async stopBackgroundAudioSession(): Promise { + if (!this.backgroundSessionActive) { + return; + } + + this.backgroundSessionActive = false; + + try { + await MetoyouMobile.stopVoiceForegroundService(); + } catch (error) { + console.info('[mobile] failed to stop background voice session', error); + } + } + + override isScreenShareSupported(): boolean { + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + + if (/iPhone|iPad|iPod/i.test(userAgent)) { + return false; + } + + return !!navigator.mediaDevices?.getDisplayMedia; + } + + override isPictureInPictureSupported(): boolean { + return super.isPictureInPictureSupported(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts new file mode 100644 index 0000000..94946cb --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts @@ -0,0 +1,151 @@ +import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules'; +import { resolveCallNotificationAction } from '../../logic/call-notification.rules'; +import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts'; +import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader'; + +const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call'; +const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call'; + +/** Capacitor local + push notification bridge with action buttons for in-call controls. */ +export class CapacitorMobileNotificationsAdapter implements MobileNotificationAdapter { + private actionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null; + private listenersRegistered = false; + + async initialize(): Promise { + const LocalNotifications = loadCapacitorLocalNotificationsPlugin(); + const PushNotifications = loadCapacitorPushNotificationsPlugin(); + + if (!LocalNotifications) { + return; + } + + await LocalNotifications.createChannel({ + id: INCOMING_CALL_CHANNEL_ID, + name: 'Incoming calls', + importance: 5, + visibility: 1, + sound: 'call.wav' + }); + + await LocalNotifications.createChannel({ + id: ACTIVE_CALL_CHANNEL_ID, + name: 'Active calls', + importance: 4, + visibility: 1 + }); + + await LocalNotifications.registerActionTypes({ + types: [ + { + id: 'INCOMING_CALL_ACTIONS', + actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }] + }, + { + id: 'ACTIVE_CALL_ACTIONS', + actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }] + } + ] + }); + + if (!this.listenersRegistered) { + await LocalNotifications.addListener('localNotificationActionPerformed', (event) => { + const callId = event.notification.extra?.callId as string | undefined; + const intent = resolveCallNotificationAction(event.actionId); + + if (!callId || !intent || !this.actionHandler) { + return; + } + + this.actionHandler({ callId, intent }); + }); + + this.listenersRegistered = true; + } + + if (PushNotifications) { + const permission = await PushNotifications.checkPermissions(); + + if (permission.receive === 'prompt') { + await PushNotifications.requestPermissions(); + } + } + } + + async requestPermission(): Promise { + const LocalNotifications = loadCapacitorLocalNotificationsPlugin(); + + if (!LocalNotifications) { + return false; + } + + const permission = await LocalNotifications.checkPermissions(); + + if (permission.display === 'granted') { + return true; + } + + const requested = await LocalNotifications.requestPermissions(); + + return requested.display === 'granted'; + } + + async showCallNotification(payload: CallNotificationPayload): Promise { + const LocalNotifications = loadCapacitorLocalNotificationsPlugin(); + + if (!LocalNotifications) { + return; + } + + const granted = await this.requestPermission(); + + if (!granted) { + return; + } + + await LocalNotifications.schedule({ + notifications: [ + { + id: payload.id, + title: payload.title, + body: payload.body, + channelId: payload.kind === 'incoming' ? INCOMING_CALL_CHANNEL_ID : ACTIVE_CALL_CHANNEL_ID, + ongoing: payload.kind === 'active', + autoCancel: payload.kind === 'incoming', + extra: { + callId: payload.callId, + kind: payload.kind + }, + actionTypeId: payload.kind === 'active' ? 'ACTIVE_CALL_ACTIONS' : 'INCOMING_CALL_ACTIONS' + } + ] + }); + } + + async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise { + const LocalNotifications = loadCapacitorLocalNotificationsPlugin(); + + if (!LocalNotifications) { + return; + } + + const notifications = await LocalNotifications.getDeliveredNotifications(); + const matching = notifications.notifications.filter((notification) => { + const extraCallId = notification.extra?.callId as string | undefined; + const extraKind = notification.extra?.kind as CallNotificationPayload['kind'] | undefined; + + return extraCallId === callId && extraKind === kind; + }); + + if (matching.length === 0) { + return; + } + + await LocalNotifications.removeDeliveredNotifications({ + notifications: matching + }); + } + + onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void { + this.actionHandler = handler; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-persistence.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-persistence.adapter.ts new file mode 100644 index 0000000..0741d2b --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-persistence.adapter.ts @@ -0,0 +1,35 @@ +import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts'; +import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-connection.service'; + +/** + * Capacitor SQLite persistence adapter. + * + * Initializes native SQLite with schema mirrored from Electron TypeORM entities. + * Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells. + */ +export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter { + private initialized = false; + + constructor(private readonly connection: MobileSqliteConnectionService) {} + + get isNativeSqlite(): boolean { + return this.connection.isAvailable; + } + + async initialize(): Promise { + if (this.initialized) { + return; + } + + const store = await this.connection.initialize(); + + if (!store?.isAvailable) { + console.warn('[mobile] SQLite plugin unavailable on this Capacitor shell'); + this.initialized = true; + return; + } + + this.initialized = true; + console.info('[mobile] native SQLite persistence initialized'); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-picture-in-picture.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-picture-in-picture.adapter.ts new file mode 100644 index 0000000..5e0c21c --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-picture-in-picture.adapter.ts @@ -0,0 +1,43 @@ +import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts'; +import { MetoyouMobile } from './metoyou-mobile.plugin'; +import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter'; + +/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */ +export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter { + private nativeSupported: boolean | null = null; + + override isSupported(): boolean { + if (super.isSupported()) { + return true; + } + + return this.nativeSupported === true; + } + + override async enter(videoElement: HTMLVideoElement): Promise { + if (super.isSupported()) { + await super.enter(videoElement); + return; + } + + const result = await MetoyouMobile.enterNativePictureInPicture(); + + this.nativeSupported = result.supported; + + if (!result.supported) { + return; + } + + if (videoElement.paused) { + await videoElement.play().catch(() => {}); + } + } + + override async exit(): Promise { + if (document.pictureInPictureElement) { + await super.exit(); + } + + await MetoyouMobile.exitNativePictureInPicture().catch(() => {}); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.spec.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.spec.ts new file mode 100644 index 0000000..4a09e21 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.spec.ts @@ -0,0 +1,75 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +const capacitorState = vi.hoisted(() => ({ + isNativePlatform: true, + isPluginAvailable: true, + platform: 'android' +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: () => capacitorState.isNativePlatform, + isPluginAvailable: (name: string) => capacitorState.isPluginAvailable && name.length > 0, + getPlatform: () => capacitorState.platform + } +})); + +vi.mock('@capacitor/app', () => ({ + App: { + addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) + } +})); + +vi.mock('@capacitor/local-notifications', () => ({ + LocalNotifications: { + checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' })) + } +})); + +import { App } from '@capacitor/app'; +import { LocalNotifications } from '@capacitor/local-notifications'; +import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader'; + +describe('capacitor-plugin-loader', () => { + beforeEach(() => { + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + capacitorState.isNativePlatform = true; + capacitorState.isPluginAvailable = true; + capacitorState.platform = 'android'; + vi.unstubAllGlobals(); + }); + + it('returns registered plugin instances synchronously without wrapping them in a Promise', () => { + const appPlugin = loadCapacitorAppPlugin(); + const notificationsPlugin = loadCapacitorLocalNotificationsPlugin(); + + expect(appPlugin).toBe(App); + expect(notificationsPlugin).toBe(LocalNotifications); + expect(appPlugin).not.toBeInstanceOf(Promise); + expect(notificationsPlugin).not.toBeInstanceOf(Promise); + }); + + it('returns null when the plugin is unavailable on the active native shell', () => { + capacitorState.isPluginAvailable = false; + + expect(loadCapacitorAppPlugin()).toBeNull(); + expect(loadCapacitorLocalNotificationsPlugin()).toBeNull(); + }); + + it('returns null on non-native shells', () => { + capacitorState.isNativePlatform = false; + + expect(loadCapacitorAppPlugin()).toBeNull(); + expect(loadCapacitorLocalNotificationsPlugin()).toBeNull(); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts new file mode 100644 index 0000000..87cb297 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts @@ -0,0 +1,44 @@ +import { App } from '@capacitor/app'; +import { Capacitor } from '@capacitor/core'; +import { Device } from '@capacitor/device'; +import { LocalNotifications } from '@capacitor/local-notifications'; +import { PushNotifications } from '@capacitor/push-notifications'; +import { AudioSession } from '@capgo/capacitor-audio-session'; + +function resolveCapacitorPlugin(pluginName: string, plugin: T): T | null { + if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) { + return null; + } + + if (!Capacitor.isPluginAvailable(pluginName)) { + console.warn(`[mobile] Capacitor plugin "${pluginName}" is not implemented on ${Capacitor.getPlatform()}`); + return null; + } + + return plugin; +} + +/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */ +export function loadCapacitorAppPlugin(): typeof App | null { + return resolveCapacitorPlugin('App', App); +} + +/** Resolve the Capacitor LocalNotifications plugin on native shells. */ +export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null { + return resolveCapacitorPlugin('LocalNotifications', LocalNotifications); +} + +/** Resolve the Capacitor PushNotifications plugin on native shells. */ +export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null { + return resolveCapacitorPlugin('PushNotifications', PushNotifications); +} + +/** Resolve the Capacitor Device plugin on native shells. */ +export function loadCapacitorDevicePlugin(): typeof Device | null { + return resolveCapacitorPlugin('Device', Device); +} + +/** Resolve the Capacitor AudioSession plugin on native shells. */ +export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null { + return resolveCapacitorPlugin('AudioSession', AudioSession); +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-sqlite.store.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-sqlite.store.ts new file mode 100644 index 0000000..4a74fd0 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-sqlite.store.ts @@ -0,0 +1,115 @@ +import { + MOBILE_SQLITE_DATABASE_NAME, + MOBILE_SQLITE_SCHEMA_VERSION, + resolveMobileSqliteMigrationStatements +} from '../../logic/mobile-sqlite-schema.rules'; +import { executeMobileSqliteStatements } from '../../logic/mobile-sqlite-execute.rules'; + +const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version'; + +export interface MobileSqliteStore { + readonly isAvailable: boolean; + initialize(): Promise; + run(statement: string, values?: unknown[]): Promise; + query(statement: string, values?: unknown[]): Promise; +} + +const schemaInitializationFailures = new Set(); + +/** Lazy-loaded @capacitor-community/sqlite connection for native mobile shells. */ +export async function createCapacitorSqliteStore( + databaseName: string = MOBILE_SQLITE_DATABASE_NAME +): Promise { + if (typeof window === 'undefined') { + return null; + } + + try { + const sqliteModule = await import('@capacitor-community/sqlite'); + + type SqliteDbConnection = import('@capacitor-community/sqlite').SQLiteDBConnection; + + const sqliteConnection = new sqliteModule.SQLiteConnection(sqliteModule.CapacitorSQLite); + + let database: SqliteDbConnection | null = null; + + return { + get isAvailable() { + return database !== null && !schemaInitializationFailures.has(databaseName); + }, + + async initialize(): Promise { + if (schemaInitializationFailures.has(databaseName)) { + throw new Error(`Mobile SQLite schema initialization failed for "${databaseName}".`); + } + + await sqliteConnection.checkConnectionsConsistency(); + const connectionState = await sqliteConnection.isConnection(databaseName, false); + + database = connectionState.result + ? await sqliteConnection.retrieveConnection(databaseName, false) + : await sqliteConnection.createConnection( + databaseName, + false, + 'no-encryption', + MOBILE_SQLITE_SCHEMA_VERSION, + false + ); + + await database.open(); + + let storedVersion = 0; + + try { + const versionRows = await database.query(`SELECT value FROM meta WHERE key = '${META_SCHEMA_VERSION_KEY}' LIMIT 1`); + + storedVersion = Number(versionRows.values?.[0]?.value ?? 0); + } catch { + storedVersion = 0; + } + + const statements = resolveMobileSqliteMigrationStatements(storedVersion); + + if (statements.length === 0) { + return; + } + + try { + const activeDatabase = database; + + if (!activeDatabase) { + throw new Error('Mobile SQLite store is not initialized.'); + } + + await executeMobileSqliteStatements( + (statement) => activeDatabase.execute(statement), + statements + ); + } catch (error) { + schemaInitializationFailures.add(databaseName); + throw error; + } + }, + + async run(statement: string, values: unknown[] = []): Promise { + if (!database) { + throw new Error('Mobile SQLite store is not initialized.'); + } + + await database.run(statement, values); + }, + + async query(statement: string, values: unknown[] = []): Promise { + if (!database) { + throw new Error('Mobile SQLite store is not initialized.'); + } + + const result = await database.query(statement, values); + + return (result.values ?? []) as T[]; + } + }; + } catch { + return null; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts new file mode 100644 index 0000000..5771424 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts @@ -0,0 +1,14 @@ +import { registerPlugin } from '@capacitor/core'; + +export interface MetoyouMobilePlugin { + setSpeakerphoneEnabled(options: { enabled: boolean }): Promise; + startVoiceForegroundService(): Promise; + stopVoiceForegroundService(): Promise; + enterNativePictureInPicture(): Promise<{ supported: boolean }>; + exitNativePictureInPicture(): Promise; + startCallKitSession(options: { callId: string; displayName: string }): Promise<{ supported: boolean }>; + endCallKitSession(options: { callId: string }): Promise; + isRemotePushConfigured(): Promise<{ configured: boolean }>; +} + +export const MetoyouMobile = registerPlugin('MetoyouMobile'); diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-lifecycle.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-lifecycle.adapter.ts new file mode 100644 index 0000000..20a2058 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-lifecycle.adapter.ts @@ -0,0 +1,20 @@ +import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts'; + +/** Visibility API fallback for browser runtimes. */ +export class WebMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter { + private handler: ((isActive: boolean) => void) | null = null; + + async initialize(): Promise { + if (typeof document === 'undefined') { + return; + } + + document.addEventListener('visibilitychange', () => { + this.handler?.(!document.hidden); + }); + } + + onAppStateChange(handler: (isActive: boolean) => void): void { + this.handler = handler; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-callkit.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-callkit.adapter.ts new file mode 100644 index 0000000..b313b21 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-callkit.adapter.ts @@ -0,0 +1,8 @@ +import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts'; + +/** Web shells do not expose CallKit. */ +export class WebMobileCallKitAdapter implements MobileCallKitAdapter { + async startActiveCall(): Promise {} + + async endActiveCall(): Promise {} +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-media.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-media.adapter.ts new file mode 100644 index 0000000..6ec15f3 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-media.adapter.ts @@ -0,0 +1,49 @@ +import type { MobileMediaAdapter } from '../../contracts/mobile.contracts'; + +/** Web fallback for mobile media affordances. */ +export class WebMobileMediaAdapter implements MobileMediaAdapter { + async pickAttachments(): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + + input.type = 'file'; + input.multiple = true; + input.accept = 'image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.doc,.docx,.xls,.xlsx,.ppt,.pptx'; + input.style.display = 'none'; + + input.addEventListener('change', () => { + const files = input.files ? Array.from(input.files) : []; + + input.remove(); + resolve(files); + }, { once: true }); + + document.body.appendChild(input); + input.click(); + }); + } + + async setSpeakerphoneEnabled(_enabled: boolean): Promise { + return; + } + + async startBackgroundAudioSession(): Promise { + return; + } + + async stopBackgroundAudioSession(): Promise { + return; + } + + isScreenShareSupported(): boolean { + return typeof navigator !== 'undefined' + && !!navigator.mediaDevices?.getDisplayMedia + && !/iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + } + + isPictureInPictureSupported(): boolean { + return typeof document !== 'undefined' + && 'pictureInPictureEnabled' in document + && document.pictureInPictureEnabled === true; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-notifications.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-notifications.adapter.ts new file mode 100644 index 0000000..32901c0 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-notifications.adapter.ts @@ -0,0 +1,60 @@ +import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules'; +import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts'; + +type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void; + +/** Browser Notification API fallback for web and Capacitor dev shells. */ +export class WebMobileNotificationsAdapter implements MobileNotificationAdapter { + private actionHandler: CallActionHandler | null = null; + + async initialize(): Promise { + return; + } + + async requestPermission(): Promise { + if (typeof Notification === 'undefined') { + return false; + } + + if (Notification.permission === 'granted') { + return true; + } + + if (Notification.permission === 'denied') { + return false; + } + + const permission = await Notification.requestPermission(); + + return permission === 'granted'; + } + + async showCallNotification(payload: CallNotificationPayload): Promise { + const granted = await this.requestPermission(); + + if (!granted) { + return; + } + + const notification = new Notification(payload.title, { + body: payload.body, + tag: `toju-call-${payload.callId}-${payload.kind}` + }); + + notification.onclick = () => { + window.focus(); + this.actionHandler?.({ + callId: payload.callId, + intent: payload.kind === 'incoming' ? 'answer' : 'toggle-mute' + }); + }; + } + + async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise { + return; + } + + onActionSelected(handler: CallActionHandler): void { + this.actionHandler = handler; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-persistence.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-persistence.adapter.ts new file mode 100644 index 0000000..e5a151b --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-persistence.adapter.ts @@ -0,0 +1,10 @@ +import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts'; + +/** Web persistence marker - IndexedDB remains the active store via DatabaseService. */ +export class WebMobilePersistenceAdapter implements MobilePersistenceAdapter { + readonly isNativeSqlite = false; + + async initialize(): Promise { + return; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-picture-in-picture.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-picture-in-picture.adapter.ts new file mode 100644 index 0000000..1713425 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-picture-in-picture.adapter.ts @@ -0,0 +1,26 @@ +import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts'; + +/** Document Picture-in-Picture API adapter for supported browsers. */ +export class WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter { + isSupported(): boolean { + return typeof document !== 'undefined' + && 'pictureInPictureEnabled' in document + && document.pictureInPictureEnabled === true; + } + + async enter(videoElement: HTMLVideoElement): Promise { + if (!this.isSupported() || document.pictureInPictureElement) { + return; + } + + await videoElement.requestPictureInPicture(); + } + + async exit(): Promise { + if (!document.pictureInPictureElement) { + return; + } + + await document.exitPictureInPicture(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts b/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts new file mode 100644 index 0000000..9900ee2 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts @@ -0,0 +1,46 @@ +import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules'; +import type { RuntimePlatform } from '../logic/platform-detection.rules'; + +export interface MobileNotificationAdapter { + initialize(): Promise; + requestPermission(): Promise; + showCallNotification(payload: CallNotificationPayload): Promise; + dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise; + onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void; +} + +export interface MobileMediaAdapter { + pickAttachments(): Promise; + setSpeakerphoneEnabled(enabled: boolean): Promise; + startBackgroundAudioSession(): Promise; + stopBackgroundAudioSession(): Promise; + isScreenShareSupported(): boolean; + isPictureInPictureSupported(): boolean; +} + +export interface MobilePictureInPictureAdapter { + isSupported(): boolean; + enter(videoElement: HTMLVideoElement): Promise; + exit(): Promise; +} + +export interface MobilePersistenceAdapter { + readonly isNativeSqlite: boolean; + initialize(): Promise; +} + +export interface MobileAppLifecycleAdapter { + initialize(): Promise; + onAppStateChange(handler: (isActive: boolean) => void): void; +} + +export interface MobileCallKitAdapter { + startActiveCall(callId: string, displayName: string): Promise; + endActiveCall(callId: string): Promise; +} + +export interface MobilePlatformSnapshot { + runtime: RuntimePlatform; + isNativeMobile: boolean; + isCapacitor: boolean; +} diff --git a/toju-app/src/app/infrastructure/mobile/index.ts b/toju-app/src/app/infrastructure/mobile/index.ts new file mode 100644 index 0000000..a7c106e --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/index.ts @@ -0,0 +1,12 @@ +export * from './logic/platform-detection.rules'; +export * from './logic/call-notification.rules'; +export * from './services/mobile-platform.service'; +export * from './services/mobile-notifications.service'; +export * from './services/mobile-media.service'; +export * from './services/mobile-picture-in-picture.service'; +export * from './services/mobile-persistence.service'; +export * from './services/mobile-call-session.service'; +export * from './services/mobile-app-lifecycle.service'; +export * from './services/mobile-push-registration.service'; +export * from './services/mobile-callkit.service'; +export * from './services/mobile-sqlite-connection.service'; diff --git a/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.spec.ts new file mode 100644 index 0000000..790cb56 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.spec.ts @@ -0,0 +1,46 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + buildIncomingCallNotification, + buildInCallNotification, + resolveCallNotificationAction +} from './call-notification.rules'; + +describe('call-notification.rules', () => { + it('builds an incoming call notification payload', () => { + expect(buildIncomingCallNotification('Alex', 'call-1')).toMatchObject({ + title: 'Incoming call', + body: 'Alex is calling you', + callId: 'call-1', + kind: 'incoming', + actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }] + }); + }); + + it('builds a persistent in-call notification with action ids', () => { + const payload = buildInCallNotification({ + callId: 'call-2', + displayName: 'Team call', + isMuted: false + }); + + expect(payload).toMatchObject({ + title: 'Team call', + body: 'Call in progress', + callId: 'call-2', + kind: 'active', + actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }] + }); + }); + + it('maps mute action to toggle mute intent', () => { + expect(resolveCallNotificationAction('mute')).toBe('toggle-mute'); + expect(resolveCallNotificationAction('hangup')).toBe('hang-up'); + expect(resolveCallNotificationAction('answer')).toBe('answer'); + expect(resolveCallNotificationAction('unknown')).toBeNull(); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.ts new file mode 100644 index 0000000..f95fcce --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/call-notification.rules.ts @@ -0,0 +1,69 @@ +export type CallNotificationKind = 'incoming' | 'active'; + +export type CallNotificationActionId = 'answer' | 'mute' | 'hangup'; + +export type CallNotificationActionIntent = 'answer' | 'toggle-mute' | 'hang-up'; + +export interface CallNotificationPayload { + id: number; + title: string; + body: string; + callId: string; + kind: CallNotificationKind; + actions?: { id: CallNotificationActionId; title: string }[]; +} + +const INCOMING_CALL_NOTIFICATION_BASE_ID = 1000; +const ACTIVE_CALL_NOTIFICATION_BASE_ID = 2000; + +/** Build a local notification payload for an incoming direct call. */ +export function buildIncomingCallNotification(displayName: string, callId: string): CallNotificationPayload { + return { + id: INCOMING_CALL_NOTIFICATION_BASE_ID + hashCallId(callId), + title: 'Incoming call', + body: `${displayName} is calling you`, + callId, + kind: 'incoming', + actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }] + }; +} + +/** Build a persistent in-call notification payload with quick actions. */ +export function buildInCallNotification(input: { + callId: string; + displayName: string; + isMuted: boolean; +}): CallNotificationPayload { + return { + id: ACTIVE_CALL_NOTIFICATION_BASE_ID + hashCallId(input.callId), + title: input.displayName, + body: input.isMuted ? 'Call in progress · muted' : 'Call in progress', + callId: input.callId, + kind: 'active', + actions: [{ id: 'mute', title: input.isMuted ? 'Unmute' : 'Mute' }, { id: 'hangup', title: 'Hang up' }] + }; +} + +/** Map a notification action button id to a call-control intent. */ +export function resolveCallNotificationAction(actionId: string): CallNotificationActionIntent | null { + switch (actionId) { + case 'answer': + return 'answer'; + case 'mute': + return 'toggle-mute'; + case 'hangup': + return 'hang-up'; + default: + return null; + } +} + +function hashCallId(callId: string): number { + let hash = 0; + + for (let index = 0; index < callId.length; index += 1) { + hash = (hash * 31 + callId.charCodeAt(index)) % 997; + } + + return hash; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.spec.ts new file mode 100644 index 0000000..5f1b9bc --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.spec.ts @@ -0,0 +1,64 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + buildRemotePushSkipMessage, + resolveRemotePushSkipReason, + shouldRegisterForRemotePush, + type RemotePushRegistrationGateInput +} from './mobile-push-registration.rules'; + +describe('mobile-push-registration.rules', () => { + const configuredInput: RemotePushRegistrationGateInput = { + hasPushPlugin: true, + hasDevicePlugin: true, + remotePushConfigured: true + }; + + it('skips registration when remote push is not configured', () => { + expect( + shouldRegisterForRemotePush({ + ...configuredInput, + remotePushConfigured: false + }) + ).toBe(false); + }); + + it('skips registration when the push plugin is unavailable', () => { + expect( + shouldRegisterForRemotePush({ + ...configuredInput, + hasPushPlugin: false + }) + ).toBe(false); + }); + + it('skips registration when the device plugin is unavailable', () => { + expect( + shouldRegisterForRemotePush({ + ...configuredInput, + hasDevicePlugin: false + }) + ).toBe(false); + }); + + it('allows registration when plugins and remote push are available', () => { + expect(shouldRegisterForRemotePush(configuredInput)).toBe(true); + }); + + it('reports why registration was skipped', () => { + expect( + resolveRemotePushSkipReason({ + ...configuredInput, + remotePushConfigured: false + }) + ).toBe('remote-push-not-configured'); + }); + + it('builds a single actionable skip message for missing Firebase setup', () => { + expect(buildRemotePushSkipMessage('remote-push-not-configured')).toContain('google-services.json'); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.ts new file mode 100644 index 0000000..55e66ae --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-registration.rules.ts @@ -0,0 +1,55 @@ +export type RemotePushSkipReason = + | 'missing-push-plugin' + | 'missing-device-plugin' + | 'remote-push-not-configured' + | 'remote-push-disabled'; + +export interface RemotePushRegistrationGateInput { + hasPushPlugin: boolean; + hasDevicePlugin: boolean; + remotePushConfigured: boolean; + remotePushEnabled?: boolean; +} + +/** Whether the client should call `PushNotifications.register()` on this shell. */ +export function shouldRegisterForRemotePush(input: RemotePushRegistrationGateInput): boolean { + return resolveRemotePushSkipReason(input) === null; +} + +/** Resolve the first reason remote push registration should be skipped. */ +export function resolveRemotePushSkipReason( + input: RemotePushRegistrationGateInput +): RemotePushSkipReason | null { + if (!input.hasPushPlugin) { + return 'missing-push-plugin'; + } + + if (!input.hasDevicePlugin) { + return 'missing-device-plugin'; + } + + if (input.remotePushEnabled === false) { + return 'remote-push-disabled'; + } + + if (!input.remotePushConfigured) { + return 'remote-push-not-configured'; + } + + return null; +} + +/** User-facing console message when remote push registration is skipped. */ +export function buildRemotePushSkipMessage(reason: RemotePushSkipReason): string { + switch (reason) { + case 'missing-push-plugin': + return '[mobile] remote push registration skipped: PushNotifications plugin is unavailable on this shell.'; + case 'missing-device-plugin': + return '[mobile] remote push registration skipped: Device plugin is unavailable on this shell.'; + case 'remote-push-disabled': + return '[mobile] remote push registration skipped: disabled by environment configuration.'; + case 'remote-push-not-configured': + return '[mobile] remote push registration skipped: Firebase/APNs is not configured. ' + + 'Add google-services.json (Android) and rebuild to enable push.'; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.spec.ts new file mode 100644 index 0000000..d01abf6 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.spec.ts @@ -0,0 +1,27 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from './mobile-push-token.rules'; + +describe('mobile-push-token.rules', () => { + it('normalizes capacitor runtime platforms', () => { + expect(normalizePushPlatform('ios')).toBe('ios'); + expect(normalizePushPlatform('android')).toBe('android'); + expect(normalizePushPlatform('web')).toBeNull(); + }); + + it('builds a registration payload for the signaling server', () => { + expect(buildPushDeviceTokenRegistrationPayload({ + userId: 'user-1', + token: 'abc123', + platform: 'android' + })).toEqual({ + userId: 'user-1', + token: 'abc123', + platform: 'android' + }); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.ts new file mode 100644 index 0000000..f3fc307 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-push-token.rules.ts @@ -0,0 +1,31 @@ +export type MobilePushPlatform = 'ios' | 'android'; + +export interface PushDeviceTokenRegistrationInput { + userId: string; + token: string; + platform: MobilePushPlatform; +} + +export interface PushDeviceTokenRegistrationPayload { + userId: string; + token: string; + platform: MobilePushPlatform; +} + +export function normalizePushPlatform(platform: string): MobilePushPlatform | null { + if (platform === 'ios' || platform === 'android') { + return platform; + } + + return null; +} + +export function buildPushDeviceTokenRegistrationPayload( + input: PushDeviceTokenRegistrationInput +): PushDeviceTokenRegistrationPayload { + return { + userId: input.userId, + token: input.token, + platform: input.platform + }; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.spec.ts new file mode 100644 index 0000000..be4c8cb --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.spec.ts @@ -0,0 +1,37 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules'; + +describe('mobile-safe-area.rules', () => { + it('builds CSS values with Capacitor and env fallbacks', () => { + expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))'); + expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))'); + }); + + it('sets default safe-area variables on the document root', () => { + const properties = new Map(); + const root = { + style: { + getPropertyValue: (property: string) => properties.get(property) ?? '', + setProperty: (property: string, value: string) => { + properties.set(property, value); + } + } + } as unknown as HTMLElement; + + applyMobileSafeAreaDefaults(root); + + expect(properties.get('--safe-area-inset-top')).toBe('0px'); + expect(properties.get('--safe-area-inset-right')).toBe('0px'); + expect(properties.get('--safe-area-inset-bottom')).toBe('0px'); + expect(properties.get('--safe-area-inset-left')).toBe('0px'); + }); + + it('ignores null roots instead of throwing', () => { + expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow(); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts new file mode 100644 index 0000000..7f1fa08 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts @@ -0,0 +1,30 @@ +const SAFE_AREA_SIDES = [ + 'top', + 'right', + 'bottom', + 'left' +] as const; + +export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number]; + +/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */ +export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string { + return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`; +} + +/** Apply default safe-area CSS variables when the document root is available. */ +export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof document === 'undefined' + ? null + : document.documentElement): void { + if (!root?.style) { + return; + } + + for (const side of SAFE_AREA_SIDES) { + const property = `--safe-area-inset-${side}`; + + if (!root.style.getPropertyValue(property)) { + root.style.setProperty(property, '0px'); + } + } +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.spec.ts new file mode 100644 index 0000000..96a8aa6 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.spec.ts @@ -0,0 +1,17 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { resolveMobileSqliteDatabaseName } from './mobile-sqlite-database-name.rules'; + +describe('mobile-sqlite-database-name.rules', () => { + it('scopes sqlite files per authenticated user', () => { + expect(resolveMobileSqliteDatabaseName('user-123')).toBe('metoyou__user-123'); + }); + + it('uses an anonymous scope before login', () => { + expect(resolveMobileSqliteDatabaseName(null)).toBe('metoyou__anonymous'); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.ts new file mode 100644 index 0000000..b8e6366 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-database-name.rules.ts @@ -0,0 +1,10 @@ +import { MOBILE_SQLITE_DATABASE_NAME } from './mobile-sqlite-schema.rules'; + +const ANONYMOUS_DATABASE_SCOPE = 'anonymous'; + +/** Mirrors IndexedDB per-user database scoping for Capacitor SQLite files. */ +export function resolveMobileSqliteDatabaseName(userId: string | null): string { + const scopedUserId = userId?.trim() || ANONYMOUS_DATABASE_SCOPE; + + return `${MOBILE_SQLITE_DATABASE_NAME}__${encodeURIComponent(scopedUserId)}`; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-execute.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-execute.rules.ts new file mode 100644 index 0000000..7d306f1 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-execute.rules.ts @@ -0,0 +1,15 @@ +/** Runs SQLite DDL/DML statements one at a time - required by @capacitor-community/sqlite `execute()`. */ +export async function executeMobileSqliteStatements( + execute: (statement: string) => Promise, + statements: readonly string[] +): Promise { + for (const statement of statements) { + const trimmed = statement.trim(); + + if (!trimmed) { + continue; + } + + await execute(trimmed); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts new file mode 100644 index 0000000..90c48ff --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts @@ -0,0 +1,65 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + messageToRow, + rowToMessage, + rowToUser, + userToRow +} from './mobile-sqlite-row-mapper.rules'; + +describe('mobile-sqlite-row-mapper.rules', () => { + it('round-trips message fields including JSON metadata', () => { + const message = { + id: 'm1', + roomId: 'r1', + channelId: 'general', + senderId: 'u1', + senderName: 'Alice', + content: 'hello', + timestamp: 100, + editedAt: 101, + isDeleted: false, + replyToId: 'm0', + linkMetadata: [{ url: 'https://example.com', title: 'Example' }], + kind: 'user' as const, + reactions: [] + }; + + const row = messageToRow(message); + const restored = rowToMessage(row, [{ id: 'rx1', messageId: 'm1', oderId: 'u1', userId: 'u1', emoji: '👍', timestamp: 102 }]); + + expect(restored.id).toBe('m1'); + expect(restored.linkMetadata?.[0]?.title).toBe('Example'); + expect(restored.reactions).toHaveLength(1); + }); + + it('maps booleans to integers for SQLite storage', () => { + const user = userToRow({ + id: 'u1', + oderId: 'u1', + username: 'alice', + displayName: 'Alice', + status: 'online', + role: 'member', + joinedAt: 1, + isOnline: true, + isAdmin: false, + isRoomOwner: true, + voiceState: { muted: false, deafened: false, speaking: false } + }); + + expect(user.isOnline).toBe(1); + expect(user.isAdmin).toBe(0); + expect(user.isRoomOwner).toBe(1); + expect(user.voiceState).toContain('muted'); + + const restored = rowToUser(user); + + expect(restored.isOnline).toBe(true); + expect(restored.voiceState).toEqual({ muted: false, deafened: false, speaking: false }); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts new file mode 100644 index 0000000..4c83d18 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts @@ -0,0 +1,320 @@ +import { + DELETED_MESSAGE_CONTENT, + type BanEntry, + type Message, + type Reaction, + type Room, + type User +} from '../../../shared-kernel'; +import type { ChatAttachmentMeta, CustomEmoji } from '../../../shared-kernel'; + +export interface MessageRow { + id: string; + roomId: string; + ownerUserId?: string | null; + channelId?: string | null; + senderId: string; + senderName: string; + content: string; + timestamp: number; + editedAt?: number | null; + isDeleted: number; + replyToId?: string | null; + linkMetadata?: string | null; + kind?: string | null; + systemEvent?: string | null; +} + +export interface UserRow { + id: string; + oderId?: string | null; + username?: string | null; + displayName?: string | null; + description?: string | null; + profileUpdatedAt?: number | null; + avatarUrl?: string | null; + avatarHash?: string | null; + avatarMime?: string | null; + avatarUpdatedAt?: number | null; + status?: string | null; + role?: string | null; + joinedAt?: number | null; + peerId?: string | null; + isOnline: number; + isAdmin: number; + isRoomOwner: number; + voiceState?: string | null; + screenShareState?: string | null; + homeSignalServerUrl?: string | null; +} + +export interface RoomRow { + id: string; + name: string; + description?: string | null; + topic?: string | null; + hostId: string; + password?: string | null; + hasPassword: number; + isPrivate: number; + createdAt: number; + userCount: number; + maxUsers?: number | null; + icon?: string | null; + iconUpdatedAt?: number | null; + slowModeInterval: number; + sourceId?: string | null; + sourceName?: string | null; + sourceUrl?: string | null; +} + +function encodeJson(value: unknown): string | null { + if (value === undefined || value === null) { + return null; + } + + return JSON.stringify(value); +} + +function decodeJson(value: string | null | undefined): T | undefined { + if (!value) { + return undefined; + } + + try { + return JSON.parse(value) as T; + } catch { + return undefined; + } +} + +export function messageToRow(message: Message): MessageRow { + return { + id: message.id, + roomId: message.roomId, + channelId: message.channelId ?? null, + senderId: message.senderId, + senderName: message.senderName, + content: message.content, + timestamp: message.timestamp, + editedAt: message.editedAt ?? null, + isDeleted: message.isDeleted ? 1 : 0, + replyToId: message.replyToId ?? null, + linkMetadata: encodeJson(message.linkMetadata), + kind: message.kind ?? null, + systemEvent: message.systemEvent ?? null + }; +} + +export function rowToMessage(row: MessageRow, reactions: Reaction[] = []): Message { + const message: Message = { + id: row.id, + roomId: row.roomId, + channelId: row.channelId ?? undefined, + senderId: row.senderId, + senderName: row.senderName, + content: row.content, + timestamp: row.timestamp, + editedAt: row.editedAt ?? undefined, + isDeleted: row.isDeleted === 1, + replyToId: row.replyToId ?? undefined, + linkMetadata: decodeJson(row.linkMetadata), + kind: (row.kind as Message['kind']) ?? undefined, + systemEvent: (row.systemEvent as Message['systemEvent']) ?? undefined, + reactions + }; + + if (message.content === DELETED_MESSAGE_CONTENT) { + return { ...message, reactions: [] }; + } + + return message; +} + +export function userToRow(user: User): UserRow { + return { + id: user.id, + oderId: user.oderId, + username: user.username, + displayName: user.displayName, + description: user.description ?? null, + profileUpdatedAt: user.profileUpdatedAt ?? null, + avatarUrl: user.avatarUrl ?? null, + avatarHash: user.avatarHash ?? null, + avatarMime: user.avatarMime ?? null, + avatarUpdatedAt: user.avatarUpdatedAt ?? null, + status: user.status, + role: user.role, + joinedAt: user.joinedAt, + peerId: user.peerId ?? null, + isOnline: user.isOnline ? 1 : 0, + isAdmin: user.isAdmin ? 1 : 0, + isRoomOwner: user.isRoomOwner ? 1 : 0, + voiceState: encodeJson(user.voiceState), + screenShareState: encodeJson(user.screenShareState), + homeSignalServerUrl: user.homeSignalServerUrl ?? null + }; +} + +export function rowToUser(row: UserRow): User { + return { + id: row.id, + oderId: row.oderId ?? row.id, + username: row.username ?? '', + displayName: row.displayName ?? '', + description: row.description ?? undefined, + profileUpdatedAt: row.profileUpdatedAt ?? undefined, + avatarUrl: row.avatarUrl ?? undefined, + avatarHash: row.avatarHash ?? undefined, + avatarMime: row.avatarMime ?? undefined, + avatarUpdatedAt: row.avatarUpdatedAt ?? undefined, + status: (row.status as User['status']) ?? 'offline', + role: (row.role as User['role']) ?? 'member', + joinedAt: row.joinedAt ?? 0, + peerId: row.peerId ?? undefined, + isOnline: row.isOnline === 1, + isAdmin: row.isAdmin === 1, + isRoomOwner: row.isRoomOwner === 1, + voiceState: decodeJson(row.voiceState), + screenShareState: decodeJson(row.screenShareState), + homeSignalServerUrl: row.homeSignalServerUrl ?? undefined + }; +} + +export function roomToRow(room: Room): RoomRow { + return { + id: room.id, + name: room.name, + description: room.description ?? null, + topic: room.topic ?? null, + hostId: room.hostId, + password: room.password ?? null, + hasPassword: room.hasPassword ? 1 : 0, + isPrivate: room.isPrivate ? 1 : 0, + createdAt: room.createdAt, + userCount: room.userCount, + maxUsers: room.maxUsers ?? null, + icon: room.icon ?? null, + iconUpdatedAt: room.iconUpdatedAt ?? null, + slowModeInterval: room.slowModeInterval ?? 0, + sourceId: room.sourceId ?? null, + sourceName: room.sourceName ?? null, + sourceUrl: room.sourceUrl ?? null + }; +} + +export function rowToRoom(row: RoomRow): Room { + return { + id: row.id, + name: row.name, + description: row.description ?? undefined, + topic: row.topic ?? undefined, + hostId: row.hostId, + password: row.password ?? undefined, + hasPassword: row.hasPassword === 1, + isPrivate: row.isPrivate === 1, + createdAt: row.createdAt, + userCount: row.userCount, + maxUsers: row.maxUsers ?? undefined, + icon: row.icon ?? undefined, + iconUpdatedAt: row.iconUpdatedAt ?? undefined, + slowModeInterval: row.slowModeInterval, + sourceId: row.sourceId ?? undefined, + sourceName: row.sourceName ?? undefined, + sourceUrl: row.sourceUrl ?? undefined + }; +} + +export function reactionToValues(reaction: Reaction): unknown[] { + return [ + reaction.id, + reaction.messageId, + reaction.oderId, + reaction.userId, + reaction.emoji, + reaction.timestamp + ]; +} + +export function rowToReaction(row: Reaction): Reaction { + return row; +} + +export function banToValues(ban: BanEntry): unknown[] { + return [ + ban.oderId, + ban.roomId, + ban.userId, + ban.bannedBy, + ban.displayName ?? null, + ban.reason ?? null, + ban.expiresAt ?? null, + ban.timestamp + ]; +} + +export function attachmentToValues(attachment: ChatAttachmentMeta): unknown[] { + return [ + attachment.id, + attachment.messageId, + attachment.filename, + attachment.size, + attachment.mime, + attachment.isImage ? 1 : 0, + attachment.uploaderPeerId ?? null, + attachment.filePath ?? null, + attachment.savedPath ?? null + ]; +} + +export function rowToAttachment(row: { + id: string; + messageId: string; + filename: string; + size: number; + mime: string; + isImage: number; + uploaderPeerId?: string | null; + filePath?: string | null; + savedPath?: string | null; +}): ChatAttachmentMeta { + return { + id: row.id, + messageId: row.messageId, + filename: row.filename, + size: row.size, + mime: row.mime, + isImage: row.isImage === 1, + uploaderPeerId: row.uploaderPeerId ?? undefined, + filePath: row.filePath ?? undefined, + savedPath: row.savedPath ?? undefined + }; +} + +export function customEmojiToValues(emoji: CustomEmoji): unknown[] { + return [ + emoji.id, + emoji.name, + emoji.creatorUserId, + emoji.dataUrl, + emoji.hash, + emoji.mime, + emoji.size, + emoji.createdAt, + emoji.updatedAt + ]; +} + +export function rowToCustomEmoji(row: { + id: string; + name: string; + creatorUserId: string; + dataUrl: string; + hash: string; + mime: string; + size: number; + createdAt: number; + updatedAt: number; +}): CustomEmoji { + return row; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.spec.ts new file mode 100644 index 0000000..e8696d7 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.spec.ts @@ -0,0 +1,43 @@ +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { executeMobileSqliteStatements } from './mobile-sqlite-execute.rules'; +import { + MOBILE_SQLITE_SCHEMA_VERSION, + buildMobileSqliteSchemaStatements, + resolveMobileSqliteMigrationStatements +} from './mobile-sqlite-schema.rules'; + +describe('mobile-sqlite-schema.rules', () => { + it('returns one statement per DDL operation for a fresh database', () => { + const statements = resolveMobileSqliteMigrationStatements(0); + + expect(statements.length).toBe(buildMobileSqliteSchemaStatements().length); + expect(statements.length).toBeGreaterThan(1); + + for (const statement of statements) { + expect(statement.trim().length).toBeGreaterThan(0); + expect(statement).not.toMatch(/;\s*CREATE/i); + } + }); + + it('returns no statements when the stored schema version is current', () => { + expect(resolveMobileSqliteMigrationStatements(MOBILE_SQLITE_SCHEMA_VERSION)).toEqual([]); + }); + + it('executes each migration statement separately', async () => { + const execute = vi.fn(() => Promise.resolve()); + const statements = resolveMobileSqliteMigrationStatements(0).slice(0, 3); + + await executeMobileSqliteStatements(execute, statements); + + expect(execute).toHaveBeenCalledTimes(3); + expect(execute.mock.calls.map(([statement]) => statement)).toEqual(statements); + expect(execute.mock.calls[0]?.[0]).toMatch(/^CREATE TABLE IF NOT EXISTS messages/); + expect(execute.mock.calls[1]?.[0]).toMatch(/^CREATE INDEX IF NOT EXISTS idx_messages_room_id/); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts new file mode 100644 index 0000000..d300c35 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts @@ -0,0 +1,157 @@ +/** Native SQLite database name for Capacitor mobile shells. */ +export const MOBILE_SQLITE_DATABASE_NAME = 'metoyou'; + +/** Bump when adding DDL statements; stored in meta table. */ +export const MOBILE_SQLITE_SCHEMA_VERSION = 2; + +const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version'; + +/** DDL mirrored from Electron TypeORM entities under `electron/entities/`. */ +export function buildMobileSqliteSchemaStatements(): string[] { + return [ + `CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + roomId TEXT NOT NULL, + ownerUserId TEXT, + channelId TEXT, + senderId TEXT NOT NULL, + senderName TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + editedAt INTEGER, + isDeleted INTEGER NOT NULL DEFAULT 0, + replyToId TEXT, + linkMetadata TEXT, + kind TEXT, + systemEvent TEXT + )`, + 'CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(roomId)', + 'CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)', + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + oderId TEXT, + username TEXT, + displayName TEXT, + description TEXT, + profileUpdatedAt INTEGER, + avatarUrl TEXT, + avatarHash TEXT, + avatarMime TEXT, + avatarUpdatedAt INTEGER, + status TEXT, + role TEXT, + joinedAt INTEGER, + peerId TEXT, + isOnline INTEGER NOT NULL DEFAULT 0, + isAdmin INTEGER NOT NULL DEFAULT 0, + isRoomOwner INTEGER NOT NULL DEFAULT 0, + voiceState TEXT, + screenShareState TEXT, + homeSignalServerUrl TEXT + )`, + `CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + topic TEXT, + hostId TEXT NOT NULL, + password TEXT, + hasPassword INTEGER NOT NULL DEFAULT 0, + isPrivate INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + userCount INTEGER NOT NULL DEFAULT 0, + maxUsers INTEGER, + icon TEXT, + iconUpdatedAt INTEGER, + slowModeInterval INTEGER NOT NULL DEFAULT 0, + sourceId TEXT, + sourceName TEXT, + sourceUrl TEXT + )`, + 'CREATE INDEX IF NOT EXISTS idx_rooms_created_at ON rooms(createdAt)', + `CREATE TABLE IF NOT EXISTS reactions ( + id TEXT PRIMARY KEY NOT NULL, + messageId TEXT NOT NULL, + oderId TEXT, + userId TEXT, + emoji TEXT NOT NULL, + timestamp INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_reactions_message_id ON reactions(messageId)', + `CREATE TABLE IF NOT EXISTS bans ( + oderId TEXT NOT NULL, + roomId TEXT NOT NULL, + userId TEXT, + bannedBy TEXT NOT NULL, + displayName TEXT, + reason TEXT, + expiresAt INTEGER, + timestamp INTEGER NOT NULL, + PRIMARY KEY (oderId, roomId) + )`, + 'CREATE INDEX IF NOT EXISTS idx_bans_room_id ON bans(roomId)', + `CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY NOT NULL, + messageId TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + mime TEXT NOT NULL, + isImage INTEGER NOT NULL DEFAULT 0, + uploaderPeerId TEXT, + filePath TEXT, + savedPath TEXT + )`, + 'CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(messageId)', + `CREATE TABLE IF NOT EXISTS custom_emojis ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + creatorUserId TEXT NOT NULL, + dataUrl TEXT NOT NULL, + hash TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_custom_emojis_updated_at ON custom_emojis(updatedAt)', + `CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + )`, + `CREATE TABLE IF NOT EXISTS push_device_tokens ( + id TEXT PRIMARY KEY NOT NULL, + userId TEXT NOT NULL, + platform TEXT NOT NULL, + token TEXT NOT NULL, + updatedAt INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_push_device_tokens_user_id ON push_device_tokens(userId)', + `INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '${MOBILE_SQLITE_SCHEMA_VERSION}')` + ]; +} + +const SCHEMA_V2_MESSAGE_COLUMNS = [ + 'ALTER TABLE messages ADD COLUMN linkMetadata TEXT', + 'ALTER TABLE messages ADD COLUMN kind TEXT', + 'ALTER TABLE messages ADD COLUMN systemEvent TEXT' +]; + +/** Returns DDL statements that still need to run for the stored schema version. */ +export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] { + if (storedVersion >= MOBILE_SQLITE_SCHEMA_VERSION) { + return []; + } + + if (storedVersion <= 0) { + return buildMobileSqliteSchemaStatements(); + } + + const statements: string[] = []; + + if (storedVersion < 2) { + statements.push(...SCHEMA_V2_MESSAGE_COLUMNS); + statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '2')`); + } + + return statements; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.spec.ts new file mode 100644 index 0000000..d40895d --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.spec.ts @@ -0,0 +1,61 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + detectRuntimePlatform, + isCapacitorNativeRuntime, + shouldUseMobileAttachmentPicker, + type RuntimePlatform +} from './platform-detection.rules'; + +describe('platform-detection.rules', () => { + describe('detectRuntimePlatform', () => { + it('prefers electron when the preload API is present', () => { + expect( + detectRuntimePlatform({ + hasElectronApi: true, + capacitorIsNative: true + }) + ).toBe('electron'); + }); + + it('detects capacitor when running in a native shell without electron', () => { + expect( + detectRuntimePlatform({ + hasElectronApi: false, + capacitorIsNative: true + }) + ).toBe('capacitor'); + }); + + it('falls back to browser for web runtimes', () => { + expect( + detectRuntimePlatform({ + hasElectronApi: false, + capacitorIsNative: false + }) + ).toBe('browser'); + }); + }); + + describe('shouldUseMobileAttachmentPicker', () => { + it('enables the picker on capacitor native and mobile web viewports', () => { + expect(shouldUseMobileAttachmentPicker('capacitor', true)).toBe(true); + expect(shouldUseMobileAttachmentPicker('browser', true)).toBe(true); + }); + + it('keeps drag-and-drop on desktop browser and electron', () => { + expect(shouldUseMobileAttachmentPicker('browser', false)).toBe(false); + expect(shouldUseMobileAttachmentPicker('electron', true)).toBe(false); + }); + }); + + describe('isCapacitorNativeRuntime', () => { + it('returns false when Capacitor is unavailable', () => { + expect(isCapacitorNativeRuntime()).toBe(false); + }); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.ts new file mode 100644 index 0000000..f1b5f3e --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/platform-detection.rules.ts @@ -0,0 +1,45 @@ +export type RuntimePlatform = 'electron' | 'capacitor' | 'browser'; + +export interface PlatformDetectionInput { + hasElectronApi: boolean; + capacitorIsNative?: boolean; +} + +type CapacitorWindow = Window & { + Capacitor?: { + isNativePlatform?: () => boolean; + }; +}; + +/** Resolve the active runtime shell used by the product client. */ +export function detectRuntimePlatform(input: PlatformDetectionInput): RuntimePlatform { + if (input.hasElectronApi) { + return 'electron'; + } + + if (input.capacitorIsNative) { + return 'capacitor'; + } + + return 'browser'; +} + +/** Best-effort detection of a Capacitor native shell without importing Capacitor modules. */ +export function isCapacitorNativeRuntime(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const capacitor = (window as CapacitorWindow).Capacitor; + + return capacitor?.isNativePlatform?.() === true; +} + +/** Whether the chat composer should expose a tap-to-attach control instead of drag-and-drop only. */ +export function shouldUseMobileAttachmentPicker(runtime: RuntimePlatform, isMobileViewport: boolean): boolean { + if (runtime === 'electron') { + return false; + } + + return runtime === 'capacitor' || isMobileViewport; +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts new file mode 100644 index 0000000..103bf5a --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts @@ -0,0 +1,34 @@ +import { Injectable, inject } from '@angular/core'; + +import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter'; +import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Facade for foreground/background lifecycle events. */ +@Injectable({ providedIn: 'root' }) +export class MobileAppLifecycleService { + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter(); + private initialized = false; + + async initialize(): Promise { + if (this.initialized) { + return; + } + + await this.adapter.initialize(); + this.mobilePlatform.refreshRuntimeDetection(); + this.initialized = true; + } + + onAppStateChange(handler: (isActive: boolean) => void): void { + this.adapter.onAppStateChange(handler); + } + + private createAdapter(): MobileAppLifecycleAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobileAppLifecycleAdapter() + : new WebMobileAppLifecycleAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-call-session.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-call-session.service.ts new file mode 100644 index 0000000..55e9f5b --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-call-session.service.ts @@ -0,0 +1,135 @@ +import { + DestroyRef, + Injectable, + inject +} from '@angular/core'; +import { Router } from '@angular/router'; + +import type { CallNotificationActionIntent } from '../logic/call-notification.rules'; +import { MobileAppLifecycleService } from './mobile-app-lifecycle.service'; +import { MobileCallKitService } from './mobile-callkit.service'; +import { MobileMediaService } from './mobile-media.service'; +import { MobileNotificationsService } from './mobile-notifications.service'; +import { MobilePictureInPictureService } from './mobile-picture-in-picture.service'; +import { MobilePlatformService } from './mobile-platform.service'; + +export interface ActiveCallSessionState { + callId: string; + displayName: string; + isMuted: boolean; + focusedStreamVideo?: HTMLVideoElement | null; +} + +/** Coordinates in-call notifications, background audio, and stream pop-out for mobile shells. */ +@Injectable({ providedIn: 'root' }) +export class MobileCallSessionService { + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly notifications = inject(MobileNotificationsService); + private readonly media = inject(MobileMediaService); + private readonly pictureInPicture = inject(MobilePictureInPictureService); + private readonly lifecycle = inject(MobileAppLifecycleService); + private readonly callKit = inject(MobileCallKitService); + + private activeSession: ActiveCallSessionState | null = null; + private actionHandler: ((intent: CallNotificationActionIntent, callId: string) => void) | null = null; + private wired = false; + + initialize(): void { + if (this.wired) { + return; + } + + this.wired = true; + + void this.notifications.initialize(); + void this.lifecycle.initialize(); + + this.notifications.onCallAction(({ callId, intent }) => { + void this.router.navigate(['/call', callId]); + this.actionHandler?.(intent, callId); + }); + + this.lifecycle.onAppStateChange((isActive) => { + void this.handleAppStateChange(isActive); + }); + + this.destroyRef.onDestroy(() => { + this.activeSession = null; + this.actionHandler = null; + }); + } + + onCallControlAction(handler: (intent: CallNotificationActionIntent, callId: string) => void): void { + this.actionHandler = handler; + } + + async notifyIncomingCall(displayName: string, callId: string): Promise { + if (!this.shouldHandleMobileCalls()) { + return; + } + + await this.notifications.showIncomingCall(displayName, callId); + } + + async startActiveCall(session: ActiveCallSessionState): Promise { + if (!this.shouldHandleMobileCalls()) { + return; + } + + this.activeSession = session; + await this.media.startBackgroundAudioSession(); + await this.callKit.startActiveCall(session.callId, session.displayName); + await this.notifications.dismissIncomingCall(session.callId); + await this.notifications.showActiveCall(session); + } + + async updateActiveCall(session: ActiveCallSessionState): Promise { + if (!this.shouldHandleMobileCalls()) { + return; + } + + this.activeSession = session; + await this.notifications.showActiveCall(session); + } + + async endActiveCall(callId: string): Promise { + await this.notifications.dismissIncomingCall(callId); + await this.notifications.dismissActiveCall(callId); + await this.callKit.endActiveCall(callId); + await this.media.stopBackgroundAudioSession(); + await this.pictureInPicture.exit(); + + if (this.activeSession?.callId === callId) { + this.activeSession = null; + } + } + + setFocusedStreamVideo(videoElement: HTMLVideoElement | null): void { + if (!this.activeSession) { + return; + } + + this.activeSession = { + ...this.activeSession, + focusedStreamVideo: videoElement + }; + } + + private shouldHandleMobileCalls(): boolean { + return this.mobilePlatform.isNativeMobile(); + } + + private async handleAppStateChange(isActive: boolean): Promise { + if (!this.activeSession || isActive) { + return; + } + + const video = this.activeSession.focusedStreamVideo; + + if (video && this.pictureInPicture.isSupported()) { + await this.pictureInPicture.enter(video); + } + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-callkit.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-callkit.service.ts new file mode 100644 index 0000000..767c23b --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-callkit.service.ts @@ -0,0 +1,35 @@ +import { Injectable, inject } from '@angular/core'; + +import type { MobileCallKitAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter'; +import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Facade for iOS CallKit active-call reporting. */ +@Injectable({ providedIn: 'root' }) +export class MobileCallKitService { + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly adapter: MobileCallKitAdapter = this.createAdapter(); + + startActiveCall(callId: string, displayName: string): Promise { + if (!this.mobilePlatform.isCapacitor()) { + return Promise.resolve(); + } + + return this.adapter.startActiveCall(callId, displayName); + } + + endActiveCall(callId: string): Promise { + if (!this.mobilePlatform.isCapacitor()) { + return Promise.resolve(); + } + + return this.adapter.endActiveCall(callId); + } + + private createAdapter(): MobileCallKitAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobileCallKitAdapter() + : new WebMobileCallKitAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts new file mode 100644 index 0000000..6d5c140 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts @@ -0,0 +1,42 @@ +import { + Injectable, + computed, + inject +} from '@angular/core'; + +import type { MobileMediaAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter'; +import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Facade for mobile media affordances: attachments, speakerphone, background audio, capture limits. */ +@Injectable({ providedIn: 'root' }) +export class MobileMediaService { + readonly isScreenShareSupported = computed(() => this.adapter.isScreenShareSupported()); + readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported()); + + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly adapter: MobileMediaAdapter = this.createAdapter(); + + pickAttachments(): Promise { + return this.adapter.pickAttachments(); + } + + setSpeakerphoneEnabled(enabled: boolean): Promise { + return this.adapter.setSpeakerphoneEnabled(enabled); + } + + startBackgroundAudioSession(): Promise { + return this.adapter.startBackgroundAudioSession(); + } + + stopBackgroundAudioSession(): Promise { + return this.adapter.stopBackgroundAudioSession(); + } + + private createAdapter(): MobileMediaAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobileMediaAdapter() + : new WebMobileMediaAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-notifications.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-notifications.service.ts new file mode 100644 index 0000000..821ebce --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-notifications.service.ts @@ -0,0 +1,56 @@ +import { Injectable, inject } from '@angular/core'; + +import type { CallNotificationActionIntent } from '../logic/call-notification.rules'; +import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules'; +import type { MobileNotificationAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter'; +import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; +import { MobilePushRegistrationService } from './mobile-push-registration.service'; + +/** Facade for push/local notifications with platform-specific adapters. */ +@Injectable({ providedIn: 'root' }) +export class MobileNotificationsService { + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly pushRegistration = inject(MobilePushRegistrationService); + private readonly adapter: MobileNotificationAdapter = this.createAdapter(); + private initialized = false; + + async initialize(): Promise { + if (this.initialized) { + return; + } + + await this.adapter.initialize(); + this.pushRegistration.initialize(); + this.initialized = true; + } + + async showIncomingCall(displayName: string, callId: string): Promise { + await this.initialize(); + await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId)); + } + + async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise { + await this.initialize(); + await this.adapter.showCallNotification(buildInCallNotification(input)); + } + + async dismissIncomingCall(callId: string): Promise { + await this.adapter.dismissCallNotification(callId, 'incoming'); + } + + async dismissActiveCall(callId: string): Promise { + await this.adapter.dismissCallNotification(callId, 'active'); + } + + onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void { + this.adapter.onActionSelected(handler); + } + + private createAdapter(): MobileNotificationAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobileNotificationsAdapter() + : new WebMobileNotificationsAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-persistence.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-persistence.service.ts new file mode 100644 index 0000000..8cfadc9 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-persistence.service.ts @@ -0,0 +1,29 @@ +import { Injectable, inject } from '@angular/core'; + +import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter'; +import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; +import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service'; + +/** Facade for native SQLite persistence on mobile shells. */ +@Injectable({ providedIn: 'root' }) +export class MobilePersistenceService { + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly sqliteConnection = inject(MobileSqliteConnectionService); + private readonly adapter: MobilePersistenceAdapter = this.createAdapter(); + + get isNativeSqlite(): boolean { + return this.adapter.isNativeSqlite; + } + + initialize(): Promise { + return this.adapter.initialize(); + } + + private createAdapter(): MobilePersistenceAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobilePersistenceAdapter(this.sqliteConnection) + : new WebMobilePersistenceAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-picture-in-picture.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-picture-in-picture.service.ts new file mode 100644 index 0000000..75f0c87 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-picture-in-picture.service.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; + +import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts'; +import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter'; +import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Facade for stream pop-out while the app is backgrounded. */ +@Injectable({ providedIn: 'root' }) +export class MobilePictureInPictureService { + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter(); + + isSupported(): boolean { + return this.adapter.isSupported(); + } + + enter(videoElement: HTMLVideoElement): Promise { + return this.adapter.enter(videoElement); + } + + exit(): Promise { + return this.adapter.exit(); + } + + private createAdapter(): MobilePictureInPictureAdapter { + return this.mobilePlatform.isCapacitor() + ? new CapacitorMobilePictureInPictureAdapter() + : new WebMobilePictureInPictureAdapter(); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.spec.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.spec.ts new file mode 100644 index 0000000..7d8213c --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.spec.ts @@ -0,0 +1,46 @@ +import '@angular/compiler'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { + describe, + expect, + it +} from 'vitest'; + +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import { ViewportService } from '../../../core/platform/viewport.service'; +import { MobilePlatformService } from './mobile-platform.service'; + +function createService(options: { isMobile: boolean }): MobilePlatformService { + const injector = Injector.create({ + providers: [ + MobilePlatformService, + { + provide: ElectronBridgeService, + useValue: { isAvailable: false } + }, + { + provide: ViewportService, + useValue: { + isMobile: () => options.isMobile + } + } + ] + }); + + return runInInjectionContext(injector, () => injector.get(MobilePlatformService)); +} + +describe('MobilePlatformService', () => { + it('reports browser runtime and hides attachment button on desktop viewport', () => { + const service = createService({ isMobile: false }); + + expect(service.runtime()).toBe('browser'); + expect(service.shouldShowAttachmentButton()).toBe(false); + }); + + it('enables attachment button on mobile viewport in browser runtime', () => { + const service = createService({ isMobile: true }); + + expect(service.shouldShowAttachmentButton()).toBe(true); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.ts new file mode 100644 index 0000000..9940741 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-platform.service.ts @@ -0,0 +1,51 @@ +import { + Injectable, + computed, + inject, + signal +} from '@angular/core'; + +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import { ViewportService } from '../../../core/platform/viewport.service'; +import { + detectRuntimePlatform, + isCapacitorNativeRuntime, + shouldUseMobileAttachmentPicker, + type RuntimePlatform +} from '../logic/platform-detection.rules'; +import type { MobilePlatformSnapshot } from '../contracts/mobile.contracts'; + +/** Detects runtime shell and exposes mobile capability flags to Angular domains. */ +@Injectable({ providedIn: 'root' }) +export class MobilePlatformService { + readonly runtime = computed(() => this.runtimeSignal()); + readonly isCapacitor = computed(() => this.runtimeSignal() === 'capacitor'); + readonly isNativeMobile = computed(() => this.isCapacitor()); + readonly shouldShowAttachmentButton = computed(() => + shouldUseMobileAttachmentPicker(this.runtimeSignal(), this.viewport.isMobile()) + ); + readonly snapshot = computed(() => ({ + runtime: this.runtimeSignal(), + isNativeMobile: this.isCapacitor(), + isCapacitor: this.isCapacitor() + })); + + private readonly electronBridge = inject(ElectronBridgeService); + private readonly viewport = inject(ViewportService); + private readonly runtimeSignal = signal( + detectRuntimePlatform({ + hasElectronApi: this.electronBridge.isAvailable, + capacitorIsNative: isCapacitorNativeRuntime() + }) + ); + + /** Re-evaluate runtime detection after Capacitor bootstraps on device. */ + refreshRuntimeDetection(): void { + this.runtimeSignal.set( + detectRuntimePlatform({ + hasElectronApi: this.electronBridge.isAvailable, + capacitorIsNative: isCapacitorNativeRuntime() + }) + ); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.spec.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.spec.ts new file mode 100644 index 0000000..c283b10 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.spec.ts @@ -0,0 +1,111 @@ +import '@angular/compiler'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; +import { Injector, runInInjectionContext } from '@angular/core'; + +const pushState = vi.hoisted(() => ({ + register: vi.fn(() => Promise.resolve()), + checkPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })), + requestPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })), + addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) +})); +const deviceState = vi.hoisted(() => ({ + getInfo: vi.fn(() => Promise.resolve({ platform: 'android' })) +})); +const mobilePlatformState = vi.hoisted(() => ({ + isCapacitor: true +})); +const remotePushState = vi.hoisted(() => ({ + configured: true +})); + +vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({ + loadCapacitorPushNotificationsPlugin: () => pushState, + loadCapacitorDevicePlugin: () => deviceState +})); + +vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({ + MetoyouMobile: { + isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured })) + } +})); + +import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin'; +import { MobilePlatformService } from './mobile-platform.service'; +import { MobilePushRegistrationService } from './mobile-push-registration.service'; + +function createService(): MobilePushRegistrationService { + const injector = Injector.create({ + providers: [ + MobilePushRegistrationService, + { + provide: MobilePlatformService, + useValue: { + isCapacitor: () => mobilePlatformState.isCapacitor + } + } + ] + }); + + return runInInjectionContext(injector, () => injector.get(MobilePushRegistrationService)); +} + +describe('MobilePushRegistrationService', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mobilePlatformState.isCapacitor = true; + remotePushState.configured = true; + pushState.register.mockClear(); + pushState.addListener.mockClear(); + vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('skips PushNotifications.register when remote push is unavailable', async () => { + remotePushState.configured = false; + + const service = createService(); + + service.initialize(); + + await vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('google-services.json') + ); + }); + + expect(pushState.register).not.toHaveBeenCalled(); + }); + + it('registers for remote push when Firebase/APNs is configured', async () => { + const service = createService(); + + service.initialize(); + + await vi.waitFor(() => { + expect(pushState.register).toHaveBeenCalledTimes(1); + }); + + expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled(); + }); + + it('does not wire listeners on non-capacitor shells', () => { + mobilePlatformState.isCapacitor = false; + + const service = createService(); + + service.initialize(); + + expect(pushState.register).not.toHaveBeenCalled(); + expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled(); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.ts new file mode 100644 index 0000000..c15a2fe --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-push-registration.service.ts @@ -0,0 +1,139 @@ +import { Injectable, inject } from '@angular/core'; + +import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage'; +import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules'; +import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules'; +import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader'; +import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */ +@Injectable({ providedIn: 'root' }) +export class MobilePushRegistrationService { + private readonly mobilePlatform = inject(MobilePlatformService); + private wired = false; + private latestToken: string | null = null; + + initialize(): void { + if (this.wired || !this.mobilePlatform.isCapacitor()) { + return; + } + + this.wired = true; + void this.registerPushListeners(); + } + + async registerCurrentToken(): Promise { + if (!this.latestToken) { + return; + } + + await this.persistToken(this.latestToken); + } + + private async registerPushListeners(): Promise { + const PushNotifications = loadCapacitorPushNotificationsPlugin(); + const Device = loadCapacitorDevicePlugin(); + const remotePushConfigured = await this.isRemotePushConfigured(); + const skipReason = resolveRemotePushSkipReason({ + hasPushPlugin: !!PushNotifications, + hasDevicePlugin: !!Device, + remotePushConfigured + }); + + if (skipReason) { + console.warn(buildRemotePushSkipMessage(skipReason)); + return; + } + + const pushNotifications = PushNotifications; + + if (!pushNotifications) { + return; + } + + try { + const permission = await pushNotifications.checkPermissions(); + + if (permission.receive === 'prompt') { + await pushNotifications.requestPermissions(); + } + + await pushNotifications.addListener('registration', (event) => { + this.latestToken = event.value; + void this.persistToken(event.value); + }); + + await pushNotifications.addListener('registrationError', (error) => { + console.warn('[mobile] push registration failed', error); + }); + + await pushNotifications.register(); + } catch (error) { + console.warn('[mobile] remote push registration skipped after failure', error); + } + } + + private async isRemotePushConfigured(): Promise { + try { + const result = await MetoyouMobile.isRemotePushConfigured(); + + return result.configured === true; + } catch { + return false; + } + } + + private async persistToken(token: string): Promise { + const userId = getStoredCurrentUserId(); + + if (!userId) { + return; + } + + const Device = loadCapacitorDevicePlugin(); + const deviceInfo = Device ? await Device.getInfo() : null; + const platform = normalizePushPlatform(deviceInfo?.platform ?? ''); + + if (!platform) { + return; + } + + const payload = buildPushDeviceTokenRegistrationPayload({ + userId, + token, + platform + }); + const serverUrl = this.resolveSignalingServerUrl(); + + if (!serverUrl) { + return; + } + + try { + await fetch(`${serverUrl}/api/users/device-tokens`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + } catch (error) { + console.warn('[mobile] failed to persist push device token', error); + } + } + + private resolveSignalingServerUrl(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const configured = window.localStorage.getItem('metoyou.signalServerUrl'); + + if (configured) { + return configured.replace(/\/$/, ''); + } + + return `${window.location.origin}`; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-sqlite-connection.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-sqlite-connection.service.ts new file mode 100644 index 0000000..bb8c109 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-sqlite-connection.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; + +import { createCapacitorSqliteStore, type MobileSqliteStore } from '../adapters/capacitor/capacitor-sqlite.store'; +import { resolveMobileSqliteDatabaseName } from '../logic/mobile-sqlite-database-name.rules'; +import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage'; + +/** Shared native SQLite connection used by mobile persistence and DatabaseService. */ +@Injectable({ providedIn: 'root' }) +export class MobileSqliteConnectionService { + private store: MobileSqliteStore | null = null; + private activeDatabaseName: string | null = null; + private initializationPromise: Promise | null = null; + private initializationFailed = false; + + get isAvailable(): boolean { + return this.store?.isAvailable === true; + } + + async initialize(): Promise { + if (this.initializationFailed) { + return null; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.openStore() + .catch((error) => { + this.initializationFailed = true; + console.error('[mobile] SQLite initialization failed', error); + return null; + }) + .finally(() => { + this.initializationPromise = null; + }); + + return this.initializationPromise; + } + + async getStore(): Promise { + const store = await this.initialize(); + + if (!store?.isAvailable) { + throw new Error('Native SQLite store is unavailable on this shell.'); + } + + return store; + } + + private async openStore(): Promise { + const databaseName = resolveMobileSqliteDatabaseName(getStoredCurrentUserId()); + + if (this.store && this.activeDatabaseName === databaseName && this.store.isAvailable) { + return this.store; + } + + this.store = await createCapacitorSqliteStore(databaseName); + + if (!this.store) { + return null; + } + + await this.store.initialize(); + this.activeDatabaseName = databaseName; + + return this.store; + } +} diff --git a/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts b/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts new file mode 100644 index 0000000..a186bae --- /dev/null +++ b/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts @@ -0,0 +1,503 @@ +import { Injectable, inject } from '@angular/core'; +import { + DELETED_MESSAGE_CONTENT, + type BanEntry, + type Message, + type Reaction, + type Room, + type User +} from '../../shared-kernel'; +import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel'; +import { getStoredCurrentUserId } from '../../core/storage/current-user-storage'; +import { + attachmentToValues, + banToValues, + customEmojiToValues, + messageToRow, + reactionToValues, + roomToRow, + rowToAttachment, + rowToCustomEmoji, + rowToMessage, + rowToRoom, + rowToUser, + userToRow, + type MessageRow, + type RoomRow, + type UserRow +} from '../mobile/logic/mobile-sqlite-row-mapper.rules'; +import { MobileSqliteConnectionService } from '../mobile/services/mobile-sqlite-connection.service'; +import type { RoomMessageStats } from './database.service'; + +/** + * SQLite-backed database service for Capacitor native shells. + * + * Mirrors the {@link BrowserDatabaseService} API using `@capacitor-community/sqlite`. + */ +@Injectable({ providedIn: 'root' }) +export class CapacitorDatabaseService { + private readonly connection = inject(MobileSqliteConnectionService); + + async initialize(): Promise { + await this.connection.initialize(); + } + + async saveMessage(message: Message): Promise { + const store = await this.connection.getStore(); + const row = messageToRow(message); + + await store.run( + `INSERT OR REPLACE INTO messages ( + id, roomId, ownerUserId, channelId, senderId, senderName, content, + timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id, + row.roomId, + row.ownerUserId ?? null, + row.channelId ?? null, + row.senderId, + row.senderName, + row.content, + row.timestamp, + row.editedAt ?? null, + row.isDeleted, + row.replyToId ?? null, + row.linkMetadata ?? null, + row.kind ?? null, + row.systemEvent ?? null + ] + ); + } + + async getMessages( + roomId: string, + limit = 100, + offset = 0, + channelId?: string, + beforeTimestamp?: number + ): Promise { + const store = await this.connection.getStore(); + const rows = await store.query(` + SELECT * FROM messages + WHERE roomId = ? + ${beforeTimestamp !== undefined ? 'AND timestamp < ?' : ''} + ORDER BY timestamp ASC + `, beforeTimestamp !== undefined ? [roomId, beforeTimestamp] : [roomId]); + const scopedRows = channelId + ? rows.filter((row) => (row.channelId || 'general') === channelId) + : rows; + const endIndex = Math.max(scopedRows.length - offset, 0); + const startIndex = Math.max(endIndex - limit, 0); + const slice = scopedRows.slice(startIndex, endIndex); + + return this.hydrateMessages(slice.map((row) => rowToMessage(row))); + } + + async getMessagesSince(roomId: string, sinceTimestamp: number): Promise { + const store = await this.connection.getStore(); + const rows = await store.query( + 'SELECT * FROM messages WHERE roomId = ? AND timestamp > ? ORDER BY timestamp ASC', + [roomId, sinceTimestamp] + ); + + return this.hydrateMessages(rows.map((row) => rowToMessage(row))); + } + + async getRoomMessageStats(roomId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query<{ count: number; lastUpdated: number }>( + `SELECT COUNT(*) as count, MAX(COALESCE(editedAt, timestamp, 0)) as lastUpdated + FROM messages WHERE roomId = ?`, + [roomId] + ); + + return { + count: Number(rows[0]?.count ?? 0), + lastUpdated: Number(rows[0]?.lastUpdated ?? 0) + }; + } + + async deleteMessage(messageId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM messages WHERE id = ?', [messageId]); + } + + async updateMessage(messageId: string, updates: Partial): Promise { + const existing = await this.getMessageById(messageId); + + if (existing) { + await this.saveMessage({ ...existing, ...updates }); + } + } + + async getMessageById(messageId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]); + const row = rows[0]; + + if (!row) { + return null; + } + + const messages = await this.hydrateMessages([rowToMessage(row)]); + + return messages[0] ?? null; + } + + async clearRoomMessages(roomId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM messages WHERE roomId = ?', [roomId]); + } + + async saveReaction(reaction: Reaction): Promise { + const store = await this.connection.getStore(); + const existing = await store.query( + 'SELECT * FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ? LIMIT 1', + [ + reaction.messageId, + reaction.userId, + reaction.emoji + ] + ); + + if (existing.length === 0) { + await store.run( + 'INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp) VALUES (?, ?, ?, ?, ?, ?)', + reactionToValues(reaction) + ); + } + } + + async removeReaction(messageId: string, userId: string, emoji: string): Promise { + const store = await this.connection.getStore(); + + await store.run( + 'DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', + [ + messageId, + userId, + emoji + ] + ); + } + + async getReactionsForMessage(messageId: string): Promise { + const store = await this.connection.getStore(); + + return store.query( + 'SELECT * FROM reactions WHERE messageId = ? ORDER BY timestamp ASC', + [messageId] + ); + } + + async saveUser(user: User): Promise { + const store = await this.connection.getStore(); + const row = userToRow(user); + + await store.run( + `INSERT OR REPLACE INTO users ( + id, oderId, username, displayName, description, profileUpdatedAt, + avatarUrl, avatarHash, avatarMime, avatarUpdatedAt, status, role, + joinedAt, peerId, isOnline, isAdmin, isRoomOwner, voiceState, + screenShareState, homeSignalServerUrl + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id, + row.oderId ?? null, + row.username ?? null, + row.displayName ?? null, + row.description ?? null, + row.profileUpdatedAt ?? null, + row.avatarUrl ?? null, + row.avatarHash ?? null, + row.avatarMime ?? null, + row.avatarUpdatedAt ?? null, + row.status ?? null, + row.role ?? null, + row.joinedAt ?? null, + row.peerId ?? null, + row.isOnline, + row.isAdmin, + row.isRoomOwner, + row.voiceState ?? null, + row.screenShareState ?? null, + row.homeSignalServerUrl ?? null + ] + ); + } + + async getUser(userId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); + + return rows[0] ? rowToUser(rows[0]) : null; + } + + async getCurrentUser(): Promise { + const userId = await this.getCurrentUserId(); + + return userId ? this.getUser(userId) : null; + } + + async getCurrentUserId(): Promise { + const store = await this.connection.getStore(); + const rows = await store.query<{ value: string }>( + "SELECT value FROM meta WHERE key = 'currentUserId' LIMIT 1" + ); + + return rows[0]?.value?.trim() || null; + } + + async setCurrentUserId(userId: string): Promise { + const store = await this.connection.getStore(); + + await store.run( + "INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)", + [userId] + ); + + if (getStoredCurrentUserId() !== userId) { + await this.connection.initialize(); + } + } + + async getUsersByRoom(_roomId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query('SELECT * FROM users'); + + return rows.map(rowToUser); + } + + async updateUser(userId: string, updates: Partial): Promise { + const existing = await this.getUser(userId); + + if (existing) { + await this.saveUser({ ...existing, ...updates }); + } + } + + async saveRoom(room: Room): Promise { + const store = await this.connection.getStore(); + const row = roomToRow(room); + + await store.run( + `INSERT OR REPLACE INTO rooms ( + id, name, description, topic, hostId, password, hasPassword, isPrivate, + createdAt, userCount, maxUsers, icon, iconUpdatedAt, slowModeInterval, + sourceId, sourceName, sourceUrl + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id, + row.name, + row.description ?? null, + row.topic ?? null, + row.hostId, + row.password ?? null, + row.hasPassword, + row.isPrivate, + row.createdAt, + row.userCount, + row.maxUsers ?? null, + row.icon ?? null, + row.iconUpdatedAt ?? null, + row.slowModeInterval, + row.sourceId ?? null, + row.sourceName ?? null, + row.sourceUrl ?? null + ] + ); + } + + async getRoom(roomId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]); + + return rows[0] ? rowToRoom(rows[0]) : null; + } + + async getAllRooms(): Promise { + const store = await this.connection.getStore(); + const rows = await store.query('SELECT * FROM rooms ORDER BY createdAt ASC'); + + return rows.map(rowToRoom); + } + + async deleteRoom(roomId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM rooms WHERE id = ?', [roomId]); + await this.clearRoomMessages(roomId); + } + + async updateRoom(roomId: string, updates: Partial): Promise { + const existing = await this.getRoom(roomId); + + if (existing) { + await this.saveRoom({ ...existing, ...updates }); + } + } + + async saveBan(ban: BanEntry): Promise { + const store = await this.connection.getStore(); + + await store.run( + `INSERT OR REPLACE INTO bans ( + oderId, roomId, userId, bannedBy, displayName, reason, expiresAt, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + banToValues(ban) + ); + } + + async removeBan(oderId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM bans WHERE oderId = ?', [oderId]); + } + + async getBansForRoom(roomId: string): Promise { + const store = await this.connection.getStore(); + const now = Date.now(); + const rows = await store.query( + 'SELECT * FROM bans WHERE roomId = ?', + [roomId] + ); + + return rows.filter((ban) => !ban.expiresAt || ban.expiresAt > now); + } + + async isUserBanned(userId: string, roomId: string): Promise { + const activeBans = await this.getBansForRoom(roomId); + + return activeBans.some((ban) => ban.oderId === userId); + } + + async saveAttachment(attachment: ChatAttachmentMeta): Promise { + const store = await this.connection.getStore(); + + await store.run( + `INSERT OR REPLACE INTO attachments ( + id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + attachmentToValues(attachment) + ); + } + + async getAttachmentsForMessage(messageId: string): Promise { + const store = await this.connection.getStore(); + const rows = await store.query[0]>( + 'SELECT * FROM attachments WHERE messageId = ?', + [messageId] + ); + + return rows.map(rowToAttachment); + } + + async getAllAttachments(): Promise { + const store = await this.connection.getStore(); + const rows = await store.query[0]>('SELECT * FROM attachments'); + + return rows.map(rowToAttachment); + } + + async saveCustomEmoji(emoji: CustomEmoji): Promise { + const store = await this.connection.getStore(); + + await store.run( + `INSERT OR REPLACE INTO custom_emojis ( + id, name, creatorUserId, dataUrl, hash, mime, size, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + customEmojiToValues(emoji) + ); + } + + async getCustomEmojis(): Promise { + const store = await this.connection.getStore(); + const rows = await store.query[0]>('SELECT * FROM custom_emojis'); + + return rows.map(rowToCustomEmoji); + } + + async deleteCustomEmoji(emojiId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM custom_emojis WHERE id = ?', [emojiId]); + } + + async deleteAttachmentsForMessage(messageId: string): Promise { + const store = await this.connection.getStore(); + + await store.run('DELETE FROM attachments WHERE messageId = ?', [messageId]); + } + + async clearAllData(): Promise { + const store = await this.connection.getStore(); + const tables = [ + 'messages', + 'users', + 'rooms', + 'reactions', + 'bans', + 'attachments', + 'custom_emojis', + 'meta', + 'push_device_tokens' + ]; + + for (const table of tables) { + await store.run(`DELETE FROM ${table}`); + } + } + + private async hydrateMessages(messages: Message[]): Promise { + if (messages.length === 0) { + return []; + } + + const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id)); + + return messages.map((message) => this.normaliseMessage({ + ...message, + reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? [] + })); + } + + private async loadReactionsForMessages(messageIds: readonly string[]): Promise> { + const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0)); + const reactionsByMessageId = new Map(); + + if (messageIdSet.size === 0) { + return reactionsByMessageId; + } + + const store = await this.connection.getStore(); + const allReactions = await store.query('SELECT * FROM reactions'); + + for (const reaction of allReactions) { + if (!messageIdSet.has(reaction.messageId)) { + continue; + } + + const reactions = reactionsByMessageId.get(reaction.messageId) ?? []; + + reactions.push(reaction); + reactionsByMessageId.set(reaction.messageId, reactions); + } + + for (const reactions of reactionsByMessageId.values()) { + reactions.sort((first, second) => first.timestamp - second.timestamp); + } + + return reactionsByMessageId; + } + + private normaliseMessage(message: Message): Message { + if (message.content === DELETED_MESSAGE_CONTENT) { + return { ...message, reactions: [] }; + } + + return message; + } +} diff --git a/toju-app/src/app/infrastructure/persistence/database-backend.rules.spec.ts b/toju-app/src/app/infrastructure/persistence/database-backend.rules.spec.ts new file mode 100644 index 0000000..1dfcfc0 --- /dev/null +++ b/toju-app/src/app/infrastructure/persistence/database-backend.rules.spec.ts @@ -0,0 +1,25 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { resolveDatabaseBackend } from './database-backend.rules'; + +describe('database-backend.rules', () => { + it('routes Electron to the IPC SQLite backend', () => { + expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: false })).toBe('electron'); + }); + + it('routes Capacitor native shells to SQLite instead of IndexedDB', () => { + expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: true })).toBe('capacitor-sqlite'); + }); + + it('routes plain browser shells to IndexedDB', () => { + expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: false })).toBe('browser'); + }); + + it('prefers Electron when both Electron and Capacitor flags are set', () => { + expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: true })).toBe('electron'); + }); +}); diff --git a/toju-app/src/app/infrastructure/persistence/database-backend.rules.ts b/toju-app/src/app/infrastructure/persistence/database-backend.rules.ts new file mode 100644 index 0000000..e6901fe --- /dev/null +++ b/toju-app/src/app/infrastructure/persistence/database-backend.rules.ts @@ -0,0 +1,17 @@ +export type DatabaseBackendKind = 'electron' | 'capacitor-sqlite' | 'browser'; + +/** Selects the persistence backend for the current runtime shell. */ +export function resolveDatabaseBackend(input: { + isElectron: boolean; + isCapacitor: boolean; +}): DatabaseBackendKind { + if (input.isElectron) { + return 'electron'; + } + + if (input.isCapacitor) { + return 'capacitor-sqlite'; + } + + return 'browser'; +} diff --git a/toju-app/src/app/infrastructure/persistence/database.service.spec.ts b/toju-app/src/app/infrastructure/persistence/database.service.spec.ts index 4fc1ba6..6bd41e1 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.spec.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.spec.ts @@ -12,6 +12,7 @@ import { import { PlatformService } from '../../core/platform'; import { BrowserDatabaseService } from './browser-database.service'; +import { CapacitorDatabaseService } from './capacitor-database.service'; import { DatabaseService } from './database.service'; import { ElectronDatabaseService } from './electron-database.service'; @@ -20,6 +21,10 @@ describe('DatabaseService', () => { getBansForRoom: ReturnType; initialize: ReturnType; }; + let capacitorDatabase: { + getBansForRoom: ReturnType; + initialize: ReturnType; + }; let electronDatabase: { getBansForRoom: ReturnType; initialize: ReturnType; @@ -30,18 +35,23 @@ describe('DatabaseService', () => { getBansForRoom: vi.fn(() => Promise.resolve([])), initialize: vi.fn(() => Promise.resolve()) }; + capacitorDatabase = { + getBansForRoom: vi.fn(() => Promise.resolve([])), + initialize: vi.fn(() => Promise.resolve()) + }; electronDatabase = { getBansForRoom: vi.fn(() => Promise.resolve([])), initialize: vi.fn(() => Promise.resolve()) }; }); - function createService(): DatabaseService { + function createService(platform: Pick): DatabaseService { const injector = Injector.create({ providers: [ DatabaseService, - { provide: PlatformService, useValue: { isBrowser: true, isElectron: false } }, + { provide: PlatformService, useValue: platform }, { provide: BrowserDatabaseService, useValue: browserDatabase }, + { provide: CapacitorDatabaseService, useValue: capacitorDatabase }, { provide: ElectronDatabaseService, useValue: electronDatabase } ] }); @@ -49,13 +59,35 @@ describe('DatabaseService', () => { return runInInjectionContext(injector, () => injector.get(DatabaseService)); } - it('initializes the selected backend before the first delegated read', async () => { - const service = createService(); + it('initializes the browser backend before the first delegated read', async () => { + const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false }); await service.getBansForRoom('room-1'); expect(browserDatabase.initialize).toHaveBeenCalledTimes(1); expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1'); + expect(capacitorDatabase.initialize).not.toHaveBeenCalled(); expect(service.isReady()).toBe(true); }); -}); \ No newline at end of file + + it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => { + const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true }); + + await service.getBansForRoom('room-1'); + + expect(capacitorDatabase.initialize).toHaveBeenCalledTimes(1); + expect(capacitorDatabase.getBansForRoom).toHaveBeenCalledWith('room-1'); + expect(browserDatabase.initialize).not.toHaveBeenCalled(); + }); + + it('routes Electron shells to the IPC SQLite backend', async () => { + const service = createService({ isBrowser: false, isElectron: true, isCapacitor: false }); + + await service.getBansForRoom('room-1'); + + expect(electronDatabase.initialize).toHaveBeenCalledTimes(1); + expect(electronDatabase.getBansForRoom).toHaveBeenCalledWith('room-1'); + expect(browserDatabase.initialize).not.toHaveBeenCalled(); + expect(capacitorDatabase.initialize).not.toHaveBeenCalled(); + }); +}); diff --git a/toju-app/src/app/infrastructure/persistence/database.service.ts b/toju-app/src/app/infrastructure/persistence/database.service.ts index 9b9a77e..7978f80 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.ts @@ -14,6 +14,8 @@ import { import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel'; import { PlatformService } from '../../core/platform'; import { BrowserDatabaseService } from './browser-database.service'; +import { CapacitorDatabaseService } from './capacitor-database.service'; +import { resolveDatabaseBackend } from './database-backend.rules'; import { ElectronDatabaseService } from './electron-database.service'; export interface RoomMessageStats { @@ -26,6 +28,7 @@ export interface RoomMessageStats { * storage backend based on the runtime platform. * * - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process). + * - Capacitor -> native SQLite via {@link CapacitorDatabaseService}. * - Browser -> IndexedDB via {@link BrowserDatabaseService}. * * All consumers inject `DatabaseService`; the underlying storage engine @@ -35,6 +38,7 @@ export interface RoomMessageStats { export class DatabaseService { private readonly platform = inject(PlatformService); private readonly browserDb = inject(BrowserDatabaseService); + private readonly capacitorDb = inject(CapacitorDatabaseService); private readonly electronDb = inject(ElectronDatabaseService); private initializationPromise: Promise | null = null; @@ -43,7 +47,20 @@ export class DatabaseService { /** The active storage backend for the current platform. */ private get backend() { - return this.platform.isBrowser ? this.browserDb : this.electronDb; + const backendKind = resolveDatabaseBackend({ + isElectron: this.platform.isElectron, + isCapacitor: this.platform.isCapacitor + }); + + if (backendKind === 'electron') { + return this.electronDb; + } + + if (backendKind === 'capacitor-sqlite') { + return this.capacitorDb; + } + + return this.browserDb; } /** Initialise the platform-specific database. */ diff --git a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html index 49396b2..3f2126c 100644 --- a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html +++ b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html @@ -36,7 +36,7 @@ -
+
diff --git a/toju-app/src/index.html b/toju-app/src/index.html index dc54d25..46f8acc 100644 --- a/toju-app/src/index.html +++ b/toju-app/src/index.html @@ -1,12 +1,15 @@ - + MeToYou , ) globally. registerSwiperElements(); +// Ensure Capacitor SystemBars injection has a document root with safe-area defaults. +applyMobileSafeAreaDefaults(); + // Expose mermaid globally for ngx-remark's MermaidComponent window.mermaid = mermaid; mermaid.initialize({ diff --git a/toju-app/src/styles.scss b/toju-app/src/styles.scss index 1fb77b2..c49134b 100644 --- a/toju-app/src/styles.scss +++ b/toju-app/src/styles.scss @@ -124,12 +124,34 @@ @apply border-border; } + html { + box-sizing: border-box; + height: 100%; + padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); + padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); + padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); + padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + body { @apply bg-background text-foreground; + height: 100%; + margin: 0; font-feature-settings: 'rlig' 1, 'calt' 1; } + + app-root { + display: block; + height: 100%; + } } /* Scrollbar styling */ diff --git a/tools/build-android-apk.sh b/tools/build-android-apk.sh new file mode 100755 index 0000000..0d084b5 --- /dev/null +++ b/tools/build-android-apk.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Build a debug Android APK for the Capacitor shell. +# +# Prerequisites (CI installs these automatically): +# - Node.js 22 + root npm dependencies (`npm ci`) +# - JDK 21 (JAVA_HOME) +# - Android SDK with platform 36 + build-tools (ANDROID_SDK_ROOT / ANDROID_HOME) +# +# Output: +# toju-app/android/app/build/outputs/apk/debug/app-debug.apk + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +npm run bundle:rnnoise +npm run build:prod + +cd toju-app +npx cap sync android + +cd android +chmod +x gradlew +./gradlew assembleDebug --no-daemon --stacktrace diff --git a/tools/cap-open-android.js b/tools/cap-open-android.js new file mode 100644 index 0000000..c493160 --- /dev/null +++ b/tools/cap-open-android.js @@ -0,0 +1,45 @@ +'use strict'; + +const { spawn } = require('child_process'); +const path = require('path'); + +const { resolveAndroidStudioPath } = require('./resolve-android-studio-path'); + +function main() { + const studioPath = resolveAndroidStudioPath(); + + if (!studioPath) { + console.error( + '[error] Unable to locate Android Studio (studio.sh).\n' + + ' Install Android Studio or set CAPACITOR_ANDROID_STUDIO_PATH to studio.sh.' + ); + process.exit(1); + } + + const tojuAppDir = path.resolve(__dirname, '..', 'toju-app'); + const child = spawn('npx', ['cap', 'open', 'android'], { + cwd: tojuAppDir, + env: { + ...process.env, + CAPACITOR_ANDROID_STUDIO_PATH: studioPath + }, + stdio: 'inherit', + shell: process.platform === 'win32' + }); + + child.on('error', (error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); + }); +} + +main(); diff --git a/tools/resolve-android-studio-path.js b/tools/resolve-android-studio-path.js new file mode 100644 index 0000000..cc43b09 --- /dev/null +++ b/tools/resolve-android-studio-path.js @@ -0,0 +1,99 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * @param {string} filePath + * @returns {boolean} + */ +function isExecutableStudioSh(filePath) { + try { + const stat = fs.statSync(filePath); + + return stat.isFile() && (stat.mode & 0o111) !== 0; + } catch { + return false; + } +} + +/** + * @param {string} homeDir + * @returns {string | null} + */ +function findJetBrainsToolboxStudioSh(homeDir) { + const toolboxRoot = path.join( + homeDir, + '.local/share/JetBrains/Toolbox/apps/AndroidStudio' + ); + + let entries; + + try { + entries = fs.readdirSync(toolboxRoot, { withFileTypes: true }); + } catch { + return null; + } + + const studioPaths = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(toolboxRoot, entry.name, 'bin/studio.sh')) + .filter((candidate) => isExecutableStudioSh(candidate)) + .sort(); + + return studioPaths.at(-1) ?? null; +} + +/** + * @param {{ env?: NodeJS.ProcessEnv; homeDir?: string }} [options] + * @returns {string | null} + */ +function resolveAndroidStudioPath(options = {}) { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const fromEnv = String(env.CAPACITOR_ANDROID_STUDIO_PATH ?? '').trim(); + + if (fromEnv && isExecutableStudioSh(fromEnv)) { + return fromEnv; + } + + const candidates = [ + '/usr/local/android-studio/bin/studio.sh', + '/opt/android-studio/bin/studio.sh', + path.join(homeDir, 'android-studio/bin/studio.sh'), + '/var/lib/flatpak/app/com.google.AndroidStudio/x86_64/stable/active/files/extra/bin/studio.sh', + path.join( + homeDir, + '.local/share/flatpak/app/com.google.AndroidStudio/x86_64/stable/active/files/extra/bin/studio.sh' + ), + '/snap/android-studio/current/bin/studio.sh' + ]; + + for (const candidate of candidates) { + if (isExecutableStudioSh(candidate)) { + return candidate; + } + } + + return findJetBrainsToolboxStudioSh(homeDir); +} + +module.exports = { + resolveAndroidStudioPath, + isExecutableStudioSh +}; + +if (require.main === module) { + const resolved = resolveAndroidStudioPath(); + + if (!resolved) { + console.error( + 'Could not find Android Studio (studio.sh). Install Android Studio or set CAPACITOR_ANDROID_STUDIO_PATH.' + ); + process.exit(1); + } + + process.stdout.write(`${resolved}\n`); +} diff --git a/tools/resolve-android-studio-path.test.js b/tools/resolve-android-studio-path.test.js new file mode 100644 index 0000000..e1f008f --- /dev/null +++ b/tools/resolve-android-studio-path.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveAndroidStudioPath } = require('./resolve-android-studio-path'); + +test('resolveAndroidStudioPath prefers CAPACITOR_ANDROID_STUDIO_PATH when executable', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-sh-')); + const studioSh = path.join(tempDir, 'studio.sh'); + + fs.writeFileSync(studioSh, '#!/bin/sh\n', { mode: 0o755 }); + + const resolved = resolveAndroidStudioPath({ + env: { CAPACITOR_ANDROID_STUDIO_PATH: studioSh }, + homeDir: tempDir + }); + + assert.equal(resolved, studioSh); + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test('resolveAndroidStudioPath finds flatpak active studio.sh when present', () => { + const flatpakPath = + '/var/lib/flatpak/app/com.google.AndroidStudio/x86_64/stable/active/files/extra/bin/studio.sh'; + + if (!fs.existsSync(flatpakPath)) { + return; + } + + const resolved = resolveAndroidStudioPath({ + env: {}, + homeDir: '/nonexistent-home-for-test' + }); + + assert.equal(resolved, flatpakPath); +});