From 532e17a4901745b014d74a9c2f429e03ff8d3c1f Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:23:27 -0500 Subject: [PATCH] fix(oura): refresh registry sightings on authenticated sessions Refs #2739 --- Strand/BLE/SourceCoordinator.swift | 11 ++++ StrandTests/OuraLastSeenTests.swift | 57 +++++++++++++++++++ .../java/com/noop/ble/SourceCoordinator.kt | 13 +++++ .../java/com/noop/ble/OuraLastSeenTest.kt | 48 ++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 StrandTests/OuraLastSeenTests.swift create mode 100644 android/app/src/test/java/com/noop/ble/OuraLastSeenTest.kt diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index fd296556bf..5594b3358e 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -97,6 +97,7 @@ final class SourceCoordinator: ObservableObject { private var connectedWhoopUuid: String? private var cancellables = Set() + private var ouraSightingSubscription: AnyCancellable? // MARK: - Init @@ -450,10 +451,19 @@ final class SourceCoordinator: ObservableObject { notifyMaskFull: { UserDefaults.standard.bool(forKey: AppModel.ouraNotifyMaskFullKey) }, // packed-notification A/B adoptIntent: adoptIntent) if adoptIntent { straplog("Oura: adopt consent granted - this session may install NOOP's key") } + ouraSightingSubscription = Self.ouraSightings(source.$linkPhase.eraseToAnyPublisher()) + .sink { [registry] _ in registry.touchLastSeen(id) } ouraSource = source // the published typed handle for the adopt mirror (same object as activeSource) return source } + /// An authenticated ring session is a confirmed sighting, even when it has no new history. + /// Failed attempts and duplicate phase publications never refresh the Devices timestamp. + static func ouraSightings(_ phases: AnyPublisher) + -> AnyPublisher { + phases.removeDuplicates().filter { $0 == .authenticated }.eraseToAnyPublisher() + } + /// #771: the live Oura source read the ring's stable SERIAL (`serial`) on connect. Re-point this device /// from its transient CoreBluetooth-UUID id (`currentId`) onto its `oura-` id, so a re-pair — which /// mints a fresh CB UUID — never orphans the ring's history again. The `oura-` prefix comes from the ONE @@ -503,6 +513,7 @@ final class SourceCoordinator: ObservableObject { /// handle so the adopt mirror resets to `.idle` (when an Oura ring was live it is the same object as /// `activeSource`; otherwise it is already nil and this is a no-op). private func tearDownNonWhoopSource() { + ouraSightingSubscription = nil activeSource?.stop() activeSource = nil ouraSource = nil diff --git a/StrandTests/OuraLastSeenTests.swift b/StrandTests/OuraLastSeenTests.swift new file mode 100644 index 0000000000..2b638648cc --- /dev/null +++ b/StrandTests/OuraLastSeenTests.swift @@ -0,0 +1,57 @@ +import Combine +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class OuraLastSeenTests: XCTestCase { + func testAuthenticatedSessionsRefreshOnlyTheObservedRing() async throws { + let store = try await WhoopStore.inMemory() + let registryStore = DeviceRegistryStore(dbQueue: store.registryWriter) + for id in ["oura-observed", "oura-other"] { + try registryStore.add(PairedDevice(id: id, brand: "Oura", model: "Oura Ring 3", + sourceKind: .liveBLE, capabilities: [.hr], status: .paired, addedAt: 1, lastSeenAt: 1)) + } + let registry = DeviceRegistry(store: registryStore) + let phases = PassthroughSubject() + var clock = 100 + let subscription = SourceCoordinator.ouraSightings(phases.eraseToAnyPublisher()) + .sink { _ in registry.touchLastSeen("oura-observed", at: clock) } + func seen(_ id: String) throws -> Int { + try XCTUnwrap(registryStore.all().first { $0.id == id }).lastSeenAt + } + + // A failed connection reaches authentication but supplies no successful ring reply. + for phase in [OuraLiveSource.LinkPhase.connecting, .authenticating, .disconnected] { + phases.send(phase) + } + XCTAssertEqual(try seen("oura-observed"), 1) + phases.send(.authenticated) + XCTAssertEqual(try seen("oura-observed"), 100) + XCTAssertEqual(registry.devices.first { $0.id == "oura-observed" }?.lastSeenAt, 100) + XCTAssertEqual(try seen("oura-other"), 1) + + clock = 200 + phases.send(.authenticated) + XCTAssertEqual(try seen("oura-observed"), 100, "duplicate publication is not a new session") + phases.send(.disconnected) + phases.send(.authenticating) + phases.send(.authenticated) + XCTAssertEqual(try seen("oura-observed"), 200, "a reconnect refreshes even without new history") + + subscription.cancel() + clock = 300 + phases.send(.disconnected) + phases.send(.authenticated) + XCTAssertEqual(try seen("oura-observed"), 200, "a torn-down source must no longer record sightings") + } + + func testAlreadyAuthenticatedSourceIsSeenOnSubscription() { + let phases = CurrentValueSubject(.authenticated) + var sightings = 0 + let subscription = SourceCoordinator.ouraSightings(phases.eraseToAnyPublisher()) + .sink { _ in sightings += 1 } + XCTAssertEqual(sightings, 1) + withExtendedLifetime(subscription) {} + } +} diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index 6da2a18d45..e52ea042e1 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -13,6 +13,9 @@ import com.noop.ui.NoopPrefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -612,6 +615,11 @@ class SourceCoordinator( launch { source.ouraWearState.collect { _ouraWearState.value = it } } launch { source.batteryPct.collect { _ouraBatteryPct.value = it } } // #2075 launch { source.linkPhase.collect { _ouraLinkPhase.value = it } } // #2305 + launch { + ouraSightings(source.linkPhase).collect { + runCatching { registry.touchLastSeen(id) } + } + } } return source } @@ -669,6 +677,11 @@ class SourceCoordinator( } companion object { + /** Confirmed ring sessions refresh Last seen once; failed attempts never do. Swift twin: + * SourceCoordinator.ouraSightings. */ + internal fun ouraSightings(phases: Flow): Flow = + phases.distinctUntilChanged().filter { it == OuraLiveSource.LinkPhase.AUTHENTICATED } + /** * Classify a device id as WHOOP vs a generic strap. WHOOP if the id is the canonical "my-whoop", * the registry row's `brand` is "WHOOP" (case-insensitive), OR the id is unknown — unknown ids diff --git a/android/app/src/test/java/com/noop/ble/OuraLastSeenTest.kt b/android/app/src/test/java/com/noop/ble/OuraLastSeenTest.kt new file mode 100644 index 0000000000..07355f2c56 --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/OuraLastSeenTest.kt @@ -0,0 +1,48 @@ +package com.noop.ble + +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class OuraLastSeenTest { + @Test fun failedAttemptsDoNotRecordSightings() = runBlocking { + var sightings = 0 + SourceCoordinator.ouraSightings(flowOf( + OuraLiveSource.LinkPhase.DISCONNECTED, + OuraLiveSource.LinkPhase.CONNECTING, + OuraLiveSource.LinkPhase.AUTHENTICATING, + OuraLiveSource.LinkPhase.DISCONNECTED, + )).collect { sightings++ } + assertEquals(0, sightings) + } + + @Test fun reconnectsRefreshButDuplicatePublicationsDoNot() = runBlocking { + var clock = 100L + val lastSeen = mutableMapOf("oura-observed" to 1L, "oura-other" to 1L) + val recorded = mutableListOf() + val phases = flow { + emit(OuraLiveSource.LinkPhase.AUTHENTICATED) + clock = 200 + emit(OuraLiveSource.LinkPhase.AUTHENTICATED) + emit(OuraLiveSource.LinkPhase.DISCONNECTED) + emit(OuraLiveSource.LinkPhase.AUTHENTICATING) + emit(OuraLiveSource.LinkPhase.AUTHENTICATED) + } + SourceCoordinator.ouraSightings(phases).collect { + lastSeen["oura-observed"] = clock + recorded += clock + } + assertEquals(listOf(100L, 200L), recorded) + assertEquals(200L, lastSeen["oura-observed"]) + assertEquals(1L, lastSeen["oura-other"]) + } + + @Test fun alreadyAuthenticatedSourceIsSeenOnCollection() = runBlocking { + var sightings = 0 + SourceCoordinator.ouraSightings(flowOf(OuraLiveSource.LinkPhase.AUTHENTICATED)) + .collect { sightings++ } + assertEquals(1, sightings) + } +}