mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user