diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift new file mode 100644 index 0000000000..ec221112e9 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift @@ -0,0 +1,69 @@ +import Foundation + +/// The stable WHOOP device id derived from the strap's own serial (#1303). +/// +/// WHOOP strap identity is otherwise a TRANSIENT CoreBluetooth UUID: a re-pair or factory reset mints a +/// fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193). +/// The ring path already solved this — `DeviceRegistryStore.adoptSerialIdentity` re-points a provisional id +/// onto a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea. +/// +/// Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a +/// blank or implausible serial must yield nil and leave the existing id alone. Adopting onto a junk id +/// would be worse than not adopting at all, because the migration moves every row onto it. +/// +/// Kotlin twin: `WhoopSerialIdentity`. +public enum WhoopSerialIdentity { + + /// The one place the WHOOP id namespace is spelled. `AddDeviceWizard` mints `whoop-` and + /// `DeviceRegistryStore` classifies on the same prefix; a serial id joins the same namespace so every + /// existing prefix check keeps working unchanged. + public static let idPrefix = "whoop" + + /// Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or + /// placeholder read, which a partial GATT response can produce. + public static let minSerialLength = 6 + + /// The `whoop-` id for a strap serial, or nil when the serial cannot be trusted to identify it. + /// + /// Refuses blank/whitespace, anything under `minSerialLength`, and any serial carrying a character + /// outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a + /// serial should never become a device id. Upper-cased so the same strap read twice, in either case, + /// resolves to ONE id rather than two. + public static func adoptedId(serial: String?) -> String? { + guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let up = raw.uppercased() + guard up.count >= minSerialLength else { return nil } + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") + guard up.allSatisfy({ allowed.contains($0) }) else { return nil } + return "\(idPrefix)-\(up)" + } + + /// Whether this pairing's id may be re-pointed onto a serial id at all. + /// + /// ONLY a provisional `whoop-` id qualifies. The legacy `my-whoop` seed is deliberately + /// EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing + /// single-WHOOP install is still on that seed, ~47 code paths still read the literal `"my-whoop"` + /// directly, and `WhoopBleClient.deviceId` documents that the single-WHOOP path never reassigns it. + /// Adopting it would migrate the whole history onto `whoop-` while new samples kept being + /// written under `my-whoop` — a split history that reads as data loss. + /// + /// The legacy seed joins this path as part of #1304, once the literals no longer assume it. + public static func mayAdopt(currentId: String) -> Bool { + currentId.hasPrefix("\(idPrefix)-") + } + + /// True when `id` is already the serial id for `serial` — the steady state on every reconnect after the + /// first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect. + public static func isAlreadyAdopted(id: String, serial: String?) -> Bool { + guard let target = adoptedId(serial: serial) else { return false } + return id == target + } + + /// What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading + /// characters are ever logged — the same rule `noteWhoop5VariantFromDIS` already applies to the variant + /// line. Never log `adoptedId`'s result directly. + public static func logSafe(serial: String?) -> String { + guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return "?" } + return String(raw.uppercased().prefix(3)) + "…" + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift new file mode 100644 index 0000000000..4c8b0d68ce --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import WhoopStore + +/// The REFUSALS matter more than the composition: adoption migrates every device-scoped row onto the id +/// this returns, so a junk serial must yield nil and leave the strap on its existing id rather than move a +/// history onto a garbage key (#1303). Kotlin twin: `WhoopSerialIdentityTest`. +final class WhoopSerialIdentityTests: XCTestCase { + + func testComposesTheSerialId() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5AG12345678"), "whoop-5AG12345678") + } + + func testUpperCasesSoOneStrapCannotBecomeTwoIds() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5ag12345678"), + WhoopSerialIdentity.adoptedId(serial: "5AG12345678")) + } + + func testTrimsSurroundingWhitespaceFromTheGattString() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: " 5AG12345678\n"), "whoop-5AG12345678") + } + + func testRefusesBlankOrMissing() { + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: nil)) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "")) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: " \n ")) + } + + func testRefusesATruncatedRead() { + // A partial GATT response must not become an id: it would collide across straps. + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "5AG")) + } + + func testRefusesADescriptiveStringThatIsNotASerial() { + // Some peripherals answer DIS with prose. Never let that become a device id. + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "Not Available")) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "serial#1234")) + } + + func testAlreadyAdoptedIsTheReconnectEarlyOut() { + XCTAssertTrue(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: "5AG12345678")) + XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-ABCDEF-0123", serial: "5AG12345678")) + // An unusable serial is never "already adopted" — otherwise a junk read would silently suppress a + // later good one. + XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: " ")) + } + + /// The guard that makes this safe to ship before #1304. Every existing single-WHOOP install is on the + /// legacy `my-whoop` seed, ~47 code paths read that literal directly, and `WhoopBleClient` never + /// reassigns its deviceId on the single-WHOOP path — so adopting it would migrate the history onto + /// `whoop-` while new samples kept landing under `my-whoop`. A split history reads as data loss. + func testRefusesToAdoptTheLegacySingleWhoopSeed() { + XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "my-whoop")) + // A provisional pairing id IS adoptable — that is the multi-strap case this ships for. + XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-6B9F2C11-0000-4000-8000-0000000000AA")) + // An already-adopted serial id stays adoptable; the equality check upstream stops the re-migration. + XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-5AG12345678")) + // Another brand's id is never touched by the WHOOP path. + XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "oura-2H3B2405003655")) + } + + func testLogSafeNeverLeaksTheFullSerial() { + XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: "5AG12345678"), "5AG…") + XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: nil), "?") + XCTAssertFalse(WhoopSerialIdentity.logSafe(serial: "5AG12345678").contains("12345678")) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index f7f69c4b35..f9d574e7c5 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -526,6 +526,12 @@ final class AppModel: ObservableObject { }) coordinator.start() self.deviceRegistry = registry + // #1303: adoption re-points the strap onto its stable `whoop-` id inside BLEManager (which + // holds only the non-observable store), so mirror it onto the OBSERVABLE registry here or the + // Devices screen and the source coordinator keep watching an id that no longer exists. + self.ble.onSerialIdentityAdopted = { [weak registry] serialId in + registry?.setActive(serialId) + } self.sourceCoordinator = coordinator // #814 READ SPINE (HIGH-1): drive the read side off the registry's `activeDeviceId` for the WHOLE // session, exactly as SourceCoordinator drives the WRITE side off the SAME publisher. A Devices- diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 4b951f818d..14c90c70f7 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -495,6 +495,11 @@ public final class BLEManager: NSObject, ObservableObject { private let router: FrameRouter private var collector: Collector? /// #716: stored on bootstrap so the scan callback can fix the seeded "WHOOP" model label. + /// #1303: fired after the strap's history has been re-pointed onto its stable `whoop-` id. + /// The live persist paths are already re-pointed inline by then; this exists so the OBSERVABLE spine + /// (`DeviceRegistry`, and the coordinator that watches it) follows too — the store write above is not + /// observable, so without this the UI would keep showing the old id until relaunch. + var onSerialIdentityAdopted: ((String) -> Void)? private var registryStore: DeviceRegistryStore? /// #716: true once the seeded "WHOOP" model has been stamped to the correct family. private var modelStamped = false @@ -4131,6 +4136,50 @@ public final class BLEManager: NSObject, ObservableObject { // existing consumer — nothing about framing or decode reads this. state.whoop5Variant = variant.label reconcileModelFromAttestation(variant) + adoptWhoopSerialIdentity() + } + + /// #1303: the 5/MG DIS read hands us the strap's OWN serial, so re-point this pairing from its + /// transient CoreBluetooth-UUID id onto a stable `whoop-` id — through the SAME generic + /// migration the ring already uses (`adoptSerialIdentity`, #771), so a re-pair or factory reset stops + /// forking one physical strap into a second row and orphaning its history (#1193). + /// + /// A WHOOP 4.0 is deliberately untouched: it does not expose a DIS serial (the read above is gated + /// `!= .whoop4`) and the 4.0 serial's source on the wire is not yet identified, so there is nothing + /// honest to adopt onto here. + /// + /// Deferred to the next main-loop turn, mirroring `adoptOuraSerial`: adoption re-points the ACTIVE + /// device, and the observers that react tear down and rebuild the very connection this callback is + /// running inside, so the current BLE callback must return first. Idempotent — after the first + /// adoption the id already equals the serial id, so every later reconnect costs one string compare and + /// touches no database. A serial `WhoopSerialIdentity` refuses (blank, truncated, non-serial prose) + /// leaves the strap on its existing id: adopting onto a junk id would migrate every device-scoped row + /// onto a garbage key, which is worse than not adopting. + private func adoptWhoopSerialIdentity() { + guard let rs = registryStore, + let serialId = WhoopSerialIdentity.adoptedId(serial: disSerial), + let active = try? rs.all().first(where: { $0.status == .active }), + WhoopSerialIdentity.mayAdopt(currentId: active.id), + active.id != serialId + else { return } + let currentId = active.id + Task { @MainActor [weak self] in + guard let self, let rs = self.registryStore, + (try? rs.all().first(where: { $0.status == .active }))?.id == currentId + else { return } + guard (try? rs.adoptSerialIdentity(from: currentId, to: serialId)) == true else { return } + try? rs.setActive(serialId) + // The rows have MOVED to the serial id and the old registry row is gone, so the live persist + // paths must follow in the same turn: `deviceId`, the Collector and the Backfiller all stamp + // rows at write time, and leaving them on the now-deleted id would write new samples into a + // second, orphaned history — the same split this phase exists to prevent, just on the + // provisional path instead of the legacy one. Kotlin reaches the identical call through + // `SourceCoordinator.pointWhoop`, which re-points any non-legacy id. + self.setActiveDeviceId(serialId) + // Prefix only. `serialId` embeds the full serial, which must never reach a shareable log. + self.log("Adopted stable serial identity (serialPrefix=\(WhoopSerialIdentity.logSafe(serial: self.disSerial))) - history re-pointed off the transient pairing id (#1303)") + self.onSerialIdentityAdopted?(serialId) + } } /// The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 50f443ab0f..f2d5344e3d 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2542,6 +2542,17 @@ class WhoopBleClient( // #520 DIS identity — read ONCE per connection, post-handshake, 5/MG only. Serial and hardware // revision are immutable, so they are never re-polled (unlike the battery). Reset on disconnect. private var disRead = false + /** + * #1303: the strap's DIS serial, once read (5.0/MG only — a 4.0 does not expose one). + * + * KNOWN ASYMMETRY with the Swift twin, which adopts inline in BLEManager and uses its closure only + * to notify. Here the adoption itself lives in the observer (AppViewModel owns the registry handle + * and a scope; SourceCoordinator is inert on the WHOOP path by design), so a connect that completes + * before any observer is wired emits into a null callback and does not adopt. It is deferred, not + * lost: DIS is read on every connect, so the next one with an observer alive adopts. Worth knowing + * when reading a capture where iOS shows the adoption line and Android does not. + */ + var onSerial: ((String) -> Unit)? = null private var disSerial: String? = null private var disHwRev: String? = null @@ -4035,6 +4046,10 @@ class WhoopBleClient( val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?" log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}") reconcileModelFromAttestation(variant) + // #1303: hand the strap's OWN serial up so the coordinator can re-point this pairing onto a stable + // `whoop-` id. Emitted, not acted on here, mirroring how the Oura source reports its serial: + // adoption re-points the ACTIVE device and tears down the very connection this callback runs inside. + disSerial?.let { onSerial?.invoke(it) } } /** The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When @@ -8269,6 +8284,13 @@ class WhoopBleClient( } } + /** + * #1303: let the identity owner write one line into the SAME strap log the connection uses, so an + * adoption is visible in the capture beside the DIS line that triggered it. Deliberately narrow — + * the general [log] stays private. Callers must pass a serial PREFIX, never a full serial. + */ + fun logIdentity(line: String) = log(line) + private fun log(s: String, domain: com.noop.testcentre.TestDomain? = null) { // A diagnostic log line must NEVER be able to crash the app. log() runs on the GATT binder // thread and from the background reconnect service, so an uncaught throw here takes the WHOLE diff --git a/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt b/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt new file mode 100644 index 0000000000..9622ccd25b --- /dev/null +++ b/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt @@ -0,0 +1,83 @@ +package com.noop.data + +/** + * The stable WHOOP device id derived from the strap's own serial (#1303). Byte-parity twin of Swift + * `WhoopSerialIdentity`. + * + * WHOOP strap identity is otherwise a TRANSIENT Bluetooth address/UUID: a re-pair or factory reset mints a + * fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193). + * The ring path already solved this — `DeviceRegistry.adoptSerialIdentity` re-points a provisional id onto + * a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea. + * + * Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a + * blank or implausible serial must yield null and leave the existing id alone. Adopting onto a junk id + * would be worse than not adopting at all, because the migration moves every row onto it. + */ +object WhoopSerialIdentity { + + /** + * The one place the WHOOP id namespace is spelled. The add-device flow mints `whoop-
` and the + * registry classifies on the same prefix; a serial id joins the same namespace so every existing prefix + * check keeps working unchanged. + */ + const val ID_PREFIX = "whoop" + + /** + * Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or + * placeholder read, which a partial GATT response can produce. + */ + const val MIN_SERIAL_LENGTH = 6 + + private val ALLOWED = ('A'..'Z').toSet() + ('0'..'9').toSet() + '-' + + /** + * The `whoop-` id for a strap serial, or null when the serial cannot be trusted to identify it. + * + * Refuses blank/whitespace, anything under [MIN_SERIAL_LENGTH], and any serial carrying a character + * outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a + * serial should never become a device id. Upper-cased so the same strap read twice, in either case, + * resolves to ONE id rather than two. + */ + fun adoptedId(serial: String?): String? { + val raw = serial?.trim().orEmpty() + if (raw.isEmpty()) return null + val up = raw.uppercase() + if (up.length < MIN_SERIAL_LENGTH) return null + if (!up.all { it in ALLOWED }) return null + return "$ID_PREFIX-$up" + } + + /** + * Whether this pairing's id may be re-pointed onto a serial id at all. + * + * ONLY a provisional `whoop-
` id qualifies. The legacy `my-whoop` seed is deliberately + * EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing + * single-WHOOP install is still on that seed, ~47 code paths still read the literal "my-whoop" + * directly, and [com.noop.ble.WhoopBleClient] documents that the single-WHOOP path never reassigns its + * deviceId. Adopting it would migrate the whole history onto `whoop-` while new samples kept + * being written under "my-whoop" - a split history that reads as data loss. + * + * The legacy seed joins this path as part of #1304, once the literals no longer assume it. + */ + fun mayAdopt(currentId: String): Boolean = currentId.startsWith("$ID_PREFIX-") + + /** + * True when [id] is already the serial id for [serial] — the steady state on every reconnect after the + * first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect. + */ + fun isAlreadyAdopted(id: String, serial: String?): Boolean { + val target = adoptedId(serial) ?: return false + return id == target + } + + /** + * What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading + * characters are ever logged — the same rule `noteWhoop5VariantFromDis` already applies to the variant + * line. Never log [adoptedId]'s result directly. + */ + fun logSafe(serial: String?): String { + val raw = serial?.trim().orEmpty() + if (raw.isEmpty()) return "?" + return raw.uppercase().take(3) + "…" + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 0c1625ef6a..5293c5aa31 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -804,6 +804,38 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { ble.connectedPeripheralAddress .collect { addr -> noopApp.sourceCoordinator.connectedPeripheralChanged(addr) } } + // #1303: the 5/MG DIS read hands up the strap's OWN serial, so re-point this pairing from its + // transient address-based id onto a stable `whoop-` id, through the SAME migration the ring + // already uses (#771) — a re-pair or factory reset then stops forking one physical strap into a + // second row and orphaning its history (#1193). Lives here rather than in SourceCoordinator, which + // is deliberately inert on the WHOOP path and never touches WhoopBleClient internals; this class + // already owns the registry handle and a scope. Twin of Swift `BLEManager.adoptWhoopSerialIdentity`. + // + // A WHOOP 4.0 never reaches here: it exposes no DIS serial, and the 4.0 serial's source on the wire + // is not yet identified, so there is nothing honest to adopt onto. + ble.onSerial = { serial -> + viewModelScope.launch { + val serialId = com.noop.data.WhoopSerialIdentity.adoptedId(serial) + val currentId = deviceRegistry.activeDeviceId() + // Idempotent: once adopted the id already equals the serial id, so a reconnect costs one + // string compare and no database work. A serial WhoopSerialIdentity refuses (blank, + // truncated, non-serial prose) leaves the strap on its existing id — adopting onto a junk id + // would migrate every device-scoped row onto a garbage key, worse than not adopting at all. + if (serialId != null && currentId != null && currentId != serialId && + com.noop.data.WhoopSerialIdentity.mayAdopt(currentId) && + deviceRegistry.adoptSerialIdentity(currentId, serialId) + ) { + // Prefix only. `serialId` embeds the full serial, which must never reach a shared log. + ble.logIdentity( + "WHOOP: adopted stable serial identity (serialPrefix=" + + com.noop.data.WhoopSerialIdentity.logSafe(serial) + + ") - history re-pointed off the transient pairing id (#1303)", + ) + deviceRegistry.setActive(serialId) + noopApp.sourceCoordinator.onActiveDeviceChanged(serialId) + } + } + } // Re-arm the strap's firmware alarm once per process-alive day. The firmware alarm is a single // absolute instant with NO recurrence and was previously re-armed ONLY on the bond edge — so a // strap that stays continuously bonded (a phone in range overnight) would fire once and then diff --git a/android/app/src/test/java/com/noop/data/WhoopSerialIdentityTest.kt b/android/app/src/test/java/com/noop/data/WhoopSerialIdentityTest.kt new file mode 100644 index 0000000000..4dad868cdb --- /dev/null +++ b/android/app/src/test/java/com/noop/data/WhoopSerialIdentityTest.kt @@ -0,0 +1,77 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Twin of the Swift WhoopSerialIdentityTests. The REFUSALS matter more than the composition: adoption + * migrates every device-scoped row onto the id this returns, so a junk serial must yield null and leave + * the strap on its existing id rather than move a history onto a garbage key (#1303). + */ +class WhoopSerialIdentityTest { + + @Test fun composesTheSerialId() { + assertEquals("whoop-5AG12345678", WhoopSerialIdentity.adoptedId("5AG12345678")) + } + + @Test fun upperCasesSoOneStrapCannotBecomeTwoIds() { + assertEquals( + WhoopSerialIdentity.adoptedId("5ag12345678"), + WhoopSerialIdentity.adoptedId("5AG12345678"), + ) + } + + @Test fun trimsSurroundingWhitespaceFromTheGattString() { + assertEquals("whoop-5AG12345678", WhoopSerialIdentity.adoptedId(" 5AG12345678\n")) + } + + @Test fun refusesBlankOrMissing() { + assertNull(WhoopSerialIdentity.adoptedId(null)) + assertNull(WhoopSerialIdentity.adoptedId("")) + assertNull(WhoopSerialIdentity.adoptedId(" \n ")) + } + + @Test fun refusesATruncatedRead() { + // A partial GATT response must not become an id: it would collide across straps. + assertNull(WhoopSerialIdentity.adoptedId("5AG")) + } + + @Test fun refusesADescriptiveStringThatIsNotASerial() { + // Some peripherals answer DIS with prose. Never let that become a device id. + assertNull(WhoopSerialIdentity.adoptedId("Not Available")) + assertNull(WhoopSerialIdentity.adoptedId("serial#1234")) + } + + @Test fun alreadyAdoptedIsTheReconnectEarlyOut() { + assertTrue(WhoopSerialIdentity.isAlreadyAdopted("whoop-5AG12345678", "5AG12345678")) + assertFalse(WhoopSerialIdentity.isAlreadyAdopted("whoop-ABCDEF-0123", "5AG12345678")) + // An unusable serial is never "already adopted" — otherwise a junk read would silently + // suppress a later good one. + assertFalse(WhoopSerialIdentity.isAlreadyAdopted("whoop-5AG12345678", " ")) + } + + /** + * The guard that makes this safe to ship before #1304. Every existing single-WHOOP install is on the + * legacy "my-whoop" seed, ~47 code paths read that literal directly, and WhoopBleClient never + * reassigns its deviceId on the single-WHOOP path — so adopting it would migrate the history onto + * whoop- while new samples kept landing under "my-whoop". A split history reads as data loss. + */ + @Test fun refusesToAdoptTheLegacySingleWhoopSeed() { + assertFalse(WhoopSerialIdentity.mayAdopt("my-whoop")) + // A provisional pairing id IS adoptable — that is the multi-strap case this ships for. + assertTrue(WhoopSerialIdentity.mayAdopt("whoop-6B9F2C11-0000-4000-8000-0000000000AA")) + // An already-adopted serial id stays adoptable; the equality check upstream stops the re-migration. + assertTrue(WhoopSerialIdentity.mayAdopt("whoop-5AG12345678")) + // Another brand's id is never touched by the WHOOP path. + assertFalse(WhoopSerialIdentity.mayAdopt("oura-2H3B2405003655")) + } + + @Test fun logSafeNeverLeaksTheFullSerial() { + assertEquals("5AG…", WhoopSerialIdentity.logSafe("5AG12345678")) + assertEquals("?", WhoopSerialIdentity.logSafe(null)) + assertFalse(WhoopSerialIdentity.logSafe("5AG12345678").contains("12345678")) + } +}