mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Break cache feedback loops in monitor and reactions
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import androidx.core.util.forEach
|
||||
|
||||
fun BluetoothAddress.redactedForLogs(): String {
|
||||
val parts = split(':')
|
||||
if (parts.size < 2) return takeLast(5)
|
||||
return "XX:XX:XX:XX:${parts.takeLast(2).joinToString(":")}"
|
||||
}
|
||||
|
||||
fun BleScanResult.logSummary(): String {
|
||||
val payloadSummary = manufacturerSpecificData.entries
|
||||
.sortedBy { it.key }
|
||||
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
||||
.ifEmpty { "-" }
|
||||
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
||||
}
|
||||
|
||||
@JvmName("logBleScanResultCollectionSummary")
|
||||
fun Collection<BleScanResult>.logSummary(limit: Int = 3): String {
|
||||
if (isEmpty()) return "count=0"
|
||||
val sample = take(limit).joinToString(separator = ", ") { it.logSummary() }
|
||||
val suffix = if (size > limit) ", ..." else ""
|
||||
return "count=$size, sample=[$sample$suffix]"
|
||||
}
|
||||
|
||||
fun ScanResult.logSummary(): String {
|
||||
val payloadSummary = buildList {
|
||||
scanRecord?.manufacturerSpecificData?.forEach { manufacturerId, data ->
|
||||
add("$manufacturerId:${data.size}B")
|
||||
}
|
||||
}
|
||||
.sorted()
|
||||
.joinToString(separator = ",")
|
||||
.ifEmpty { "-" }
|
||||
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
||||
}
|
||||
|
||||
@JvmName("logFrameworkScanResultCollectionSummary")
|
||||
fun Collection<ScanResult>.logSummary(limit: Int = 3): String {
|
||||
if (isEmpty()) return "count=0"
|
||||
val sample = take(limit).joinToString(separator = ", ") { it.logSummary() }
|
||||
val suffix = if (size > limit) ", ..." else ""
|
||||
return "count=$size, sample=[$sample$suffix]"
|
||||
}
|
||||
@@ -49,15 +49,14 @@ class DeviceMonitor @Inject constructor(
|
||||
aapLifecycleManager.start()
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
private val liveState: Flow<LiveMergeState> = combine(
|
||||
blePodMonitor.devices,
|
||||
aapManager.allStates,
|
||||
deviceStateCache.cachedStates,
|
||||
profilesRepo.profiles,
|
||||
) { pods, aapStates, cachedStates, profiles ->
|
||||
) { pods, aapStates, profiles ->
|
||||
val profilesById = profiles.associateBy { it.id }
|
||||
|
||||
// Live devices — BLE + AAP + cached fallback for missing fields
|
||||
// Live devices — BLE + AAP. Cache is merged later so cache writes don't feed back here.
|
||||
val liveDevices = pods.map { pod ->
|
||||
val profile = pod.meta.profile?.id?.let(profilesById::get)
|
||||
PodDevice(
|
||||
@@ -65,7 +64,6 @@ class DeviceMonitor @Inject constructor(
|
||||
label = profile?.label,
|
||||
ble = pod,
|
||||
aap = aapStates.forProfile(profile),
|
||||
cached = profile?.id?.let { cachedStates[it] },
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
@@ -73,6 +71,25 @@ class DeviceMonitor @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
LiveMergeState(
|
||||
liveDevices = liveDevices,
|
||||
profiles = profiles,
|
||||
aapStates = aapStates,
|
||||
)
|
||||
}.onEach { liveState ->
|
||||
persistLiveDevices(liveState.liveDevices)
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
liveState,
|
||||
deviceStateCache.cachedStates,
|
||||
) { liveState, cachedStates ->
|
||||
val liveDevices = liveState.liveDevices.map { device ->
|
||||
device.copy(cached = device.profileId?.let { cachedStates[it] })
|
||||
}
|
||||
val profiles = liveState.profiles
|
||||
val aapStates = liveState.aapStates
|
||||
|
||||
// Collapse live duplicates sharing an identity-backed profile — e.g. when the legacy
|
||||
// signal-quality fallback misattributed ambient strangers to our profile.
|
||||
// Only applied when the group has at least one IRK-verified candidate; for no-IRK
|
||||
@@ -149,21 +166,25 @@ class DeviceMonitor @Inject constructor(
|
||||
}
|
||||
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}
|
||||
.onEach { devices -> persistLiveDevices(devices) }
|
||||
.replayingShare(appScope)
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.toCachedState(existing, timeSource.now()) ?: continue
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
}
|
||||
}
|
||||
|
||||
private data class LiveMergeState(
|
||||
val liveDevices: List<PodDevice>,
|
||||
val profiles: List<DeviceProfile>,
|
||||
val aapStates: Map<BluetoothAddress, AapPodState>,
|
||||
)
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
|
||||
+16
-4
@@ -37,10 +37,10 @@ fun PodDevice.toCachedState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
address = address,
|
||||
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
|
||||
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
|
||||
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
|
||||
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
|
||||
left = mergeBatterySlot(liveLeft, existing?.left, now),
|
||||
right = mergeBatterySlot(liveRight, existing?.right, now),
|
||||
case = mergeBatterySlot(liveCase, existing?.case, now),
|
||||
headset = mergeBatterySlot(liveHeadset, existing?.headset, now),
|
||||
isLeftCharging = isLeftPodCharging,
|
||||
isRightCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
@@ -56,6 +56,18 @@ fun PodDevice.toCachedState(
|
||||
return newState
|
||||
}
|
||||
|
||||
private fun mergeBatterySlot(
|
||||
livePercent: Float?,
|
||||
existing: CachedBatterySlot?,
|
||||
now: Instant,
|
||||
): CachedBatterySlot? {
|
||||
if (livePercent == null) return existing
|
||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
||||
|
||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
||||
|
||||
@@ -29,9 +29,11 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
@@ -46,7 +48,7 @@ import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -189,7 +191,7 @@ class MonitorService : Service() {
|
||||
|
||||
val monitorJob = deviceMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||
.distinctUntilChanged()
|
||||
.distinctUntilChangedBy { it?.toNotificationKey() }
|
||||
.throttleLatest(1000)
|
||||
.onEach { currentDevice ->
|
||||
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
|
||||
@@ -337,3 +339,51 @@ class MonitorService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
val model: PodModel,
|
||||
val hasDualPods: Boolean,
|
||||
val hasCase: Boolean,
|
||||
val hasEarDetection: Boolean,
|
||||
val batteryLeft: Float?,
|
||||
val batteryRight: Float?,
|
||||
val batteryCase: Float?,
|
||||
val batteryHeadset: Float?,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
val isHeadsetBeingCharged: Boolean?,
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val iconRes: Int,
|
||||
val leftPodIcon: Int,
|
||||
val rightPodIcon: Int,
|
||||
val caseIcon: Int,
|
||||
)
|
||||
|
||||
private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationDeviceKey(
|
||||
profileId = profileId,
|
||||
label = label,
|
||||
model = model,
|
||||
hasDualPods = hasDualPods,
|
||||
hasCase = hasCase,
|
||||
hasEarDetection = hasEarDetection,
|
||||
batteryLeft = batteryLeft,
|
||||
batteryRight = batteryRight,
|
||||
batteryCase = batteryCase,
|
||||
batteryHeadset = batteryHeadset,
|
||||
isLeftPodCharging = isLeftPodCharging,
|
||||
isRightPodCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetBeingCharged = isHeadsetBeingCharged,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
iconRes = iconRes,
|
||||
leftPodIcon = leftPodIcon,
|
||||
rightPodIcon = rightPodIcon,
|
||||
caseIcon = caseIcon,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import eu.darken.capod.common.bluetooth.redactedForLogs
|
||||
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import java.util.Locale
|
||||
|
||||
private fun String.shortIdForLogs(): String = take(8)
|
||||
|
||||
fun DeviceProfile.logSummary(): String = buildString {
|
||||
append("profile(id=")
|
||||
append(id.shortIdForLogs())
|
||||
append(", model=")
|
||||
append(model)
|
||||
append(", addr=")
|
||||
append(address?.redactedForLogs() ?: "-")
|
||||
append(')')
|
||||
}
|
||||
|
||||
fun BlePodSnapshot.logSummary(): String = buildString {
|
||||
append("pod(model=")
|
||||
append(model)
|
||||
append(", id=")
|
||||
append(identifier.toString().shortIdForLogs())
|
||||
append(", profile=")
|
||||
append(meta.profile?.id?.shortIdForLogs() ?: "-")
|
||||
append(", addr=")
|
||||
append(address.redactedForLogs())
|
||||
append(", rssi=")
|
||||
append(rssi)
|
||||
append(", reliability=")
|
||||
append(String.format(Locale.US, "%.2f", reliability))
|
||||
append(')')
|
||||
}
|
||||
|
||||
fun KnownDevice.logSummary(): String = buildString {
|
||||
append("known(id=")
|
||||
append(id.toString().shortIdForLogs())
|
||||
append(", model=")
|
||||
append(history.last().model)
|
||||
append(", history=")
|
||||
append(history.size)
|
||||
append(", seen=")
|
||||
append(seenCounter)
|
||||
append(", addr=")
|
||||
append(lastAddress.redactedForLogs())
|
||||
append(')')
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.flow.withPrevious
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -50,7 +52,8 @@ class PlayPause @Inject constructor(
|
||||
deviceMonitor.primaryDevice()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
// Cache persistence can update battery timestamps without changing any reaction-relevant state.
|
||||
.distinctUntilChangedBy { it?.toPlayPauseMonitorKey() }
|
||||
.withPrevious()
|
||||
.filter { (previous, current) ->
|
||||
if (previous == null || current == null) return@filter false
|
||||
@@ -363,6 +366,35 @@ class PlayPause @Inject constructor(
|
||||
val stagedConfirmation: Boolean,
|
||||
)
|
||||
|
||||
internal data class PlayPauseMonitorKey(
|
||||
val profileId: String?,
|
||||
val autoPlay: Boolean,
|
||||
val autoPause: Boolean,
|
||||
val onePodMode: Boolean,
|
||||
val hasEarDetection: Boolean,
|
||||
val hasDualPods: Boolean,
|
||||
val leftInEar: Boolean?,
|
||||
val rightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val hasAapEarDetection: Boolean,
|
||||
val hasBleSnapshot: Boolean,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey =
|
||||
PlayPauseMonitorKey(
|
||||
profileId = profileId,
|
||||
autoPlay = reactions.autoPlay,
|
||||
autoPause = reactions.autoPause,
|
||||
onePodMode = reactions.onePodMode,
|
||||
hasEarDetection = hasEarDetection,
|
||||
hasDualPods = hasDualPods,
|
||||
leftInEar = isLeftInEar,
|
||||
rightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
hasAapEarDetection = aap?.aapEarDetection != null,
|
||||
hasBleSnapshot = ble != null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "PlayPause")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
@@ -17,12 +18,17 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -107,6 +113,24 @@ class DeviceMonitorTest : BaseTest() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockLiveDualBlePodWithProfile(
|
||||
profile: DeviceProfile,
|
||||
batteryLeft: Float = 0.9f,
|
||||
batteryRight: Float = 1.0f,
|
||||
): BlePodSnapshot {
|
||||
val bleMeta = object : BlePodSnapshot.Meta {
|
||||
override val profile: DeviceProfile? = profile
|
||||
}
|
||||
return mockk<DualBlePodSnapshot>(relaxed = true) {
|
||||
every { meta } returns bleMeta
|
||||
every { this@mockk.model } returns profile.model
|
||||
every { seenFirstAt } returns Instant.parse("2026-04-05T17:50:00Z")
|
||||
every { seenLastAt } returns Instant.parse("2026-04-05T18:00:00Z")
|
||||
every { batteryLeftPodPercent } returns batteryLeft
|
||||
every { batteryRightPodPercent } returns batteryRight
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMonitor(
|
||||
ble: List<BlePodSnapshot> = emptyList(),
|
||||
aap: Map<BluetoothAddress, AapPodState> = emptyMap(),
|
||||
@@ -508,4 +532,62 @@ class DeviceMonitorTest : BaseTest() {
|
||||
|
||||
devices.size shouldBe 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
|
||||
val bleFlow = MutableStateFlow(listOf(mockLiveDualBlePodWithProfile(testProfile)))
|
||||
val aapFlow = MutableStateFlow(emptyMap<BluetoothAddress, AapPodState>())
|
||||
val cacheFlow = MutableStateFlow<Map<String, CachedDeviceState>>(emptyMap())
|
||||
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(listOf(testProfile))
|
||||
|
||||
val blePodMonitor: BlePodMonitor = mockk {
|
||||
every { devices } returns bleFlow
|
||||
}
|
||||
val aapManager: AapConnectionManager = mockk {
|
||||
every { allStates } returns aapFlow
|
||||
}
|
||||
val deviceStateCache: DeviceStateCache = mockk(relaxed = true) {
|
||||
every { cachedStates } returns cacheFlow
|
||||
coEvery { load(any()) } answers { cacheFlow.value[firstArg<String>()] }
|
||||
coEvery { save(any(), any()) } answers {
|
||||
val profileId = firstArg<String>()
|
||||
val state = secondArg<CachedDeviceState>()
|
||||
cacheFlow.value += (profileId to state)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
val profilesRepo: DeviceProfilesRepo = mockk {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
|
||||
|
||||
val monitor = DeviceMonitor(
|
||||
appScope = backgroundScope,
|
||||
blePodMonitor = blePodMonitor,
|
||||
aapManager = aapManager,
|
||||
deviceStateCache = deviceStateCache,
|
||||
profilesRepo = profilesRepo,
|
||||
aapLifecycleManager = aapLifecycleManager,
|
||||
timeSource = timeSource,
|
||||
)
|
||||
|
||||
val collector: Job = backgroundScope.launch {
|
||||
monitor.devices.collect { }
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { deviceStateCache.save(testProfile.id, any()) }
|
||||
|
||||
val persisted = cacheFlow.value.getValue(testProfile.id)
|
||||
cacheFlow.value += testProfile.id to persisted.copy(
|
||||
left = CachedDeviceState.CachedBatterySlot(
|
||||
percent = 0.1f,
|
||||
updatedAt = persisted.left?.updatedAt ?: Instant.parse("2026-04-05T18:00:00Z"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { deviceStateCache.save(testProfile.id, any()) }
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user