From 70d9c047dc7cd596754120da2fa437c49ce8106f Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 13 Apr 2026 14:01:36 +0200 Subject: [PATCH] fix: Prevent duplicate device cards for stranger AirPods with same model --- .../capod/monitor/core/DeviceMonitor.kt | 32 ++++++- .../capod/pods/core/apple/ble/AppleFactory.kt | 10 ++ .../capod/monitor/core/DeviceMonitorTest.kt | 93 +++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) 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 dfec632c..71691fd9 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 @@ -12,6 +12,7 @@ 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.ble.devices.ApplePods import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile @@ -66,13 +67,38 @@ class DeviceMonitor @Inject constructor( ) } + // Collapse live duplicates sharing an identity-backed profile — e.g. when the legacy + // signal-quality fallback misattributed ambient strangers to our profile. + // Only applied when the group has at least one IRK-verified candidate; for no-IRK + // profiles, multiple hits are genuinely different devices and must all remain visible. + val profileDedupedLive = run { + val anonymous = liveDevices.filter { it.profileId == null } + val byProfile = liveDevices + .filter { it.profileId != null } + .groupBy { it.profileId!! } + .flatMap { (_, group) -> + val hasIrkMatch = group.any { (it.ble as? ApplePods)?.meta?.isIRKMatch == true } + if (!hasIrkMatch || group.size == 1) { + group + } else { + listOf( + group.sortedWith( + compareByDescending { (it.ble as? ApplePods)?.meta?.isIRKMatch == true } + .thenByDescending { it.signalQuality } + ).first() + ) + } + } + byProfile + anonymous + } + // 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 liveProfileIds = profileDedupedLive.mapNotNull { it.profileId }.toSet() val nonLiveDevices = profiles .filter { it.id !in liveProfileIds } .mapNotNull { profile -> @@ -103,10 +129,10 @@ class DeviceMonitor @Inject constructor( .filter { it != PodModel.UNKNOWN } .toSet() val dedupedLiveDevices = if (nonLiveAapModels.isEmpty()) { - liveDevices + profileDedupedLive } else { val seenAnonymousModels = mutableSetOf() - liveDevices.filter { device -> + profileDedupedLive.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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt index 076ff9b4..ac1b8ee6 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt @@ -100,8 +100,18 @@ class AppleFactory @Inject constructor( meta = ApplePods.AppleMeta(), ) profile = profiles + .filter { it.identityKey == null } .filter { it.model == PodModel.UNKNOWN || it.model == tempDevice.model } .firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality } + + if (profile == null) { + val legacyCandidate = profiles + .filter { it.identityKey != null && (it.model == PodModel.UNKNOWN || it.model == tempDevice.model) } + .firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality } + if (legacyCandidate != null) { + log(TAG, WARN) { "Keyed profile ${legacyCandidate.id} would match via old fallback (IRK failed) — stale key?" } + } + } } factory.create( 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 4c301f57..01cc3452 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 @@ -9,6 +9,7 @@ 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.pods.core.apple.ble.BlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo @@ -43,6 +44,17 @@ class DeviceMonitorTest : BaseTest() { model = PodModel.AIRPODS_PRO2_USBC, address = null, ) + private val keyedProfile = AppleDeviceProfile( + label = "Keyed AirPods", + model = PodModel.AIRPODS_PRO2_USBC, + address = testAddress, + identityKey = ByteArray(16) { 0x42 }, + ) + private val noKeyProfile = AppleDeviceProfile( + label = "No Key AirPods", + model = PodModel.AIRPODS_PRO2_USBC, + address = "11:22:33:44:55:66", + ) private val testCachedState = CachedDeviceState( profileId = testProfile.id, @@ -76,6 +88,22 @@ class DeviceMonitorTest : BaseTest() { } } + private fun mockAppleBlePod( + profile: DeviceProfile, + isIRKMatch: Boolean, + signalQuality: Float = 0.5f, + ): ApplePods { + val appleMeta = ApplePods.AppleMeta( + isIRKMatch = isIRKMatch, + profile = profile as? AppleDeviceProfile, + ) + return mockk(relaxed = true) { + every { meta } returns appleMeta + every { this@mockk.model } returns profile.model + every { this@mockk.signalQuality } returns signalQuality + } + } + private fun createMonitor( ble: List = emptyList(), aap: Map = emptyMap(), @@ -390,4 +418,69 @@ class DeviceMonitorTest : BaseTest() { devices.count { it.profileId == null } shouldBe 1 devices.count { it.profileId == testProfile.id } shouldBe 1 } + + @Test + fun `profile dedup - two live pods with same IRK-backed profileId collapsed to IRK winner`() = + runTest(testDispatcher) { + val irkPod = mockAppleBlePod(keyedProfile, isIRKMatch = true, signalQuality = 0.6f) + val strangerPod = mockAppleBlePod(keyedProfile, isIRKMatch = false, signalQuality = 0.8f) + val monitor = createMonitor( + ble = listOf(irkPod, strangerPod), + profiles = listOf(keyedProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + devices.single().ble shouldBe irkPod + } + + @Test + fun `profile dedup - two IRK-matched pods for same profile keeps higher signal quality`() = + runTest(testDispatcher) { + val weakPod = mockAppleBlePod(keyedProfile, isIRKMatch = true, signalQuality = 0.3f) + val strongPod = mockAppleBlePod(keyedProfile, isIRKMatch = true, signalQuality = 0.9f) + val monitor = createMonitor( + ble = listOf(weakPod, strongPod), + profiles = listOf(keyedProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 1 + devices.single().ble shouldBe strongPod + } + + @Test + fun `profile dedup - two pods with same no-IRK profileId are NOT collapsed`() = + runTest(testDispatcher) { + val pod1 = mockAppleBlePod(noKeyProfile, isIRKMatch = false, signalQuality = 0.6f) + val pod2 = mockAppleBlePod(noKeyProfile, isIRKMatch = false, signalQuality = 0.8f) + val monitor = createMonitor( + ble = listOf(pod1, pod2), + profiles = listOf(noKeyProfile), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 2 + } + + @Test + fun `profile dedup - anonymous pods always pass through`() = runTest(testDispatcher) { + val anon1 = mockAnonymousBlePod(PodModel.AIRPODS_PRO2_USBC) + val anon2 = mockAnonymousBlePod(PodModel.AIRPODS_PRO2_USBC) + val monitor = createMonitor( + ble = listOf(anon1, anon2), + profiles = emptyList(), + scope = backgroundScope, + ) + + val devices = monitor.devices.first() + + devices.size shouldBe 2 + } }