fix: Prevent duplicate device cards for stranger AirPods with same model

This commit is contained in:
darken
2026-04-13 14:09:33 +02:00
committed by Matthias Urhahn
parent 7f2b6976ce
commit 70d9c047dc
3 changed files with 132 additions and 3 deletions
@@ -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<PodDevice> { (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<PodModel>()
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
@@ -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(
@@ -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<BlePodSnapshot> = emptyList(),
aap: Map<BluetoothAddress, AapPodState> = 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
}
}