Skip to content

feat/refactor-ab-connector - #25

Merged
xameYeoh merged 3 commits into
mainfrom
feat/refactor-ab-connector
Oct 20, 2026
Merged

xameYeoh merged 3 commits into
mainfrom
feat/refactor-ab-connector

Conversation

@xameYeoh

@xameYeoh xameYeoh commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

AirBeamSessionController is gone. In its place is AirBeamLink, a connection-only owner with suspend calls, and a connector seam that finally delivers measurements, so the RecordingEngine has a single stream to save. All of it is on feat/curr in three commits, and both Android and iOS build.

Commit What
b76101a SensorReading, V1 ASCII + Mini V2 binary parsers, credentials kept out of SessionConfig.toString()
2d60399 AirBeamLink replaces the controller; connector seam reworked (BLE, classic, composite); foreground service follows LinkState
b0d8d95 /docs/: legacy reconnect rules and live-reading formats recorded

The developer did not hand-copy this code: they asked me to do the work myself. The CLAUDE.md teaching-mode rule was set aside for this task, and the code should get a read-through review.

What was wrong

The nudge was right. The old controller rebuilt the legacy app's EventBus and listener shape on top of Flow, and the connector had real bugs underneath it.

Area Problem Effect
Controller API dispatch(SessionAction): fire-and-forget, wrong-state actions silently ignored Same contract as EventBus.post; callers couldn't learn the outcome
Controller state 7 mutable fields written from caller thread and Dispatchers.Default jobs Races: a late Connected could overwrite Idle after disconnect
Reconnect Invented 5-attempt cap with linear backoff; fired from Connected too; re-ran configure() Not legacy behavior. Fixed sessions resent Wi-Fi credentials; Mini V2 would reject a second session config
Reconnect startTimeMs reset on each reconnect Session start time lost on every drop
Errors Every ConfigResult became HandshakeFailed UI could never say "wrong Wi-Fi password"
Logging Data-class toString() of configs logged Wi-Fi password and auth token in Logcat
Layering SessionState.Recording without a session or measurements; live AirBeamConnection inside UI state A second state machine beside SessionStatus; resource handle leaking to UI
Connector No measurement stream at all; V2 notifications subscribed and discarded Nothing for a RecordingEngine to save
Connector Own CoroutineScopes never tied to the link; Kable Peripheral.close() never called; failed attempts not released Leaked scopes and GATT clients per reconnect
Connector No MTU request on Android V1 ASCII lines (~100 B) don't fit the default 20-byte payload
Connector V2 reply awaited only after the write A fast Ready/Nack could be missed
Classic withTimeout around blocking socket.connect() The 30 s timeout never fired
Composite error() for an unsupported transport AirBeam2 target on iOS would crash
Foreground service START_STICKY with no state restore; never started A zombie notification after process death

New design

AirBeamLink owns the one AirBeam connection for the life of the process, and it does connection work only. Sessions, their status and their data stay with the recording side.

[embedded content: AirBeam layers after the refactor]

Arrows show who calls whom. Readings travel up the same path, and AirBeamLink.readings keeps flowing across reconnects.

  • Suspend calls with outcomes. connect() returns Connected or Failed(reason). configure() returns the device's own ConfigResult, adding NotConnected when there is no ready link. disconnect() ends the link and any reconnect loop.
  • One job per link. Each link runs as a single job in an injected process scope. connect and disconnect are serialized by a Mutex, and each cancels and joins the previous job. Because the job lives in the link's scope, a screen that leaves doesn't cancel its connect.
  • No handles in state. LinkState carries the target and the failure reason, nothing else, so it is safe to expose to UI.
  • Explicit connector seam. connect returns ConnectResult instead of a fake connection that has already failed. ConnectionStatus covers one live link's life: Ready, Disconnected or DisconnectedUnexpectedly.
  • Readings. AirBeamConnection.readings: Flow<SensorReading> is buffered from connect on, so nothing is lost before the engine starts collecting. For Mini V2 it carries the sync backlog too, which gives the engine one save path.
  • BLE lifetimes. Per-link work runs in the scope Kable's connect() returns. The drop watcher runs in peripheral.scope, and teardown calls close().

Business rules taken from legacy

Every rule below comes from the legacy app, not from invention. They are now recorded in docs/DOMAIN_KNOWLEDGE.md §2 step 6 and §3.

Rule Now Legacy source
Reconnect only for a mobile session, every 2 s, no cap; stops on user disconnect AirBeamLink.reconnect AirBeamService.onMessageEvent, AirBeamReconnector
On reconnect, resume — don't reconfigure resumeMobileSession reconnectMobileSession()
Mini V2 resume: time sync, then ContinueSession if HasSavedSession, nothing if Running BleConnection.resumeMobileSession AirBeamMiniV2Configurator.reconnectMobileSession
V1 reading = 12-field ASCII line; battery text dropped AirBeamProtocol.Standard.parseReading ResponseParser, SyncableAirBeamReader
Measurement characteristics: AB3 ffe1 ffe3 ffe4 ffe5 ffe6, Mini V1 ffe4 ffe5 readingSources AirBeam3Configurator, AirBeamMiniConfigurator
V2 stream names AirBeamMini-PM1 / AirBeamMini-PM2.5, µg/m³, device time AirBeamProtocol.MiniV2.parseMeasurement postLiveMeasurementEvents
MTU 517 for V1, 247 for V2 (Android) requestMtuIfSupported SyncableAirBeamConfigurator.MAX_MTU, V2 configurator

One deliberate split: "stop" and "standalone mode" are both link.disconnect(). Whether the session becomes FINISHED or stays DISCONNECTED is the RecordingEngine's call.

Verification

The whole shared suite passes, the Android app assembles, and the iOS simulator target compiles. None of this has been run on a real AirBeam yet.

Check Result
ReadingParserTest (new) 10 / 10
SessionConfigTest (new, credential redaction) 3 / 3
AirBeamLinkTest (new, replaces the controller test) 18 / 18
ScanViewModelTest, CompositeAirBeamConnectorTest (ported) 4 / 4, 5 / 5
Rest of :shared:testAndroidHostTest green
:androidApp:assembleDebug, :shared:compileKotlinIosSimulatorArm64 build OK
Mutation check: reconfigure instead of resume, and arm reconnect for fixed sessions 3 link tests fail, as they should

The link tests run on virtual time in backgroundScope, so the 2 s interval and the no-cap rule (51 attempts) are checked deterministically.

The BLE and classic connectors have no unit tests. They wrap Kable and Android sockets, so they need hardware to test.

…Config.toString

Adds SensorReading and pure parsers for every live-measurement format:
the V1 ASCII line (AB2/AB3/Mini V1, ported from legacy ResponseParser)
and the Mini V2 binary live + sync frames (names/unit match legacy V2).

SessionConfig data classes printed the Wi-Fi password and auth token
through toString(), and the session controller logs configs.
The controller was the legacy shape in Flow clothing: fire-and-forget
dispatch(action) like EventBus.post, seven mutable fields written from
several threads, and a hand-rolled reconnect loop with rules nobody
had written down. AirBeamLink is the connection only:

- suspend connect/configure/disconnect that return their outcome;
  configure hands back the device's ConfigResult instead of flattening
  it to HandshakeFailed
- one job per link, launched in a process scope, so the connection
  outlives the screen that started it; connect/disconnect serialized
- reconnect per legacy AirBeamReconnector: mobile sessions only, every
  2 s, no cap, resume (not reconfigure) on success. Fixed sessions no
  longer resend Wi-Fi credentials on a drop
- LinkState holds no connection handle and no session data
- readings: Flow<SensorReading> that carries on across reconnects

Connector seam:
- connect returns ConnectResult instead of a fake already-failed
  connection; ConnectionStatus is the life of a live link only
- AirBeamConnection gains device, readings, resumeMobileSession
- BLE: per-link work runs in the scope Kable's connect() returns,
  Peripheral is close()d, MTU requested on Android (517 V1 / 247 V2),
  V2 replies are awaited before the write so none is missed, observe
  failures are logged instead of escaping
- Classic: lines parsed into readings; the 30 s timeout now actually
  aborts the blocking socket connect
- Composite: an unsupported transport is a Failed result, not a crash

AirBeamForegroundService follows LinkState and is START_NOT_STICKY:
a restarted process has nothing to restore the link from.
@xameYeoh
xameYeoh merged commit 2cdaccc into main Oct 20, 2026
2 checks passed
@xameYeoh
xameYeoh deleted the feat/refactor-ab-connector branch October 20, 2026 12:45
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