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
|
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.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.log
|
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.DeviceStateCache
|
||||||
import eu.darken.capod.monitor.core.cache.toCachedState
|
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.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 eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -48,24 +51,31 @@ class DeviceMonitor @Inject constructor(
|
|||||||
) { pods, aapStates, cachedStates, profiles ->
|
) { pods, aapStates, cachedStates, profiles ->
|
||||||
// Live devices — BLE + AAP + cached fallback for missing fields
|
// Live devices — BLE + AAP + cached fallback for missing fields
|
||||||
val liveDevices = pods.map { pod ->
|
val liveDevices = pods.map { pod ->
|
||||||
val bondedAddress = pod.meta.profile?.address
|
|
||||||
val profile = pod.meta.profile
|
val profile = pod.meta.profile
|
||||||
PodDevice(
|
PodDevice(
|
||||||
profileId = profile?.id,
|
profileId = profile?.id,
|
||||||
label = profile?.label,
|
label = profile?.label,
|
||||||
ble = pod,
|
ble = pod,
|
||||||
aap = bondedAddress?.let { aapStates[it] },
|
aap = aapStates.forProfile(profile),
|
||||||
cached = profile?.id?.let { cachedStates[it] },
|
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 liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
|
||||||
val cachedOnlyDevices = profiles
|
val cachedOnlyDevices = profiles
|
||||||
.filter { it.id !in liveProfileIds }
|
.filter { it.id !in liveProfileIds }
|
||||||
.mapNotNull { profile ->
|
.mapNotNull { profile ->
|
||||||
cachedStates[profile.id]?.let {
|
cachedStates[profile.id]?.let { cached ->
|
||||||
PodDevice(profileId = profile.id, label = profile.label, ble = null, aap = null, cached = it)
|
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
|
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 {
|
companion object {
|
||||||
private val TAG = logTag("DeviceMonitor")
|
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