From f2bf39bda1da02246d1e1c6aeb4181706577e6c2 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 8 Apr 2026 19:15:26 +0200 Subject: [PATCH] fix(monitor): Use AAP message time for Last Seen while connected --- .../eu/darken/capod/monitor/core/PodDevice.kt | 3 ++- .../capod/monitor/core/PodDeviceCacheTest.kt | 15 +++++++++++ .../capod/monitor/core/PodDeviceTest.kt | 27 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) 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) {