fix(aap): Synthesize AAP-only devices for cold-start window

This commit is contained in:
darken
2026-04-07 15:24:03 +02:00
committed by Matthias Urhahn
parent a511c57b7d
commit f67336d4fe
3 changed files with 278 additions and 22 deletions
@@ -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
}
}