fix(aap): Keep device settings visible when BLE scan goes stale

This commit is contained in:
darken
2026-04-07 13:28:37 +02:00
committed by Matthias Urhahn
parent 66e3ab1670
commit a511c57b7d
2 changed files with 207 additions and 5 deletions
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
@@ -10,6 +11,8 @@ 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.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -48,24 +51,31 @@ class DeviceMonitor @Inject constructor(
) { pods, aapStates, cachedStates, profiles ->
// Live devices — BLE + AAP + cached fallback for missing fields
val liveDevices = pods.map { pod ->
val bondedAddress = pod.meta.profile?.address
val profile = pod.meta.profile
PodDevice(
profileId = profile?.id,
label = profile?.label,
ble = pod,
aap = bondedAddress?.let { aapStates[it] },
aap = aapStates.forProfile(profile),
cached = profile?.id?.let { cachedStates[it] },
)
}
// Cached-only devices — profiles with cache but no live BLE
// 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.
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
val cachedOnlyDevices = profiles
.filter { it.id !in liveProfileIds }
.mapNotNull { profile ->
cachedStates[profile.id]?.let {
PodDevice(profileId = profile.id, label = profile.label, ble = null, aap = null, cached = it)
cachedStates[profile.id]?.let { cached ->
PodDevice(
profileId = profile.id,
label = profile.label,
ble = null,
aap = aapStates.forProfile(profile),
cached = cached,
)
}
}
@@ -105,6 +115,14 @@ class DeviceMonitor @Inject constructor(
return null
}
/**
* Single source of truth for "find the AAP state that belongs to this profile".
* AAP is keyed by the bonded BR/EDR address, which lives on [DeviceProfile.address].
* Used by both the live and cached-only branches of [devices] so they cannot drift apart.
*/
private fun Map<BluetoothAddress, AapPodState>.forProfile(profile: DeviceProfile?): AapPodState? =
profile?.address?.let { this[it] }
companion object {
private val TAG = logTag("DeviceMonitor")
}
@@ -0,0 +1,184 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.monitor.core.cache.DeviceStateCache
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceMonitorTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private val testAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val testProfile = AppleDeviceProfile(
label = "Test AirPods",
model = PodModel.AIRPODS_PRO2_USBC,
address = testAddress,
)
private val noAddressProfile = AppleDeviceProfile(
label = "No Address",
model = PodModel.AIRPODS_PRO2_USBC,
address = null,
)
private val testCachedState = CachedDeviceState(
profileId = testProfile.id,
model = PodModel.AIRPODS_PRO2_USBC,
address = testAddress,
lastSeenAt = Instant.parse("2026-04-05T17:52:09.182Z"),
)
private val testAapState = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
lastMessageAt = Instant.parse("2026-04-05T17:52:47.881Z"),
)
private fun mockBlePodWithProfile(profile: DeviceProfile): BlePodSnapshot {
val bleMeta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = profile
}
return mockk(relaxed = true) {
every { meta } returns bleMeta
}
}
private fun createMonitor(
ble: List<BlePodSnapshot> = emptyList(),
aap: Map<BluetoothAddress, AapPodState> = emptyMap(),
cache: Map<String, CachedDeviceState> = emptyMap(),
profiles: List<DeviceProfile> = emptyList(),
scope: CoroutineScope,
): DeviceMonitor {
val blePodMonitor: BlePodMonitor = mockk {
every { devices } returns MutableStateFlow(ble)
}
val aapManager: AapConnectionManager = mockk {
every { allStates } returns MutableStateFlow(aap)
}
val deviceStateCache: DeviceStateCache = mockk(relaxed = true) {
every { cachedStates } returns MutableStateFlow(cache)
}
val profilesRepo: DeviceProfilesRepo = mockk {
every { this@mockk.profiles } returns MutableStateFlow(profiles)
}
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
return DeviceMonitor(
appScope = scope,
blePodMonitor = blePodMonitor,
aapManager = aapManager,
deviceStateCache = deviceStateCache,
profilesRepo = profilesRepo,
aapLifecycleManager = aapLifecycleManager,
)
}
@Test
fun `BLE present and AAP present - live device has AAP attached`() = runTest(testDispatcher) {
val monitor = createMonitor(
ble = listOf(mockBlePodWithProfile(testProfile)),
aap = mapOf(testAddress to testAapState),
cache = mapOf(testProfile.id to testCachedState),
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
device.isAapReady shouldBe true
}
/**
* Regression test for https://github.com/d4rken-org/capod/issues/483 — settings disappeared
* when BLE went stale even though the AAP socket was still healthy.
*/
@Test
fun `BLE stale and cache present and AAP present - cached-only device still has AAP attached`() =
runTest(testDispatcher) {
val monitor = createMonitor(
ble = emptyList(), // BLE evicted
aap = mapOf(testAddress to testAapState),
cache = mapOf(testProfile.id to testCachedState),
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 testAddress
device.isAapConnected shouldBe true
device.isAapReady shouldBe true
}
@Test
fun `BLE stale and cache present and AAP absent - cached-only device has AAP null`() =
runTest(testDispatcher) {
val monitor = createMonitor(
ble = emptyList(),
aap = emptyMap(), // AAP also gone
cache = mapOf(testProfile.id to testCachedState),
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 false
}
@Test
fun `profile without address never binds to AAP entries`() = runTest(testDispatcher) {
val cachedForNoAddress = CachedDeviceState(
profileId = noAddressProfile.id,
model = PodModel.AIRPODS_PRO2_USBC,
address = null,
lastSeenAt = Instant.parse("2026-04-05T17:52:09.182Z"),
)
val monitor = createMonitor(
ble = emptyList(),
// AAP map contains an entry, but it's for a different address.
aap = mapOf(testAddress to testAapState),
cache = mapOf(noAddressProfile.id to cachedForNoAddress),
profiles = listOf(noAddressProfile),
scope = backgroundScope,
)
val devices = monitor.devices.first()
devices.size shouldBe 1
val device = devices.single()
device.profileId shouldBe noAddressProfile.id
device.isAapConnected shouldBe false
}
}