mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix: Prevent duplicate device cards for stranger AirPods with same model
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user