diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 1ff217db..cf4fad99 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -64,7 +64,8 @@ data class PodDevice( val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone // Signal / timing - val seenLastAt: Instant? get() = ble?.seenLastAt ?: cached?.lastSeenAt + val seenLastAt: Instant? + get() = listOfNotNull(ble?.seenLastAt, aap?.lastMessageAt, cached?.lastSeenAt).maxOrNull() val seenFirstAt: Instant? get() = ble?.seenFirstAt val signalQuality: Float get() { diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt index 53ff609e..15d7841c 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt @@ -108,6 +108,21 @@ class PodDeviceCacheTest : BaseTest() { device.seenLastAt shouldBe fiveMinAgo } + @Test + fun `seenLastAt prefers live AAP message over stale cache`() { + // Reproduces the reported bug: while AAP is connected, iOS throttles BLE advertising. + // After BlePodMonitor evicts the BLE snapshot, the merged device had ble=null and the + // getter fell through to the stale cache, showing "hours ago" even though AAP traffic + // was still flowing. Live AAP messages must win over any cached timestamp. + val freshAapMessage = Instant.parse("2026-03-31T11:59:55Z") // 5s ago, newer than cache + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = freshAapMessage, + ) + val device = PodDevice(profileId = "test-profile", ble = null, aap = aap, cached = cachedState) + device.seenLastAt shouldBe freshAapMessage + } + @Test fun `profileId falls back to cache`() { val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 08233349..b1a1c382 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -143,6 +143,33 @@ class PodDeviceTest : BaseTest() { device.rssi shouldBe 0 } + @Test + fun `seenLastAt uses AAP lastMessageAt when BLE is null`() { + val aapMessageAt = Instant.parse("2026-04-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = aapMessageAt, + ) + val device = PodDevice(profileId = null, ble = null, aap = aap) + device.seenLastAt shouldBe aapMessageAt + } + + @Test + fun `seenLastAt picks max of BLE and AAP timestamps`() { + val bleSeenAt = Instant.parse("2026-04-01T12:00:00Z") + val aapMessageAt = bleSeenAt.plusSeconds(25) // AAP is more recent + val ble = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { seenLastAt } returns bleSeenAt + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = aapMessageAt, + ) + val device = PodDevice(profileId = null, ble = ble, aap = aap) + device.seenLastAt shouldBe aapMessageAt + } + @Test fun `charging properties delegate to BLE interfaces`() { val mock = mockk(relaxed = true) {