mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(aap): Keep device settings visible when BLE scan goes stale
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user