Skip to content

Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs - #725

Closed
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/android-registry-fake-setmodel
Closed

Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs#725
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/android-registry-fake-setmodel

Conversation

@pipiche38

Copy link
Copy Markdown

Problem

The Android Full-flavor unit test source set does not compile on main, so every com.noop.* JVM unit test is blocked (Android's unit-test CI job is red).

PR #716 added a method to the DeviceRegistryDao interface — and updated the production Room DAO + Swift twin — but not the three hand-written test doubles that implement the interface:

// DeviceRegistryDao.kt (#716)
@Query("UPDATE pairedDevice SET model = :model WHERE id = :id")  suspend fun setModel(id: String, model: String)

Each fake is now a non-abstract class missing an abstract member. Because Gradle compiles the whole test source set as one unit, this fails compilation for the entire suite — not just these three files.

Fix

Add the setModel override to each fake, backed by the same in-memory devices map they already use for setPeripheralId. It's a real implementation (copy(model = model)), so the fake mirrors Room's UPDATE pairedDevice SET model rather than silently
no-op'ing:

- app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt — FakeDao
- app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt — FakeRegistryDao
- app/src/test/java/com/noop/data/DeviceRegistryTest.kt — FakeRegistryDao

Test-only; no production code changes.

Verification

./gradlew testFullDebugUnitTest \
  --tests com.noop.data.DeviceRegistryTest \
  --tests com.noop.analytics.RegistryDayOwnerSourceTest \
  --tests com.noop.ble.SourceCoordinatorAdoptionTest
→ BUILD SUCCESSFUL   (was: test-source compile failure)
Compilation succeeding here proves the whole Full-flavor suite compiles again; the three affected classes also pass.

Scope

Single concern, three files, test-only. Follow-up to #716.

… DAOs (unblock testFullDebugUnitTest)

ryanbr#716 added `suspend fun setModel(id, model)` to the DeviceRegistryDao interface (and
the production Room DAO + Swift twin) but did not update the three hand-written test
doubles that implement the interface. They became non-abstract classes missing an
abstract member, so the Full-flavor test source set no longer COMPILED — and because
Gradle compiles the whole set as one unit, every com.noop.* JVM unit test was blocked,
not just these three files. Android's unit-test CI job is red on main as a result.

Add the override to each fake, backed by the same in-memory `devices` map they already
use for `setPeripheralId` — a real implementation (copy(model = model)) so the fake's
behaviour matches Room's `UPDATE pairedDevice SET model` instead of silently no-op'ing:
- app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt (FakeDao)
- app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt (FakeRegistryDao)
- app/src/test/java/com/noop/data/DeviceRegistryTest.kt (FakeRegistryDao)

Test-only; no production code changes.

Verification: `./gradlew testFullDebugUnitTest --tests DeviceRegistryTest
--tests RegistryDayOwnerSourceTest --tests SourceCoordinatorAdoptionTest` now compiles
and passes (was: test-source compile failure). The whole Full-flavor suite compiles
again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 23, 2026
… unit-tested (both platforms)

The start-clamp added in the previous commit lived inline in the app-layer persistHypnogramBurst,
which CI never compiles or tests. Move the actual clip into the PURE reconstruction
`OuraHypnogramBurst.codesWithTimes(endUnixSeconds:sleepStartUnixSeconds:)` (OuraProtocol / Kotlin
com.noop.oura), where `swift test` and the JVM tests cover it. The app just computes the 0x49 onset
(with its sanity guards) and logs the trim.

The pure clip: lay codes backward from the end as before, then drop those with ts < onset. A clamp
that would drop EVERY code is IGNORED (returns the full unclamped lay) so a mis-paired 0x49 can never
empty the night — the safety guard is now IN the tested function, not the untested app path.

Fixtures added on both platforms (HypnogramAssemblerTests / HypnogramAssemblerTest):
- drops codes before the onset (kept codes keep their end-anchored times);
- boundary is inclusive (>=) and an onset before the burst drops nothing;
- an onset after every code falls back to the full night (never-empty guard);
- a nil onset is byte-identical to the unclamped lay.

Verification: swift test OuraProtocol green incl. the 4 new StartClamp cases; macOS Strand BUILD
SUCCEEDED; Android compileFullDebugKotlin OK. (The Kotlin twin fixtures mirror the passing Swift ones;
their RUN is still gated by the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 23, 2026
… 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 23, 2026
… 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 24, 2026
… 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 24, 2026
…name digits (ryanbr#772)

On-device a Gen3 ring registered as "Oura Ring 5": a factory-reset ring advertises its SERIAL in the BLE
name ("Oura 2H3B2405003655"), and `recognise(advertisedName:)` read the "5" in that serial as gen5. The
authoritative generation is in the ring's GetProductInfo hardware id, which the capture just confirmed:
`get_hardware` reply (op 0x19) = "BLB_03" → Gen3.

- Pure (OuraProtocol, both platforms): `OuraDecoders.productInfoString` decodes a GetProductInfo reply
  (byte0 status + NUL-terminated ASCII → serial "2H3B2405003655" / hardware "BLB_03");
  `OuraRingGen.from(hardwareId:)` maps the trailing "_NN" → gen (BLB_03→gen3 validated; gen4/5 codes
  unconfirmed → nil, never a guess). Harden `recognise(advertisedName:)` to infer a gen ONLY from an
  explicit "gen"/"ring" token, so a serial-bearing name yields nil (→ safe default) instead of a bogus gen.
- App (OuraLiveSource, both platforms): on a product-info reply, if the hardware id resolves a gen that
  differs from the session's, fire a new `onModel` callback with the true "Oura Ring N" label; the
  SourceCoordinator wires it to `registry.setModel`, correcting a mis-stamped row ON CONNECT — no re-pair
  needed (the same WHOOP `setModel`-on-connect pattern, ryanbr#716). The serial reply has no "_NN" marker, so it
  never triggers a correction even though both pages answer under op 0x19.

Tier discipline: read-only; no new writes to the ring; nothing persisted beyond the corrected model label.
Verification: swift test OuraProtocol 127/0 (incl. new RingGenProductInfoTests, 10); macOS Strand BUILD
SUCCEEDED; Android compileFullDebugKotlin OK. The Kotlin twin test mirrors the Swift one but its RUN is
gated by the pre-existing ryanbr#725 fake-`setModel` gap (three test fakes on main don't implement the DAO
method) — unrelated to this change; the main module compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 24, 2026
)

Mirrors the Apple ryanbr#771 serial→id re-point on Android now that it's hardware-validated on macOS.

- DeviceRegistryDao: 24 `reKey<Table>(from,to)` `UPDATE OR IGNORE` queries (the SAME device-scoped table
  set as deleteDeviceData; Room has no dynamic table names), plus `pairedDevice(id)` and
  `deletePairedDeviceRow`/`deleteDeviceRow`.
- DeviceRegistry.adoptSerialIdentity(activeId, serialId): one-transaction twin of the Swift store method —
  clone the active (provisional) row under the serial id when new, or carry this pairing's peripheralId/model
  onto an existing serial row (keep active, preserve its addedAt); re-key each table then clear leftovers;
  drop the provisional CB-UUID pairedDevice + device rows. ONLY the active row is folded — other past oura-*
  rows are left untouched (agreed scope).
- OuraLiveSource: `onSerial` callback fired from the product-info peek when the serial page decodes (guarded
  by `isPlausibleSerial`); the hardware page still routes to `onModel` (ryanbr#772). Byte-identical to Swift.
- SourceCoordinator.adoptOuraSerial: build `oura-<serial>` from the brand catalog (never a literal), migrate
  the install key via OuraInstallKeyStore (else the re-pointed session can't auth), `setActive` +
  `onActiveDeviceChanged` re-point the source onto the serial id.

Verification: Android compileFullDebugKotlin OK (Room KSP validates the new queries). The adoptSerialIdentity
unit test is deferred like the other Oura Kotlin tests — the test module doesn't compile until ryanbr#725 adds the
three fake-DAO `setModel` overrides; the Swift twin's 4 tests already pin the logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
ryanbr pushed a commit that referenced this pull request Jul 24, 2026
… labelled "Oura" (#773)

* feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT #446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated #716 test-fake compile gap / PR #725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android)

The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate
(`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog;
on Android they were new hardcoded literals not in the baseline.

- Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to
  Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales
  (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling
  "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…").
- Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no
  low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is
  exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a
  separate backlog task; this only closes the NEW-literal regression.)

`python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus
locales complete; Apple: no new un-extracted literals + all catalogs complete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ryanbr added a commit that referenced this pull request Jul 24, 2026
…rom its hardware id (#771, #772) (#776)

* feat(oura): capture GetProductInfo serial + hardware replies (groundwork, #771/#772)

Read-only capture instrumentation, both platforms. On reaching the stream, ASK the ring its already-
defined, pre-auth-readable GetProductInfo serial (`18 03 08 00 10`) + hardware (`18 03 18 00 10`) pages
and LOG the raw replies once per op per session (hex + ASCII, since both are strings, e.g. "BLB_03").
Nothing is decoded, minted into a device id, or persisted — this is the capture-first step so a real
fixture backs the decode before it drives:
  - #771 a STABLE `oura-<serial>` identity (iOS can't read the MAC; the CoreBluetooth UUID rotates on
    re-pair and orphans the ring's history), and
  - #772 generation from the hardware id instead of stray digits in the advertised name.

- OuraLiveSource (Swift + Kotlin twin): send the two reads alongside the existing SpO2 / real-steps
  status reads in the `.streaming` first-reached block (same read-only class, never a set/enable write);
  peek every notification's outer frames and log any product-info reply. The reply op is captured as
  BOTH 0x18 and 0x19 (request→response +1 convention, cf. GetBattery 0x0C→0x0D), so the fixture lands
  whatever the firmware uses. Neither op is an event tag (>= 0x41), so the peek never disturbs the TLV
  decode. `loggedProductInfoOps` gates once-per-op and resets on stop/disconnect.

Verification: macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK. The reply layout +
whether the serial is stable across a factory-reset-and-adopt are confirmed on the next on-device
capture (this instrumentation is how that fixture is collected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

* fix(oura): dedupe product-info capture by content so get_hardware isn't swallowed (#771/#772)

On-device the first capture logged only ONE product-info reply (the serial, op 0x19) even though both
get_serial AND get_hardware were sent: the guard printed once per OP, and both pages answer under op 0x19,
so the get_hardware reply — the page that carries the hardware id for #772 generation detection — was
suppressed. Key the once-per-session guard by op+body instead of op, so each DISTINCT reply logs once and
only identical re-serves are collapsed. Both platforms.

Field-confirmed by the capture this fixes: get_serial reply op=0x19 `00 <ascii serial> 00 00` =
"2H3B2405003655", byte-identical before and after a factory-reset-and-adopt (which rotated the
CoreBluetooth UUID from 99B6BA9D… to 4DD70E24…) — i.e. the serial is a STABLE ring identity where the
CBUUID is not. Still capture-only; nothing decoded/minted yet.

Verification: macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

* fix(oura): resolve generation from the ring's hardware id, not stray name digits (#772)

On-device a Gen3 ring registered as "Oura Ring 5": a factory-reset ring advertises its SERIAL in the BLE
name ("Oura 2H3B2405003655"), and `recognise(advertisedName:)` read the "5" in that serial as gen5. The
authoritative generation is in the ring's GetProductInfo hardware id, which the capture just confirmed:
`get_hardware` reply (op 0x19) = "BLB_03" → Gen3.

- Pure (OuraProtocol, both platforms): `OuraDecoders.productInfoString` decodes a GetProductInfo reply
  (byte0 status + NUL-terminated ASCII → serial "2H3B2405003655" / hardware "BLB_03");
  `OuraRingGen.from(hardwareId:)` maps the trailing "_NN" → gen (BLB_03→gen3 validated; gen4/5 codes
  unconfirmed → nil, never a guess). Harden `recognise(advertisedName:)` to infer a gen ONLY from an
  explicit "gen"/"ring" token, so a serial-bearing name yields nil (→ safe default) instead of a bogus gen.
- App (OuraLiveSource, both platforms): on a product-info reply, if the hardware id resolves a gen that
  differs from the session's, fire a new `onModel` callback with the true "Oura Ring N" label; the
  SourceCoordinator wires it to `registry.setModel`, correcting a mis-stamped row ON CONNECT — no re-pair
  needed (the same WHOOP `setModel`-on-connect pattern, #716). The serial reply has no "_NN" marker, so it
  never triggers a correction even though both pages answer under op 0x19.

Tier discipline: read-only; no new writes to the ring; nothing persisted beyond the corrected model label.
Verification: swift test OuraProtocol 127/0 (incl. new RingGenProductInfoTests, 10); macOS Strand BUILD
SUCCEEDED; Android compileFullDebugKotlin OK. The Kotlin twin test mirrors the Swift one but its RUN is
gated by the pre-existing #725 fake-`setModel` gap (three test fakes on main don't implement the DAO
method) — unrelated to this change; the main module compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

* feat(oura): mint the device id from the ring's stable serial, re-pointing off the CBUUID (#771)

An Oura re-pair mints a fresh CoreBluetooth UUID, so `oura-<CBUUID>` changes every pairing and orphans the
ring's history under the old id (on-device: one ring produced FOUR oura-<UUID> rows, the live data stranded
under an archived one). The ring's SERIAL (read via GetProductInfo on connect) is the one identity that
survives a re-pair, so key the device on `oura-<serial>` instead.

Approach (option 1, agreed with the user — Apple side; Android twin deferred, see below):
- The live source keeps minting the provisional `oura-<CBUUID>` at pairing; once it reads the serial page at
  `.streaming`, it fires a new `onSerial` callback (guarded by `isPlausibleSerial`: alphanumeric, 8..24, so a
  misframed reply can't mint a bogus id). The serial page is told apart from the hardware page (both op 0x19)
  by content — the hardware id has an "_NN" gen marker, the serial does not.
- `SourceCoordinator.adoptOuraSerial` re-points the ACTIVE row onto `oura-<serial>` (prefix from the brand
  catalog, never a literal): `DeviceRegistryStore.adoptSerialIdentity(from:to:)` folds ONLY the active
  CB-UUID row's data + registry into the serial id (a rename when new, an UPDATE-OR-IGNORE merge when a prior
  pairing already established it) and DELIBERATELY leaves other past `oura-*` rows untouched — the agreed
  scope. The install key (Keychain-keyed by deviceId) is migrated onto the serial id or the re-pointed
  session couldn't authenticate. Then `setActive(serialId)` moves the read/write spine over (the coordinator
  rebuilds the source under the serial id via the activeDeviceId observer; deferred one main-loop turn so the
  current BLE callback returns before its own source is torn down).

Result: after the first connect on the new build the active id becomes `oura-<serial>`, and every future
re-pair converges onto it — no more orphaning. Idempotent (serialId == deviceId ⇒ no-op).

Verification: swift test WhoopStore 273/0 (+4 adoptSerialIdentity tests: rename-when-new, merge-when-exists,
leaves-other-pairings-untouched, no-op); macOS Strand BUILD SUCCEEDED. The re-point + key migration + adopt-
flow interaction need on-device validation (a mid-session identity change is novel), so the Android twin
(a sizeable per-table Room reconcile + wiring) is deliberately deferred until this is proven on hardware —
porting first risks redoing it. The pure decode (productInfoString / fromHardwareId) is already twinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

* feat(oura): Android twin of the serial-based device identity (#771)

Mirrors the Apple #771 serial→id re-point on Android now that it's hardware-validated on macOS.

- DeviceRegistryDao: 24 `reKey<Table>(from,to)` `UPDATE OR IGNORE` queries (the SAME device-scoped table
  set as deleteDeviceData; Room has no dynamic table names), plus `pairedDevice(id)` and
  `deletePairedDeviceRow`/`deleteDeviceRow`.
- DeviceRegistry.adoptSerialIdentity(activeId, serialId): one-transaction twin of the Swift store method —
  clone the active (provisional) row under the serial id when new, or carry this pairing's peripheralId/model
  onto an existing serial row (keep active, preserve its addedAt); re-key each table then clear leftovers;
  drop the provisional CB-UUID pairedDevice + device rows. ONLY the active row is folded — other past oura-*
  rows are left untouched (agreed scope).
- OuraLiveSource: `onSerial` callback fired from the product-info peek when the serial page decodes (guarded
  by `isPlausibleSerial`); the hardware page still routes to `onModel` (#772). Byte-identical to Swift.
- SourceCoordinator.adoptOuraSerial: build `oura-<serial>` from the brand catalog (never a literal), migrate
  the install key via OuraInstallKeyStore (else the re-pointed session can't auth), `setActive` +
  `onActiveDeviceChanged` re-point the source onto the serial id.

Verification: Android compileFullDebugKotlin OK (Room KSP validates the new queries). The adoptSerialIdentity
unit test is deferred like the other Oura Kotlin tests — the test module doesn't compile until #725 adds the
three fake-DAO `setModel` overrides; the Swift twin's 4 tests already pin the logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: ryanbr <mp3geek@gmail.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 25, 2026
…series survives storage

The 0x5D hrv_event body is a run of (u8 avg HR, u8 avg RMSSD) pairs, one per
5-min bucket. The mapping stamped every bucket in a record with the SAME `ts`,
but the event key is (deviceId, ts, kind) with ON CONFLICT DO NOTHING — so N
buckets collided on insert and only the first survived, silently dropping the
rest of the ring's per-5-min series (the very series this tag now represents).

Apply the documented 5-min cadence at mapping time (the OuraHRV.index contract:
per-sample times step back from the event time, OURA_PROTOCOL.md s6): bucket
`index` is stamped 300 * index seconds before the record time. Distinct rows,
ordered oldest-last, none dropped. Derived from the known cadence + record
anchor, not a guessed time. Applied identically on Swift and Kotlin.

Also fix the stale mapping-layer header docs on both platforms — they still
described the dead (time_ms/b1/b2) layout and forbade the rmssd_ms field the
code now emits.

Addresses reviewer feedback on the storage collision and stale docs.

Swift: WhoopStore builds, OuraStreamMappingTests 12/12 pass incl. new
multi-bucket test. Kotlin: main source compiles; twin test added (module unit
tests still blocked pre-existing by the ryanbr#725 DAO fake gap). No new i18n strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
@ryanbr ryanbr mentioned this pull request Jul 26, 2026
12 tasks
ryanbr pushed a commit that referenced this pull request Jul 26, 2026
…i.kt (#733)

Moves the Sleep stage breakdown and hypnogram rendering helpers out of
SleepScreen.kt into SleepStageBreakdownUi.kt, beside the existing Sleep logic
helper files.

Verified as a genuine pure move rather than trusting the label: every function body
was extracted from both sides and compared. Five functions moved, five removed, sets
identical, bodies byte-for-byte unchanged — with one exception, `private` ->
`internal` on exactly the three that cross the file boundary (ClockLabelRow,
HypnogramWithAxis, StageBreakdownRows). That is the minimum widening the move
requires.

StageBreakdownRow and stageColorFor stay private because they are only called from
inside the new file. The two apparent external references are false hits:
StageBreakdownRow matches as a substring of StageBreakdownRows, and stageColorFor
appears only in a comment at SleepStageTimelineLogic.kt:128. No blanket widening.

The +30 line delta is package declaration, imports and file header.

Localization checked and clean — the moved code uses uiString(R.string.…) resource
lookups, not the Text(variable) pattern the i18n audit cannot see.

Contributor compiled with compileFullDebugKotlin locally and said so, which with
android.yml disabled is the only build this code gets.

Unit tests were not run, and that is not on this PR: DeviceRegistryDao.setModel
blocks testFullDebugUnitTest for everyone, #725 is the open fix, and this is the
third contributor to hit it today.

Follow-up filed separately: stageColorFor duplicates the canonicalisation in
canonicalStage(), which carries a comment saying the two must stay in parity. The
refactor did not create that — it made it visible by putting the halves in adjacent
files.
ryanbr pushed a commit that referenced this pull request Jul 26, 2026
…e test fakes (#828)

The Android test source set did not compile. DeviceRegistryDao declares the reKey* family
(#771 adopt-serial), pairedDevice(id) and deletePairedDeviceRow(id), but three standalone
in-memory fakes were never updated:

  DeviceRegistryTest.FakeRegistryDao
  SourceCoordinatorAdoptionTest.FakeRegistryDao
  RegistryDayOwnerSourceTest.FakeDao

Kotlin compiles a source set as one unit, so this blocked the ENTIRE Android unit-test tree,
not just these three classes — no Android test could run locally at all, whatever it covered.
Several PRs open today could not tick their Android-test box because of it.

Verified independently rather than on the build claim, and re-verified at the merged head:
DeviceRegistryDao declares 64 suspend funs and all three fakes override all 64, none missing.

reKeyDayOwnership is the only non-no-op and its semantics are correct. dayOwnership's primary
key is `day` alone, so UPDATE OR IGNORE ... SET deviceId can never hit a key collision and the
OR IGNORE is a no-op safeguard there; the fake's LinkedHashMap<day, row> models the table
exactly. Replacing a value for an existing key mid-iteration is safe — value replacement is not
a structural modification. The remaining reKey* overrides are no-ops, matching the existing
delete*For pattern for tables these fakes do not model.

Test-only, pure additions, no production logic touched.

Supersedes #725, which implements setModel — already present in all three fakes on main.
@ryanbr

ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closing as superseded by #828 (merged as 464c2a15), which fixes the same three fakes.

The specific change here — implementing setModel — is already on main: all three fakes carry it, so this PR is a no-op against current main. What was still missing was the reKey* family, pairedDevice(id) and deletePairedDeviceRow(id), which #828 adds.

Thanks for spotting the breakage and pushing on it — the Android test tree compiles again, so testFullDebugUnitTest runs for everyone now.

@ryanbr ryanbr closed this Jul 26, 2026
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 27, 2026
…irs — was mis-framed

The 0x5D hrv_event body is a run of (u8 avg HR bpm, u8 avg RMSSD ms) PAIRS, one per
5-min bucket (open_oura `decode_hrv`; confirmed on a real overnight capture). NOOP read
it as a 4-byte (u16 time, int8 b1, int8 b2) stride — a mis-framing that:
  - garbled the first (hr, rmssd) byte-pair into a bogus `time_ms`,
  - sign-flipped the RMSSD byte (int8, so 131 read as -125),
  - and only `b1` accidentally landed on a real HR byte.

Validation: the corrected HR byte tracks sleeping HR — a captured 22-Jul night decoded to
~52 bpm across the small hours, matching the ryanbr#511 IBI-derived median (54 bpm). The bogus
`time_ms` values reconstructed byte-for-byte into the (hr, rmssd) of the first pair.

Changes (byte-identical Swift/Kotlin twins):
- OuraProtocol: `OuraHRV` now carries `(index, hrBpm, rmssdMs)`; `decodeHRV` walks 2-byte
  (u8 hr, u8 rmssd) pairs, both UNSIGNED, and returns nil on an empty/ODD-length body
  (never a partial pair). Golden tests use real overnight bytes (32 84 32 83) + an
  odd-length-nil case. `oura-decode` CLI line updated.
- WhoopStore/data: OURA_HRV event payload is now honestly labelled `hr_bpm`/`rmssd_ms`/
  `pair_index` (the byte->unit scaling is pinned now, so the old "raw, units-neutral, no
  rmssd_ms" caveat no longer applies). Keys/values identical across platforms.
- docs/OURA_PROTOCOL.md s6.9 corrected with the pair layout + the decode-correction note.

This is a pure decoder-correctness fix. It also unblocks a follow-up (ryanbr#728): the ring's
own overnight HR + RMSSD are now available honestly, feeding avgHrv directly and giving a
ground-truth HR to validate the IBI-derived hrSample against.

Verification: swift test OuraProtocol + WhoopStore green (incl. the corrected 0x5D
golden + mapping tests); macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK.
The Android unit-test RUN is blocked only by the unrelated ryanbr#716 test-fake gap (PR ryanbr#725);
the Kotlin 0x5D tests mirror the passing Swift ones and run once that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 27, 2026
…series survives storage

The 0x5D hrv_event body is a run of (u8 avg HR, u8 avg RMSSD) pairs, one per
5-min bucket. The mapping stamped every bucket in a record with the SAME `ts`,
but the event key is (deviceId, ts, kind) with ON CONFLICT DO NOTHING — so N
buckets collided on insert and only the first survived, silently dropping the
rest of the ring's per-5-min series (the very series this tag now represents).

Apply the documented 5-min cadence at mapping time (the OuraHRV.index contract:
per-sample times step back from the event time, OURA_PROTOCOL.md s6): bucket
`index` is stamped 300 * index seconds before the record time. Distinct rows,
ordered oldest-last, none dropped. Derived from the known cadence + record
anchor, not a guessed time. Applied identically on Swift and Kotlin.

Also fix the stale mapping-layer header docs on both platforms — they still
described the dead (time_ms/b1/b2) layout and forbade the rmssd_ms field the
code now emits.

Addresses reviewer feedback on the storage collision and stale docs.

Swift: WhoopStore builds, OuraStreamMappingTests 12/12 pass incl. new
multi-bucket test. Kotlin: main source compiles; twin test added (module unit
tests still blocked pre-existing by the ryanbr#725 DAO fake gap). No new i18n strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
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.

2 participants