diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 765fb50e..8b0cc69d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -10,6 +10,7 @@ import eu.darken.capod.monitor.core.aap.AapLifecycleManager import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.monitor.core.cache.DeviceStateCache import eu.darken.capod.monitor.core.cache.toCachedState +import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.profiles.core.DeviceProfile @@ -58,28 +59,60 @@ class DeviceMonitor @Inject constructor( ble = pod, aap = aapStates.forProfile(profile), cached = profile?.id?.let { cachedStates[it] }, + profileAddress = profile?.address, + profileModel = profile?.model, ) } - // Cached-only devices — profiles with cache but no live BLE. - // AAP may still be connected even when BLE scan is stale in a crowded RF environment, - // so re-attach AAP state via the same helper — see #483. + // Non-live devices — profiles with no live BLE detection. Synthesize from cache + // and/or AAP, whichever is available: + // - cache only → "cached-only" case (PR #484 fixed AAP attach here) + // - AAP only → cold-start case: AAP connected but no battery message + // received yet, so no cache write has happened + // - cache + AAP → mid-session BLE staleness with AAP still alive val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet() - val cachedOnlyDevices = profiles + val nonLiveDevices = profiles .filter { it.id !in liveProfileIds } .mapNotNull { profile -> - cachedStates[profile.id]?.let { cached -> - PodDevice( - profileId = profile.id, - label = profile.label, - ble = null, - aap = aapStates.forProfile(profile), - cached = cached, - ) - } + val cached = cachedStates[profile.id] + val aap = aapStates.forProfile(profile) + if (cached == null && aap == null) return@mapNotNull null + PodDevice( + profileId = profile.id, + label = profile.label, + ble = null, + aap = aap, + cached = cached, + profileAddress = profile.address, + profileModel = profile.model, + ) } - liveDevices + cachedOnlyDevices + // De-dupe: if a synthesized non-live device with active AAP shares its model with + // an anonymous (profileId == null) BLE pod, the anonymous pod is most likely the + // same physical AirPods — the IRK match failed (e.g. wrong identity key, or keys + // haven't propagated yet). Hide the anonymous BLE pod to avoid two cards for the + // same device. Best-effort: model match only — BLE addresses are RPAs and can't + // be matched directly. + val nonLiveAapModels = nonLiveDevices + .filter { it.aap != null } + .map { it.model } + .filter { it != PodModel.UNKNOWN } + .toSet() + val dedupedLiveDevices = if (nonLiveAapModels.isEmpty()) { + liveDevices + } else { + val seenAnonymousModels = mutableSetOf() + liveDevices.filter { device -> + val isAnonymous = device.profileId == null + val matchesNonLive = device.model in nonLiveAapModels + // Hide at most one anonymous BLE pod per non-live AAP model so a second + // physical device of the same model isn't accidentally hidden. + !(isAnonymous && matchesNonLive && seenAnonymousModels.add(device.model)) + } + } + + dedupedLiveDevices + nonLiveDevices } .onEach { devices -> persistLiveDevices(devices) } .replayingShare(appScope) @@ -104,15 +137,25 @@ class DeviceMonitor @Inject constructor( return liveDevice } + val profile = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId } val cached = deviceStateCache.load(profileId) - if (cached != null) { - log(TAG) { "Found cached state for profile $profileId" } - val profileLabel = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }?.label - return PodDevice(profileId = profileId, label = profileLabel, ble = null, aap = null, cached = cached) + val aap = profile?.let { aapManager.allStates.value.forProfile(it) } + + if (cached == null && aap == null) { + log(TAG) { "No device found for profile $profileId" } + return null } - log(TAG) { "No device found for profile $profileId" } - return null + log(TAG) { "Found fallback device for profile $profileId (cached=${cached != null}, aap=${aap != null})" } + return PodDevice( + profileId = profileId, + label = profile?.label, + ble = null, + aap = aap, + cached = cached, + profileAddress = profile?.address, + profileModel = profile?.model, + ) } /** 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 44e4505c..9464c2be 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 @@ -35,10 +35,14 @@ data class PodDevice( internal val ble: BlePodSnapshot?, internal val aap: AapPodState?, internal val cached: CachedDeviceState? = null, + /** Bonded BR/EDR address from the profile — current source of truth, preferred over cache snapshot. */ + internal val profileAddress: BluetoothAddress? = null, + /** Pod model from the profile — current source of truth, preferred over cache snapshot. */ + internal val profileModel: PodModel? = null, ) { - val model: PodModel get() = ble?.model ?: cached?.model ?: PodModel.UNKNOWN + val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN /** Bonded BR/EDR address (from profile). Used for AAP commands. */ - val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: cached?.address + val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address /** BLE scan address (RPA, rotates). */ val bleAddress: BluetoothAddress? get() = ble?.address val identifier: BlePodSnapshot.Id? get() = ble?.identifier diff --git a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt index a7390ac6..4c301f57 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt @@ -13,6 +13,8 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -60,6 +62,17 @@ class DeviceMonitorTest : BaseTest() { } return mockk(relaxed = true) { every { meta } returns bleMeta + every { this@mockk.model } returns profile.model + } + } + + private fun mockAnonymousBlePod(model: PodModel): BlePodSnapshot { + val anonymousMeta = object : BlePodSnapshot.Meta { + override val profile: DeviceProfile? = null + } + return mockk(relaxed = true) { + every { meta } returns anonymousMeta + every { this@mockk.model } returns model } } @@ -78,6 +91,9 @@ class DeviceMonitorTest : BaseTest() { } val deviceStateCache: DeviceStateCache = mockk(relaxed = true) { every { cachedStates } returns MutableStateFlow(cache) + // Relaxed mocks return a mocked child for unstubbed nullable returns, + // so explicitly delegate load() to the test's cache map. + coEvery { load(any()) } answers { cache[firstArg()] } } val profilesRepo: DeviceProfilesRepo = mockk { every { this@mockk.profiles } returns MutableStateFlow(profiles) @@ -181,4 +197,197 @@ class DeviceMonitorTest : BaseTest() { device.profileId shouldBe noAddressProfile.id device.isAapConnected shouldBe false } + + /** + * Cold-start AAP-only synthesis: AAP socket is alive (CONNECTING) but no BLE + * detection has happened yet and the cache hasn't been written. The merge must + * still emit a PodDevice for the profile so DeviceSettingsScreen can render. + */ + @Test + fun `AAP-only synthesis CONNECTING - no BLE no cache`() = runTest(testDispatcher) { + val connectingAap = AapPodState( + connectionState = AapPodState.ConnectionState.CONNECTING, + lastMessageAt = null, + ) + val monitor = createMonitor( + ble = emptyList(), + aap = mapOf(testAddress to connectingAap), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + val device = devices.single() + device.profileId shouldBe testProfile.id + device.address shouldBe testProfile.address + device.model shouldBe testProfile.model + device.isAapConnected shouldBe true + device.isAapReady shouldBe false + } + + @Test + fun `AAP-only synthesis READY - no BLE no cache`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = emptyList(), + aap = mapOf(testAddress to testAapState), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + val device = devices.single() + device.profileId shouldBe testProfile.id + device.address shouldBe testProfile.address + device.model shouldBe testProfile.model + device.isAapConnected shouldBe true + device.isAapReady shouldBe true + } + + @Test + fun `profile in repo with no BLE no cache no AAP - no device emitted`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = emptyList(), + aap = emptyMap(), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 0 + } + + @Test + fun `getDeviceForProfile AAP-only fallback - returns synthesized device`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = emptyList(), + aap = mapOf(testAddress to testAapState), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val device = monitor.getDeviceForProfile(testProfile.id) + + device shouldNotBe null + device!!.profileId shouldBe testProfile.id + device.address shouldBe testProfile.address + device.model shouldBe testProfile.model + device.isAapConnected shouldBe true + } + + @Test + fun `getDeviceForProfile no BLE no cache no AAP - returns null`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = emptyList(), + aap = emptyMap(), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val device = monitor.getDeviceForProfile(testProfile.id) + + device shouldBe null + } + + /** + * Stale-cache regression: after a model correction + * (`AapAutoConnect.correctModelOnDeviceInfo`) the profile's model is updated, but the + * cache still holds the old model from before correction. The PodDevice must reflect + * the profile (current truth), not the cache (stale snapshot). + */ + @Test + fun `stale cache vs updated profile model - profile wins`() = runTest(testDispatcher) { + val staleCache = CachedDeviceState( + profileId = testProfile.id, + model = PodModel.AIRPODS_PRO2, // old model in cache + address = testAddress, + lastSeenAt = Instant.parse("2026-04-05T17:52:09.182Z"), + ) + // testProfile.model = AIRPODS_PRO2_USBC (the correction) + val monitor = createMonitor( + ble = emptyList(), + aap = emptyMap(), + cache = mapOf(testProfile.id to staleCache), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + devices.single().model shouldBe PodModel.AIRPODS_PRO2_USBC + } + + @Test + fun `stale cache vs updated profile address - profile wins`() = runTest(testDispatcher) { + val staleCache = CachedDeviceState( + profileId = testProfile.id, + model = PodModel.AIRPODS_PRO2_USBC, + address = "00:00:00:00:00:00", // old address in cache + lastSeenAt = Instant.parse("2026-04-05T17:52:09.182Z"), + ) + // testProfile.address = "AA:BB:CC:DD:EE:FF" + val monitor = createMonitor( + ble = emptyList(), + aap = emptyMap(), + cache = mapOf(testProfile.id to staleCache), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + devices.single().address shouldBe testAddress + } + + /** + * De-dupe: an anonymous BLE pod (no IRK match) of the same model as a synthesized + * AAP-only profile is most likely the same physical device with broken/missing keys. + * Hide the anonymous pod to avoid showing two cards. + */ + @Test + fun `de-dupe - anonymous BLE pod with matching model is hidden`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = listOf(mockAnonymousBlePod(PodModel.AIRPODS_PRO2_USBC)), + aap = mapOf(testAddress to testAapState), + cache = emptyMap(), + profiles = listOf(testProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + val device = devices.single() + device.profileId shouldBe testProfile.id + device.isAapConnected shouldBe true + } + + @Test + fun `de-dupe - anonymous BLE pod with different model is kept`() = runTest(testDispatcher) { + val monitor = createMonitor( + ble = listOf(mockAnonymousBlePod(PodModel.AIRPODS_PRO)), // different model + aap = mapOf(testAddress to testAapState), + cache = emptyMap(), + profiles = listOf(testProfile), // model = PRO2_USBC + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 2 + // One anonymous BLE pod (no profile), one synthesized AAP-only profile + devices.count { it.profileId == null } shouldBe 1 + devices.count { it.profileId == testProfile.id } shouldBe 1 + } }