Files
openchamber/packages/mobile/HANDOFF.md
T

12 KiB

OpenChamber Mobile Handoff

Status and process reference for the native iOS/Android apps. Written so work can continue after merge — either by finishing CI/release automation, or by adding features as follow-up fixes. The apps are feature-complete for a first public/TestFlight-style test; CI/signing is the main gap.

What this package is

packages/mobile is a Capacitor workspace that wraps the hosted mobile web UI (the MobileApp renderer), not the desktop shell. The native app is a WKWebView (iOS) / Android WebView loading a bundled copy of the web build; native capabilities are added via Capacitor plugins and two iOS app extensions.

  • App id / package: com.openchamber.app; app name OpenChamber.
  • Capacitor config: capacitor.config.ts (Keyboard resize: 'none', StatusBar overlay, Push presentationOptions: []).
  • Renderer: the web build's mobile.html entry (MobileApp), copied into dist/ and served by Capacitor. Mobile-only surfaces (connection onboarding, Instances, QR pairing, widgets) exist only in the Capacitor shell — hosted mobile.html in a plain browser does not expose them.

Build pipeline (how a native build is produced)

bun run --cwd packages/web build          # web/dist
  → scripts/prepare-web-assets.mjs         # copy web/dist → mobile/dist, mobile.html → index.html
    → cap sync                             # copy dist → native, sync plugins/config
      → xcodebuild / gradle assembleDebug  # native binary

sync (in packages/mobile/package.json) runs bun run build && cap sync inside the mobile env wrapper. Everything native-facing goes through scripts/with-mobile-env.mjs.

with-mobile-env.mjs (toolchain wrapper — read this before debugging build env issues)

Every build/deploy script runs through it. It sets, with env overrides honored first:

  • DEVELOPER_DIR$DEVELOPER_DIRxcode-select -p/Applications/Xcode.app/.... It intentionally honors xcode-select so an Xcode beta / non-default install is used (hardcoding the path previously forced builds onto the wrong Xcode / Command Line Tools).
  • JAVA_HOME$JAVA_HOME/opt/homebrew/opt/openjdk@21.
  • ANDROID_HOME / ANDROID_SDK_ROOT$ANDROID_HOME/opt/homebrew/share/android-commandlinetools.
  • PATH — prepends $JAVA_HOME/bin and $ANDROID_HOME/platform-tools (so adb resolves).

On another machine, override these env vars rather than editing the script. xcode-select may point at Command Line Tools; the wrapper's DEVELOPER_DIR handling covers that for mobile commands.

Commands

Root aliases (from repo root):

bun run mobile:build                 # web build + prepare-web-assets
bun run mobile:sync                  # build + cap sync
bun run mobile:build:android:debug   # sync + gradle assembleDebug
bun run mobile:build:ios:simulator   # simulator build (strips MLKit pod, see quirks)
bun run mobile:open:ios              # open in Xcode
bun run mobile:open:android          # open in Android Studio
bun run type-check:mobile
bun run lint:mobile

Android physical-device deploy (adb-based, in scripts/android-device.mjs) — not aliased at root, run from the package:

bun run --cwd packages/mobile android:devices   # list adb devices (want `device`, not `unauthorized`)
bun run --cwd packages/mobile android:install    # adb install -r the debug APK
bun run --cwd packages/mobile android:launch     # am start MainActivity
bun run --cwd packages/mobile android:run         # install + launch
bun run --cwd packages/mobile android:logcat      # app logs

Typical device iteration: bun run --cwd packages/mobile build:android:debug then android:run. APK path: android/app/build/outputs/apk/debug/app-debug.apk.

iOS Simulator helpers: mobile:sim:{boot,install,launch,run,serve,list,kill} (see scripts/ios-sim.mjs; serve-sim for a browser preview of the simulator).

Native capabilities implemented

  • Connection onboarding — server URL entry, password unlock for locked servers, client-token issuance, saved connections, Instances management sheet, auto-connect to the last instance on launch. Deleting the active instance resets the runtime to the connect screen.
  • QR pairing@capacitor-mlkit/barcode-scanning. Android uses the CameraX-backed startScan() flow with the barcode model bundled in the app, so scanning works offline and without Google Play Services. iOS uses the plugin's native scanner. CAMERA permission + NSCameraUsageDescription declared.
  • Secure storage@aparajita/capacitor-secure-storage for connection tokens.
  • Deep linksopenchamber:// URL scheme; a reusable intent vocabulary (apps/deepLinks.ts) used by notification taps, widgets, and Control Center. Cold-launch intents are stashed.
  • Push notifications — iOS APNs + Android FCM (see below). Presence-aware routing suppresses a device's push when an interactive (desktop/web) client is visible.
  • iOS widgets + Control Center + Notification Service Extension — WidgetKit extension (OpenChamberWidget), a Control Center control, and an NSE (OpenChamberNotificationService) that refreshes widgets from push. All share the App Group group.com.openchamber.app.
  • Native chrome — status bar (iOS overlay + safe-area; Android inset + themed background), keyboard handling (iOS CSS inset; Android native adjustResize), edge-swipe session switch, back-button handling, app-icon badge.
  • App icons — iOS AppIcon; Android adaptive launcher icon; notification small icon (ic_stat_notify).

Push / notifications architecture

  • Registration: on launch the app registers a device token — iOS → APNs, Android → FCM — and sends it to the connected server tagged with platform (ios/android).
  • The server forwards notification-worthy events to a signed relay; the relay routes each token to APNs or FCM by its bound platform. The app itself only needs to obtain and register the token.
  • Presence-aware suppression: each client reports foreground visibility + its platform; a mobile push is skipped while an interactive (desktop/web/vscode) client is visible (it already shows the in-app notification). Gated on the desktop's visibility, never the phone's own.
  • Foreground behavior: iOS suppresses the banner via presentationOptions: []; the web/PWA service worker suppresses when a window is focused.

Platform config specifics

iOS (ios/App)

  • Extensions: OpenChamberWidget (WidgetKit, deployment 17.0) and OpenChamberNotificationService (NSE, 15.5), both hand-wired into App.xcodeproj/project.pbxproj and embedded via a copy phase.
  • App Group group.com.openchamber.app in all three targets' entitlements (app + widget + NSE).
  • Info.plist: CFBundleURLTypes scheme openchamber, NSCameraUsageDescription.
  • Push entitlement (aps-environment) required.
  • APNs mutable-content: 1 (set server/relay side) wakes the NSE to refresh widgets.

Android (android/app)

  • google-services.json (committed; Firebase project openchamber-8bf7e). The Google Services Gradle plugin is applied conditionally when the file exists; @capacitor/push-notifications brings firebase-messaging.
  • Manifest: permissions INTERNET, CAMERA (+ optional camera feature), POST_NOTIFICATIONS (Android 13+; older versions allow notifications by default). windowSoftInputMode=adjustResize. FCM default_notification_icon=@drawable/ic_stat_notify.
  • Adaptive launcher icon: full-bleed color background + ic_launcher_foreground (sources under packages/mobile/assets/, regenerable with @capacitor/assets).

Quirks / gotchas

  • iOS Simulator + MLKit: GoogleMLKit barcode has no arm64-simulator slice, so scripts/ios-sim-build.mjs temporarily strips the CapacitorMlkitBarcodeScanning pod, builds, then restores it. Device builds include it normally.
  • Android WebView version: the UI uses color-mix() (Tailwind v4 + theme) which needs Chromium 111+. An outdated Android System WebView renders translucency/selection wrong — tell testers to keep Android System WebView updated (or use a device with a current one).
  • Capacitor stream transport is locked to SSE on the native apps (native WebSocket streaming is unreliable on Android). The Chat transport setting shows SSE selected and disables the others in the Capacitor shell.
  • Android push needs the app rebuilt with google-services.json; without it register() used to crash ("Default FirebaseApp is not initialized"). Registration is gated to iOS/Android natives.

Validation

bun run type-check:mobile
bun run lint:mobile
bun run mobile:build:android:debug
bun run mobile:build:ios:simulator

Web-inherited build warnings (KaTeX font URLs, onnxruntime-web eval, chunk-size) are expected and non-fatal.

The gap: CI / release automation (next work)

The apps build and deploy locally; there is no CI/signing/publishing yet. To take them to TestFlight / Play internal testing:

iOS

  • Apple Developer account; App IDs for the app and both extensions (com.openchamber.app, .OpenChamberWidget, .OpenChamberNotificationService), each enabled for the App Group and (app) Push.
  • Signing certificate + provisioning profiles for all three targets (extensions need their own).
  • App Store Connect API key for non-interactive TestFlight upload (xcodebuild archive + notarytool/altool, or fastlane gym+pilot).
  • Runner: macOS with the same Xcode as DEVELOPER_DIR.

Android

  • Release keystore (kept as a CI secret); build a signed AAB (bundleRelease) — the debug scripts here produce an unsigned debug APK.
  • Play Console app + internal testing track; a Play service account for automated upload (fastlane supply or the Play Developer API).
  • google-services.json is committed, so FCM builds in CI without extra setup.
  • Runner: Linux with the Android SDK + openjdk@21.

Notes for CI

  • Reuse with-mobile-env.mjs's env contract (DEVELOPER_DIR, JAVA_HOME, ANDROID_HOME) — set them in the workflow instead of relying on local Homebrew paths.
  • Relay/push secrets (APNs key, FCM service account) live in the relay infrastructure, not app CI.
  • Version/build-number bumping is not automated yet.

Store review readiness

Xcode build warnings do not block review; the concrete items are store requirements, not code quality. Done in-repo vs. to-do at release time:

Done in-repo (this branch):

  • iOS app Privacy Manifest (ios/App/App/PrivacyInfo.xcprivacy) — declares no tracking and the required-reason UserDefaults API (App Group snapshot). Bundled SDKs ship their own manifests.
  • iOS ITSAppUsesNonExemptEncryption = false in Info.plist (skips the per-build export- compliance prompt).
  • iOS camera + local-network usage strings; Android SDK levels (target/compile 35, min 24) meet Play's current requirements.

To-do at release (console / infra, not code):

  • Privacy policy URL — required by both stores because the app uses camera + notifications.
  • iOS App Privacy nutrition label (App Store Connect) and Android Data Safety form — declare what's collected (device push token; the app otherwise talks only to the user's own server).
  • Production APNs for App Store / TestFlight builds: the app's aps-environment must be production in the release build, and the relay must send to production APNs (not sandbox).
  • Demo instance + credentials for reviewers — the app connects to a user's server, so review needs a reachable test instance (App Store 2.1 / Play).
  • Guideline 4.2 (minimum functionality) — WebView-wrapper apps can be scrutinized; cite the native features (push, widgets, Control Center, QR pairing) in the review notes.
  • Signing/upload as covered in the CI section above (all three iOS targets; signed Android AAB).