diff --git a/app/src/main/java/eu/darken/capod/App.kt b/app/src/main/java/eu/darken/capod/App.kt index 92caef01..94e54202 100644 --- a/app/src/main/java/eu/darken/capod/App.kt +++ b/app/src/main/java/eu/darken/capod/App.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index d6936300..cc2e6b36 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -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) { + 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)" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt deleted file mode 100644 index e4df4b40..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt +++ /dev/null @@ -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 = 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") - } -} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 38f0e287..3661860d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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" } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt deleted file mode 100644 index aeccf0ef..00000000 --- a/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt +++ /dev/null @@ -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> - private lateinit var cachedStatesFlow: MutableStateFlow> - - 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() - } - } -}