Break cache feedback loops in monitor and reactions

This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 6825abaa41
commit 71ba531025
7 changed files with 307 additions and 16 deletions
@@ -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)" }
@@ -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")
}