refactor: Merge device state persistence into DeviceMonitor

Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService.
This commit is contained in:
darken
2026-04-02 16:36:12 +02:00
parent 24a84a49f1
commit 942e03af36
5 changed files with 68 additions and 256 deletions
+1
View File
@@ -13,6 +13,7 @@ import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CoroutineScope
@@ -1,12 +1,23 @@
package eu.darken.capod.monitor.core
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
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,10 +26,12 @@ import javax.inject.Singleton
* ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects.
*
* Includes cached-only devices for profiles that have cached state but no live BLE data.
* Persists live device state to [DeviceStateCache] as a side effect.
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/
@Singleton
class DeviceMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
private val deviceStateCache: DeviceStateCache,
@@ -55,6 +68,58 @@ class DeviceMonitor @Inject constructor(
liveDevices + cachedOnlyDevices
}
.onEach { devices -> persistLiveDevices(devices) }
.replayingShare(appScope)
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
for (device in devices) {
if (!device.isLive) continue
val profileId = device.profileId ?: continue
val now = Instant.now()
val existing = deviceStateCache.cachedStates.value[profileId]
val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent
val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue
val newState = CachedDeviceState(
profileId = profileId,
model = device.model,
address = device.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,
isLeftCharging = device.isLeftPodCharging,
isRightCharging = device.isRightPodCharging,
isCaseCharging = device.isCaseCharging,
isHeadsetCharging = device.isHeadsetBeingCharged,
lastSeenAt = device.seenLastAt ?: now,
)
if (isSameState(existing, newState)) continue
log(TAG, VERBOSE) { "Persisting state for $profileId (L=$liveLeft R=$liveRight C=$liveCase H=$liveHeadset)" }
deviceStateCache.save(profileId, newState)
}
}
private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean {
if (old == null) return false
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false
return old.left?.percent == new.left?.percent
&& old.right?.percent == new.right?.percent
&& old.case?.percent == new.case?.percent
&& old.headset?.percent == new.headset?.percent
&& old.isLeftCharging == new.isLeftCharging
&& old.isRightCharging == new.isRightCharging
&& old.isCaseCharging == new.isCaseCharging
&& old.isHeadsetCharging == new.isHeadsetCharging
}
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
@@ -1,86 +0,0 @@
package eu.darken.capod.monitor.core
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.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import kotlinx.coroutines.flow.Flow
import java.time.Duration
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists combined device state (battery, charging, model) to [DeviceStateCache].
* Reads from raw BLE/AAP sources to avoid re-persisting cached values.
*/
@Singleton
class DeviceStatePersister @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val deviceStateCache: DeviceStateCache,
) {
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices ->
for (device in devices) {
if (!device.isLive) continue
val profileId = device.profileId ?: continue
val now = Instant.now()
val existing = deviceStateCache.cachedStates.value[profileId]
val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent
val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
// At least one live battery must be non-null to be worth persisting
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue
val newState = CachedDeviceState(
profileId = profileId,
model = device.model,
address = device.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,
isLeftCharging = device.isLeftPodCharging,
isRightCharging = device.isRightPodCharging,
isCaseCharging = device.isCaseCharging,
isHeadsetCharging = device.isHeadsetBeingCharged,
lastSeenAt = device.seenLastAt ?: now,
)
if (isSameState(existing, newState)) continue
log(TAG, VERBOSE) { "Persisting state for $profileId (L=${liveLeft} R=${liveRight} C=${liveCase} H=${liveHeadset})" }
deviceStateCache.save(profileId, newState)
}
}
.map { }
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean {
if (old == null) return false
// Update lastSeenAt periodically so the staleness label stays fresh when device goes offline
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false
return old.left?.percent == new.left?.percent
&& old.right?.percent == new.right?.percent
&& old.case?.percent == new.case?.percent
&& old.headset?.percent == new.headset?.percent
&& old.isLeftCharging == new.isLeftCharging
&& old.isRightCharging == new.isRightCharging
&& old.isCaseCharging == new.isCaseCharging
&& old.isHeadsetCharging == new.isHeadsetCharging
}
companion object {
private val TAG = logTag("Monitor", "DeviceStatePersister")
}
}
@@ -36,7 +36,7 @@ import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.reaction.core.aap.AapAutoConnect
import eu.darken.capod.monitor.core.DeviceStatePersister
import eu.darken.capod.monitor.core.aap.AapKeyPersister
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
@@ -78,7 +78,7 @@ class MonitorService : Service() {
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapAutoConnect: AapAutoConnect
@Inject lateinit var aapKeyPersister: AapKeyPersister
@Inject lateinit var deviceStatePersister: DeviceStatePersister
@Inject lateinit var aapConnectionManager: AapConnectionManager
private val monitorScope = MonitorCoroutineScope()
@@ -309,11 +309,6 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
deviceStatePersister.monitor()
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
.catch { log(TAG, WARN) { "deviceStatePersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -1,163 +0,0 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.monitor.core.CachedDeviceState
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.DeviceStateCache
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.profiles.core.AppleDeviceProfile
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceStatePersisterTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var deviceStateCache: DeviceStateCache
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var cachedStatesFlow: MutableStateFlow<Map<String, CachedDeviceState>>
private val testProfile = AppleDeviceProfile(
id = "test-profile",
label = "Test AirPods",
model = PodModel.AIRPODS_PRO3,
address = "AA:BB:CC:DD:EE:FF",
)
@BeforeEach
fun setup() {
devicesFlow = MutableStateFlow(emptyList())
cachedStatesFlow = MutableStateFlow(emptyMap())
deviceMonitor = mockk {
every { devices } returns devicesFlow
}
deviceStateCache = mockk(relaxed = true) {
every { cachedStates } returns cachedStatesFlow
}
}
private fun createPersister() = DeviceStatePersister(
deviceMonitor = deviceMonitor,
deviceStateCache = deviceStateCache,
)
private fun createLiveDevice(
leftBattery: Float? = 0.8f,
rightBattery: Float? = 0.7f,
caseBattery: Float? = 0.5f,
): PodDevice {
val bleMeta = ApplePods.AppleMeta(profile = testProfile)
val blePod: DualApplePods = mockk(relaxed = true) {
every { meta } returns bleMeta
every { batteryLeftPodPercent } returns leftBattery
every { batteryRightPodPercent } returns rightBattery
every { batteryCasePercent } returns caseBattery
every { isLeftPodCharging } returns false
every { isRightPodCharging } returns false
every { isCaseCharging } returns false
every { model } returns PodModel.AIRPODS_PRO3
every { address } returns "5A:3B:1C:2D:4E:6F"
every { seenLastAt } returns Instant.now()
}
return PodDevice(profileId = "test-profile", ble = blePod, aap = null, cached = null)
}
@Nested
inner class Persistence {
@Test
fun `persists state for live device with battery`() = runTest(testDispatcher) {
val persister = createPersister()
val job = launch { persister.monitor().toList() }
devicesFlow.value = listOf(createLiveDevice())
advanceUntilIdle()
coVerify(exactly = 1) { deviceStateCache.save("test-profile", any()) }
job.cancel()
}
@Test
fun `skips device with all null live batteries`() = runTest(testDispatcher) {
val persister = createPersister()
val job = launch { persister.monitor().toList() }
devicesFlow.value = listOf(createLiveDevice(leftBattery = null, rightBattery = null, caseBattery = null))
advanceUntilIdle()
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
job.cancel()
}
@Test
fun `skips cached-only devices`() = runTest(testDispatcher) {
val persister = createPersister()
val cachedOnlyDevice = PodDevice(
profileId = "test-profile", ble = null,
aap = null,
cached = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()),
lastSeenAt = Instant.now(),
),
)
val job = launch { persister.monitor().toList() }
devicesFlow.value = listOf(cachedOnlyDevice)
advanceUntilIdle()
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
job.cancel()
}
@Test
fun `skips write when values unchanged`() = runTest(testDispatcher) {
val persister = createPersister()
val existingCached = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, Instant.now()),
right = CachedDeviceState.CachedBatterySlot(0.7f, Instant.now()),
case = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
isHeadsetCharging = false,
lastSeenAt = Instant.now(),
)
cachedStatesFlow.value = mapOf("test-profile" to existingCached)
val job = launch { persister.monitor().toList() }
devicesFlow.value = listOf(createLiveDevice())
advanceUntilIdle()
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
job.cancel()
}
}
}