Skip to content

feat(mobile): add iOS support to the Expo client - #45

Open
aurascoper wants to merge 3 commits into
feat/dialect-synthesisfrom
feat/ios-client
Open

feat(mobile): add iOS support to the Expo client#45
aurascoper wants to merge 3 commits into
feat/dialect-synthesisfrom
feat/ios-client

Conversation

@aurascoper

@aurascoper aurascoper commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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-synthesis intentionally and must not be retargeted to main.
(Longer-term disposition: a separate neuralcompose-client repository.)

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; no
custom native code or entitlements. Android package, EAS Android profiles, and
runtime behavior preserved.

  • app.json: removed invalid newArchEnabled (SDK 57 schema error), added
    scheme, iOS bundle ID com.aurascoper.neuralcomposeclient, buildNumber /
    versionCode, microphone + local-network usage strings. ATS left fully
    intact
    — no NSAllowsArbitraryLoads, no exception domains.
  • Config: EXPO_PUBLIC_USE_MOCK / EXPO_PUBLIC_SERVER_URL /
    EXPO_PUBLIC_EEG_WS_URL env-driven with pure, tested resolvers; .env.example
    added. Live mode without an endpoint is a visible LIVE · NO ENDPOINT error
    state — never a silent fallback to mock.
  • Privacy hygiene: the committed Tailnet IP is scrubbed from source, README,
    reference doc, and test fixtures; a Jest scan guards committed config against
    CGNAT addresses and credential-shaped strings.
  • Overview: client data-source pill (MOCK DATA / LIVE /
    LIVE · NO ENDPOINT) — tab headers are hidden, so the previous header badge
    never actually rendered.
  • WebSocket: decodeEEGFrame validates shape/finiteness and supports batch
    frames; malformed samples are rejected before the render buffer.
  • eegBuffer: bounded push + fixed TP9/AF7/AF8/TP10 channel split extracted
    and unit-tested.
  • Deps: expo-asset added (expo-audio peer dependency; expo-doctor failure,
    dev-build crash risk). Jest 30 kept (suite green) and excluded from
    expo install --check.
  • eas.json: development + development-simulator profiles added alongside
    the untouched Android profiles.
  • Docs: docs/ios-client-audit.md (baseline audit) and docs/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)

Everything from here through the original evidence table describes the
PRE-ACCEPTANCE state of this PR (original submission 96d3871: 24 files,
+843/−63; 15 suites / 184 tests; Android and audio not yet observed). It is
retained as the historical baseline and is SUPERSEDED by the Gate 4 section
and the "ALL FIVE GATES OBSERVED" acceptance sheet below. No commit-status
checks are attached to any head of this PR — its green state is the recorded
device/simulator evidence plus the local closing suite, not remote CI.

Class Status
Jest / TypeScript 15 suites / 184 tests pass (2 suites, 3 tests skipped); tsc --noEmit clean; expo-doctor 20/20
Android JS bundle expo export --platform android green (Hermes 2.1 MB)
Android device Not run this session (prior Pixel 8a evidence in docs/fable5/)
iOS static export expo export --platform ios green (bundling + config only)
Native iOS compilation Not observed (Expo Go path; no prebuild)
iOS Simulator (Expo Go) Observed — iPhone 17 / iOS 26.5: app launched, all six tabs rendered and navigated, MOCK DATA pill visible, EEG traces in fixed channel order at ~30 fps, mic permission prompt exercised, fast refresh applied live
Physical iPhone Not observed (no signing configured)
EAS build Not run (eas whoami: not logged in); profiles configured

Gate 4 — stale/reconnect (OBSERVED, drove commit 886b25f)

Run against a local stub implementing the exact API + /api/eeg/stream WS
schema (8-sample batch frames), with .env.local live mode
(EXPO_PUBLIC_USE_MOCK=false, endpoints on 127.0.0.1:8787), iPhone 17
Simulator via Expo Go:

  • Live baseline: LIVE source pill with endpoint host; PrivacyBadge shows
    the stub's server-reported profile; batch WS frames render (proves the
    decodeEEGFrame batch path live, not just in Jest).
  • Stale (defect found at 96d3871): with the socket held open but silent,
    the EEG pill stayed green OPEN and 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.
  • Fix (886b25f): receive-time tracking + flush skip in useEEGStream;
    pure presentStream() helper (unit-tested); EEG pill now shows orange
    STALE Ns with banner "Stream silent — no samples for Ns (socket still
    open)" and an honestly aging "last sample Ns ago" footer. Re-observed live:
    pause → STALE 7s; resume → OPEN.
  • Reconnect (PASS on both commits): 2.5 s forced outage → 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 phone pill
with 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 → red CLOSED + "showing last
cached 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 after
prepareToRecordAsync(), itself only called by the button (chicken-and-egg;
recording unreachable). Fixed in 69f41f4 (gate on the mount-time permission
result 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 entry
intact, 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 --check clean.

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):

  • EAS login and optional simulator development build
  • Physical iPhone test over an HTTPS-reachable Mac endpoint
  • Final review of MOCK/LIVE source-pill placement

🤖 Generated with Claude Code

aurascoper and others added 3 commits July 28, 2026 13:09
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
aurascoper marked this pull request as ready for review July 29, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant