Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Strand/BLE/SourceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ final class SourceCoordinator: ObservableObject {
private var connectedWhoopUuid: String?

private var cancellables = Set<AnyCancellable>()
private var ouraSightingSubscription: AnyCancellable?

// MARK: - Init

Expand Down Expand Up @@ -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<OuraLiveSource.LinkPhase, Never>)
-> AnyPublisher<OuraLiveSource.LinkPhase, Never> {
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-<serial>` 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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions StrandTests/OuraLastSeenTests.swift
Original file line number Diff line number Diff line change
@@ -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<OuraLiveSource.LinkPhase, Never>()
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<OuraLiveSource.LinkPhase, Never>(.authenticated)
var sightings = 0
let subscription = SourceCoordinator.ouraSightings(phases.eraseToAnyPublisher())
.sink { _ in sightings += 1 }
XCTAssertEqual(sightings, 1)
withExtendedLifetime(subscription) {}
}
}
13 changes: 13 additions & 0 deletions android/app/src/main/java/com/noop/ble/SourceCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<OuraLiveSource.LinkPhase>): Flow<OuraLiveSource.LinkPhase> =
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
Expand Down
48 changes: 48 additions & 0 deletions android/app/src/test/java/com/noop/ble/OuraLastSeenTest.kt
Original file line number Diff line number Diff line change
@@ -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<Long>()
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)
}
}
Loading