Files
openchamber/packages/mobile/HANDOFF.md
T
Bohdan Triapitsyn 61a4a23add feat: native iOS & Android mobile apps (Capacitor) (#1954)
* feat(mobile): add Capacitor native shell

* docs: add serve-sim workflow guidance

* docs(mobile): add implementation handoff

* chore(mobile): clean up generated defaults

* feat(mobile): add connection onboarding

* feat(mobile): manage saved instances

* feat(mobile): refine connection management UI

* chore(mobile): upgrade Capacitor 8

* fix(mobile): reliable saved-instance auth with secure token storage

- store client tokens in the OS secure store (iOS Keychain / Android Keystore)
  per instance URL via direct native plugin calls; keep only token-less metadata
  in localStorage. Bound every secure call so a stalled bridge can't hang unlock.
- bypass the secure-storage JS wrapper's lazy platform load (which stalled in the
  webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly.
- harden the shared connect/unlock controller (health + session + progressive
  password) and drop the heavy pre-connect hydration that stalled no-token hosts.
- await token persistence before switching runtime endpoints (no fire-and-forget).
- sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8.

* fix(mobile): keep UI stable across connection churn (no transport hardcoding)

The "reload every ~10s" was a UX bug, not a transport one:
- MobileSurfaceShell received a fresh inline onClose each parent render, so any
  re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first
  element — stealing focus from the active input and collapsing the keyboard
  mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on
  `open`. Fixes all sheets (Instances/Files/Changes/Settings).
- Gate the mobile shell on connectionPhase, not the live isConnected flag, so a
  transient reconnect keeps MobileShell mounted instead of flashing the loader.
- Instances form: populate fields imperatively on edit/cancel/save instead of via
  an effect keyed on the derived connection, so list churn can't wipe input.

Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so
WS-only Quick Tunnels and SSE-capable proxies both keep working.

* feat(mobile): add native QR pairing-code scanner

Wire the connection onboarding + Instances scan buttons to a real native
scanner via @capacitor-mlkit/barcode-scanning, which registers as the
BarcodeScanner plugin the existing mobileQrScan helper already resolves at
runtime. Add NSCameraUsageDescription and bump the iOS deployment target to
15.5 (GoogleMLKit 8 requirement).

* fix(cli): repair connect-url host resolution

Define the missing isWildcardBindHost helper that connect-url called but was
never declared, which crashed any link generation that reached host
resolution. Also treat a full http(s) --host value as a public server URL so
'--host https://example.com' produces a correct link instead of
'http://https://example.com:port'.

* fix(mobile): make input follow the keyboard across all surfaces

Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the
layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which
fires at the start of the iOS keyboard animation. A transition tuned to the
native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes
the layout rise together with the keyboard instead of snapping into place after
the built-in 'native' resize finished (~1.5s lag).

The inset is consumed by every surface that can hold a focused input:
- chat shell shrinks its height;
- portal sheets/overlays raise their bottom edge;
- the full-screen connect/login view caps its height so it actually scrolls
  (and is now generally scrollable for long saved-connection lists).

* feat(mobile): rounder chat composer + native bottom safe area

Round the mobile chat composer corners a touch more (1rem), and reserve a small
app-level bottom safe area for the native shell via the --oc-app-bottom-safe
token so controls clear the phone's rounded hardware corners. The reservation
folds into the keyboard inset (no gap above the keyboard), and the composer's
own bottom padding tightens while the keyboard is open.

* fix(mobile): remove iOS 26 dark status-bar band; polish composer

The dark band behind the status bar in system Dark Mode was iOS 26's automatic
scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the
status bar — appearance-coloured, so it tracked the system theme regardless of
the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the
WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed
web background shows under the overlaid status bar.

Also: re-assert the status-bar overlay on resume, paint the document canvas with
the theme background in the native shell, round the composer corners to 1.5rem,
and enlarge the app-level bottom safe area so controls clear the rounded corners.

* feat(mobile): logo splash until first paint is final (no FOUT / layout shift)

Cold start flashed the fallback font and then reflowed once the real font and
persisted appearance prefs landed, and text jumped a frame after mount because the
mobile typography classes were applied from a hook effect. Fix it on three fronts:

- apply device classes (device-mobile / mobile-pointer) synchronously in
  renderMobileApp before the first React paint, so mobile --text-* sizes are in
  effect from the start;
- hold a logo splash (useFontsReady) until the UI web font has loaded;
- gate that splash on appBootReady too, resolved once async appearance/typography
  preferences are applied, plus a double rAF so styles commit before reveal.

All under a 2.5s safety timeout so a slow/offline CDN can't block startup.

* feat(mobile): native local notifications; APNs implemented but frozen

The native app now delivers agent ready/error/question/permission events as iOS
(and Android) Local Notifications: a native notifications API backed by
@capacitor/local-notifications replaces the Web Notifications API (which doesn't
display in a WKWebView), driven by the notification SSE stream now subscribed in
the mobile app. Tapping a notification opens its session. Also fix the settings
toggle, which treated the Capacitor app as a browser and gated 'Enable
Notifications' on the absent Web Notification permission, leaving it un-toggleable.

Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT
server runtime, token routes, client registration, iOS native config) but kept
dormant: config-gated so it never fires, client registration not wired, and the
aps-environment entitlement / background mode removed so the app builds with no
Apple push setup. It will be reused once OpenChamber ships its own encrypted
relay so users don't each configure APNs. See notifications/APNS.md.

WKWebView can't use web push (unlike an installed PWA), so true
background-when-suspended delivery on native requires APNs via that relay.

* feat(mobile): APNs relay-mode background push

Deliver native iOS background push through the central relay: the server posts
device tokens + generic, model-based text to api.openchamber.dev/v1/push/send
(default), which holds the single APNs key and signs+sends; dead tokens (410)
are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays
as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload
is generic only (model + scenario) so no session content crosses the relay.

Re-enable the client token registration (useNativePushRegistration) and the
aps-environment entitlement (alert pushes need no background mode). Wired into
the same fanout as web push; focus-suppressed and only when tokens exist.

* fix(mobile): APNs-only native notifications, generic templates, no foreground

Make APNs the single notification channel for the native app and fix delivery:

- Remove local notifications entirely (the @capacitor/local-notifications plugin
  and the SSE-driven path). A WKWebView can't tell foreground from background
  (document.hasFocus() is unreliable), so local notifications leaked while the app
  was open; the in-app dispatch is no-op'd on native.
- Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden'
  before iOS suspends it, which dropped background push. Instead always send and let
  iOS suppress the foreground banner (PushNotifications presentationOptions: []).
- Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger
  before any push was sent.
- Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs
  your input' / 'needs permission' / 'hit an error') + the session name, no model or
  message content.
- Hide the focus toggle, templates, and test button in mobile notification settings.

* feat(push): sign relay requests + bind tokens per server

Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings,
like the VAPID keys) and uses it to:
- bind every newly-seen device token to the server on the relay
  (POST /v1/push/register-token, signed), and
- sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title).

The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and
only delivers to tokens bound to that server. Result: a leaked device token alone can no
longer be used to push to a device — the sender also needs the server's private key. Stays
zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer.

* docs(push): describe relay data-confidentiality model

Document that the push payload is not application-encrypted (TLS-in-transit only), what the
relay and Apple can see (generic scenario title + session name, plus token/sessionId), that
the signature is authentication rather than encryption, and what an end-to-end encrypted
payload would require.

* fix: invalid skill description

* feat(push): app-icon badge for native notifications

Send an absolute aps.badge with each native push = the count of distinct
collapse-ids (tag) pushed since the app was last foregrounded, mirroring the
lock-screen banner stack. Cleared server-side on user engagement (session view,
message-sent, visibility beacon) and on-device via sceneDidBecomeActive.

* feat(mobile): auto-connect last instance on launch + notification deep-links

Cold launch silently reconnects to the most-recent saved instance (when reachable
and a token is saved), holding the splash instead of flashing the connect screen;
falls back to the connect screen when there's no saved instance, it's unreachable,
or it needs a re-login. Notification-tap deep-links are now captured unconditionally
(even before connect / on cold launch) and applied once the app is ready, so a tap
opens the target session instead of being lost on the login screen.

* fix(mobile): resolve theme background before first paint on cold launch

The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold
launch flashed the WebView's default light canvas, then the baked
design-system default (.dark { --background: #151313 }) via body.bg-background,
before React's theme system injected the real theme vars. Add a blocking script
that resolves dark/light from the persisted theme + system preference and sets
--background (plus color-scheme and the element background) inline on the root,
so the very first paint matches the resolved theme. Falls back to the default
flexoki backgrounds when no theme has been persisted yet.

* feat(mobile): openchamber:// deep-link foundation + arm64 simulator build

Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent)
and a single native navigation layer (deepLinkNavigation.ts) that handles both
the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities,
external links) and notification taps, normalising each into an intent. Session
and new-session resolve against the store; shell surfaces (sessions/settings/
views/changes) register handlers. Cold-launch intents stash until the app is
ready. Replaces the push-only useNativePushDeepLink and keeps backwards
compatibility with bare sessionId payloads.

Register the openchamber:// scheme in Info.plist.

Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding
Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new
ios-sim-build script that temporarily drops the MLKit barcode-scanning pod
(no arm64-simulator slice) so the app builds an arm64 binary installable on
Apple Silicon simulators, then restores the Podfile + Pods for device builds.
QR scanning already degrades cleanly when the native plugin is absent.

* feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh

Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension
(OpenChamberNotificationService), wired into the Xcode project, sharing an App
Group with the app.

Widgets:
- Overview (medium): recent sessions with read/unread dots + four quick actions
  (new, status, instances, settings).
- Sessions (large): session list with per-session project label, attention count
  and a new-session button in the header.
- Quick Actions (small): New chat pill + status/instances.
- Lock Screen (accessoryCircular x2): brand logo to new session, attention counter.
- Control Center control: brand logo (custom SF Symbol) to new session.

Data: the app writes a session-overview snapshot (attention count + recent
sessions with project labels) to the App Group on scene activate/resign; the NSE
refreshes it from each push (aps.badge + sessionId) so widgets update even when
the app is closed (needs aps mutable-content, added to the server + relay).

Deep links: add openchamber://status (session status panel) and reuse
view/instances; all widget taps route through the existing deep-link channel.

* feat(mobile): large Sessions widget lists 6 sessions with project labels

* feat(mobile): edge-swipe to switch sessions with directional slide+fade

* fix(mobile): keep widgets in sync via reload-on-change + periodic refresh

Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the
large Sessions widget stale (no unread dot / attention count) while medium updated.
Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when
the session overview actually changed (so we don't burn the budget on every scene
activate/resign), and give each widget a periodic timeline refresh so a missed
reload self-corrects.

* feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan

Cosmetics:
- Status bar: on Android inset the WebView below the bar (overlay:false) and
  paint it with the resolved theme background + correct content Style, since
  Android doesn't feed env(safe-area-inset-top) to CSS.
- Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes
  natively, so applying it double-counted and floated the composer); declare
  windowSoftInputMode=adjustResize and disable the shell height transition on
  Android so the header no longer bounces on keyboard open.

Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable
on Android (events only arrive once a run finishes). Forced in sync-context and
the other options are disabled in the Chat settings UI.

Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications
register() needs Firebase/FCM (not configured) and crashes at launch.

QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and
install/await the Google barcode scanner module (with a post-install retry) before
scanning so the first scan works without a manual retry.

Icon: Android adaptive launcher icon generated from the cube logo (full-bleed
white background, no edge artifact on One UI). Source assets under mobile/assets.

Tooling: adb-based android-device.mjs + android:* scripts for device deploy.

* feat(notifications): presence-aware push routing (don't spam the phone)

Only push to a device when the notification would otherwise be missed there. A
notification is suppressed on devices where the user is already present.

- Tag every client's visibility beacon and web-push subscription with a platform
  ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform().
- Server tracks visibility per client (keyed by oc_ui_session) with the platform,
  and exposes isAnyInteractiveClientVisible() = any visible non-mobile client.
- Native push (APNs) and mobile PWA web-push are now suppressed when an
  interactive (desktop/web/vscode) client is visible — it already shows the
  in-app notification. Gated on the desktop's visibility (reliable), never the
  phone's own (a backgrounded WKWebView can't report "hidden").
- Desktop/web web-push keeps the any-visible gate (a visible client absorbs it).
- Skipping APNs also skips the badge increment so it doesn't drift.

Fixes the case where every session on a shared instance pushed to the phone even
while the user was actively working on desktop.

* feat(mobile): Android FCM push notifications

Enable native background push on Android via Firebase Cloud Messaging, in parallel
with the existing iOS APNs path.

- Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google
  Services Gradle plugin is applied when the file is present, so register() returns
  an FCM token instead of crashing.
- Un-gate native push registration to iOS OR Android, and tag the registered token
  with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM.
- Server stores the platform per device token and binds it to the relay (platform
  included in the signed register message).
- Notification small icon: monochrome cube silhouette with a mark on the top face,
  set as the FCM default_notification_icon so the status-bar icon reads as the logo.

Relay-side FCM sending ships in openchamber-website.

* docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap

* chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag)

- Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App
  Group snapshot shared with the widget + notification service extension) and wire it into the
  App target's resources — Apple requires an app-level privacy manifest.
- Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt.
- HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items).

Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy
bundled into App.app.

* refactor(mobile): dedupe capacitor detection + make beacon guard explicit

Addresses non-blocking PR review notes:
- Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation,
  usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform.
- usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on
  isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes.
2026-07-01 09:55:41 +03:00

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's Google code scanner module is downloaded on first scan (needs Play Services + network); mobileQrScan.ts installs/awaits it and retries. 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. ML Kit com.google.mlkit.vision.DEPENDENCIES=barcode_ui meta (preloads the code scanner). 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).