mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(aap): Synthesize AAP-only devices for cold-start window
This commit is contained in:
@@ -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<PodModel>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>()] }
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user