feat(mobile): add iOS support to the Expo client - #45
Open
aurascoper wants to merge 3 commits into
Open
Conversation
Universal Android/iOS thin client on the Expo Go path (no native code, no custom entitlements). Android package, profiles, and behavior preserved. - app.json: drop invalid newArchEnabled (SDK 57), add scheme, iOS bundle ID com.aurascoper.neuralcomposeclient, buildNumber/versionCode, mic + local-network usage strings; ATS left fully intact (no cleartext exemptions) - config: EXPO_PUBLIC_USE_MOCK / SERVER_URL / EEG_WS_URL env-driven with pure resolvers; committed Tailnet IP scrubbed; live-without-endpoint is a visible error state, never a silent fallback to mock; .env.example added - Overview: client data-source pill (MOCK DATA / LIVE / LIVE·NO ENDPOINT) — tab headers are hidden, so the old header badge never rendered - WS: decodeEEGFrame validates shape/finiteness, supports batch frames; malformed samples rejected before the render buffer - eegBuffer: bounded push + fixed-channel-order split extracted and tested - deps: expo-asset (expo-audio peer, doctor failure); jest 30 kept and excluded from expo install checks - eas.json: development + development-simulator profiles added alongside the untouched Android profiles - tests: +4 suites (config resolution, wire format, buffer bounds, committed config hygiene incl. Tailnet/secret scan); 15 suites / 184 tests green; tsc clean; expo-doctor 20/20; both platform exports green - docs: ios-client-audit.md (baseline) + ios-client.md (workflows, transport security, evidence ledger with Simulator run) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gate 4 (open-socket silence fixture) surfaced two truthfulness defects: - the EEG status pill derived health from socket state alone, so a silent stream stayed green OPEN indefinitely - the "last frame" footer aged from flush time, not receive time; because the flush interval kept re-rendering retained samples, it read "0s ago" forever Fixes: - useEEGStream tracks receive time (lastSampleAt) and skips flushes when no new samples arrived, so lastUpdate honestly stops advancing during silence - new pure presentStream() maps (socket status, sample age, threshold) to pill label/tone/banner: OPEN, STALE Ns (orange, with "socket still open" banner), OPEN · NO DATA, CONNECTING, CLOSED/ERROR - EEGScreen renders from presentStream() and an aging "last sample Ns ago" footer Verified live against the Gate 4 stub (pause → STALE 7s + orange banner + aging footer; resume → OPEN; 2.5s drop → CONNECTING → OPEN, one WS client, no rate doubling). Overview heartbeat staleness was already correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gate 3 (record->stop->persist->play) failed identically on the iOS Simulator
and a Pixel 8a: the Record button was disabled={!recorderState.canRecord},
but expo-audio's canRecord only becomes true after prepareToRecordAsync() -
which startRecording itself calls. The button that prepares the recorder was
disabled until the recorder was prepared, so recording was unreachable.
Gate the button on the mount-time permission result instead (null/granted
keep it enabled; an explicit denial disables it, preserving the Gate 2
denied-state behavior). startRecording's own prepare + error alert already
covers runtime failures visibly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aurascoper
marked this pull request as ready for review
July 29, 2026 01:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Repository topology — read first
This mobile branch is an independent Expo application history and has no
common ancestor with the Swift/macOS main branch. This PR targets
feat/dialect-synthesisintentionally and must not be retargeted tomain.(Longer-term disposition: a separate
neuralcompose-clientrepository.)What this adds
Universal Android/iOS support for the Expo thin client, on the Expo Go path
(
IOS_DEVELOPMENT_PATH=expo-go) — every native module is Expo-Go-bundled; nocustom native code or entitlements. Android package, EAS Android profiles, and
runtime behavior preserved.
newArchEnabled(SDK 57 schema error), addedscheme, iOS bundle IDcom.aurascoper.neuralcomposeclient, buildNumber /versionCode, microphone + local-network usage strings. ATS left fully
intact — no
NSAllowsArbitraryLoads, no exception domains.EXPO_PUBLIC_USE_MOCK/EXPO_PUBLIC_SERVER_URL/EXPO_PUBLIC_EEG_WS_URLenv-driven with pure, tested resolvers;.env.exampleadded. Live mode without an endpoint is a visible
LIVE · NO ENDPOINTerrorstate — never a silent fallback to mock.
reference doc, and test fixtures; a Jest scan guards committed config against
CGNAT addresses and credential-shaped strings.
MOCK DATA/LIVE/LIVE · NO ENDPOINT) — tab headers are hidden, so the previous header badgenever actually rendered.
decodeEEGFramevalidates shape/finiteness and supports batchframes; malformed samples are rejected before the render buffer.
and unit-tested.
expo-assetadded (expo-audio peer dependency; expo-doctor failure,dev-build crash risk). Jest 30 kept (suite green) and excluded from
expo install --check.development+development-simulatorprofiles added alongsidethe untouched Android profiles.
docs/ios-client-audit.md(baseline audit) anddocs/ios-client.md(workflows, transport security, evidence ledger).
Change stats (current head
69f41f4): 28 changed files, +986 / −73;closing suite 16 suites / 193 tests.
Historical pre-gate baseline (superseded — kept for the record)
tsc --noEmitclean; expo-doctor 20/20expo export --platform androidgreen (Hermes 2.1 MB)docs/fable5/)expo export --platform iosgreen (bundling + config only)MOCK DATApill visible, EEG traces in fixed channel order at ~30 fps, mic permission prompt exercised, fast refresh applied liveeas whoami: not logged in); profiles configuredGate 4 — stale/reconnect (OBSERVED, drove commit
886b25f)Run against a local stub implementing the exact API +
/api/eeg/streamWSschema (8-sample batch frames), with
.env.locallive mode(
EXPO_PUBLIC_USE_MOCK=false, endpoints on127.0.0.1:8787), iPhone 17Simulator via Expo Go:
LIVEsource pill with endpoint host; PrivacyBadge showsthe stub's server-reported profile; batch WS frames render (proves the
decodeEEGFramebatch path live, not just in Jest).96d3871): with the socket held open but silent,the EEG pill stayed green
OPENand the footer read "last frame 0s ago"indefinitely — footer age came from flush time, and the flush kept firing on
retained samples. Overview's "no heartbeat Ns" indicator was already correct,
and no silent mock substitution occurred anywhere.
886b25f): receive-time tracking + flush skip inuseEEGStream;pure
presentStream()helper (unit-tested); EEG pill now shows orangeSTALE Nswith banner "Stream silent — no samples for Ns (socket stillopen)" and an honestly aging "last sample Ns ago" footer. Re-observed live:
pause → STALE 7s; resume → OPEN.
CONNECTING(orange) →
OPEN; sample counter resumes at normal rate (no doubling);server reports exactly 1 WebSocket client after recovery (no duplicate
streams).
Acceptance sheet — ALL FIVE GATES OBSERVED (2026-07-28, head
69f41f4)Gate 1 — Android device smoke (Pixel 8a, Expo Go, USB + adb reverse):
launch + all-tab navigation; green
MOCK DATA — fixtures on this phonepillwith mock diagnostics; live mode against the reference stub showed the
endpoint identity, fixed TP9/AF7/AF8/TP10 traces at ~30 fps; pause →
STALE 5s+ silent-stream banner; drop → redCLOSED+ "showing lastcached data" → reconnect to
OPEN. PASS.Gate 2 — Microphone denied (iOS Simulator, re-observed on
69f41f4):"Microphone access denied" alert, Record visibly disabled, text path intact,
no phantom entry, no crash. PASS.
Gate 3 — Record → stop → persist → play (Pixel 8a): initially FAILED
on both platforms — the Record button was gated on
recorderState.canRecord, which only becomes true afterprepareToRecordAsync(), itself only called by the button (chicken-and-egg;recording unreachable). Fixed in
69f41f4(gate on the mount-time permissionresult instead). Re-run: recording bar + OS mic indicator → stop → "1 entry
on this device" with duration + Play → entry survives navigation away/back →
playback toggles to Stop. Audio stays in a local file URI; metadata-only in
AsyncStorage. PASS on the new head.
Gate 4 — Stale/reconnect: observed pass (previous section), re-confirmed
on Android hardware during Gate 1.
Gate 5 — Background/foreground (Pixel 8a): 30 s background → foreground:
sample counter advanced 10,520 → 19,976 (~256/s — exactly single-stream
rate, no duplicated sockets/timers), stream resumed
OPEN, journal entryintact, no crash. PASS.
Closing suite on
69f41f4: Jest 16 suites / 193 tests pass, tsc clean,expo-doctor 20/20, both platform exports green,
git diff --checkclean.Still not observed: iOS-simulator audio capture (the fixed button now
enables, but actual recording additionally needs the one-time macOS-level
microphone grant for Simulator — System Settings → Privacy & Security →
Microphone; operator-side). Real-microphone evidence is the Pixel run above.
Useful but not merge blockers for an Expo-Go-scoped PR (remain explicitly
unclaimed):
🤖 Generated with Claude Code