From 7c378540765c9ef92aad6effd57519e2f5a90950 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 16:46:56 +0200 Subject: [PATCH] refactor: Extract toCachedState extension and add tests Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter. --- .../capod/monitor/core/DeviceMonitor.kt | 51 +------ .../monitor/core/DeviceMonitorExtensions.kt | 1 + .../eu/darken/capod/monitor/core/PodSorter.kt | 9 -- .../core/cache/DeviceStateCacheExtensions.kt | 63 ++++++++ .../monitor/core/cache/ToCachedStateTest.kt | 138 ++++++++++++++++++ 5 files changed, 205 insertions(+), 57 deletions(-) delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt 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 b32d7276..9b970bb8 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 @@ -6,21 +6,15 @@ 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.ble.BlePodMonitor -import eu.darken.capod.monitor.core.cache.CachedDeviceState -import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot import eu.darken.capod.monitor.core.cache.DeviceStateCache +import eu.darken.capod.monitor.core.cache.toCachedState 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 @@ -76,54 +70,15 @@ class DeviceMonitor @Inject constructor( 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 newState = device.toCachedState(existing) ?: continue - 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)" } + log(TAG, VERBOSE) { "Persisting state for $profileId" } 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/DeviceMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt index 04f8567d..eed95c24 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt @@ -70,3 +70,4 @@ fun PodDevice.cachedBatteryFormatted(now: Instant): String { ) } } + diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt deleted file mode 100644 index 4722c106..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eu.darken.capod.monitor.core - -import dagger.Reusable -import javax.inject.Inject - -@Reusable -class PodSorter @Inject constructor( - -) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt new file mode 100644 index 00000000..617a3531 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -0,0 +1,63 @@ +package eu.darken.capod.monitor.core.cache + +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.cache.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 java.time.Duration +import java.time.Instant + +/** + * Creates a [CachedDeviceState] from this live device's raw BLE/AAP battery data. + * Returns null if: + * - The device is not live (cached-only) + * - The device has no profile + * - All live battery values are null + * - The state hasn't changed from [existing] (dedup) + */ +fun PodDevice.toCachedState( + existing: CachedDeviceState?, + now: Instant = Instant.now(), +): CachedDeviceState? { + if (!isLive) return null + val pid = profileId ?: return null + + val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent + val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent + val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + + if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null + + val newState = CachedDeviceState( + 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, + isLeftCharging = isLeftPodCharging, + isRightCharging = isRightPodCharging, + isCaseCharging = isCaseCharging, + isHeadsetCharging = isHeadsetBeingCharged, + lastSeenAt = seenLastAt ?: now, + ) + + if (existing != null && !hasStateChanged(existing, newState)) return null + + return newState +} + +private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean { + if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true + 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 +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt new file mode 100644 index 00000000..c6c8aef6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt @@ -0,0 +1,138 @@ +package eu.darken.capod.monitor.core.cache + +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class ToCachedStateTest : BaseTest() { + + private val now = Instant.parse("2026-04-02T12:00:00Z") + + private fun mockDualPod( + leftBattery: Float? = null, + rightBattery: Float? = null, + caseBattery: Float? = null, + ): DualApplePods = mockk(relaxed = true) { + every { batteryLeftPodPercent } returns leftBattery + every { batteryRightPodPercent } returns rightBattery + every { batteryCasePercent } returns caseBattery + every { model } returns PodModel.AIRPODS_PRO3 + every { seenLastAt } returns now + } + + private fun liveDevice( + leftBattery: Float? = 0.8f, + rightBattery: Float? = 0.7f, + caseBattery: Float? = 0.5f, + ) = PodDevice( + profileId = "test-profile", + ble = mockDualPod(leftBattery, rightBattery, caseBattery), + aap = null, + ) + + @Nested + inner class Creates { + + @Test + fun `creates cached state from live device with battery`() { + val result = liveDevice().toCachedState(existing = null, now = now) + result.shouldNotBeNull() + result.profileId shouldBe "test-profile" + result.left?.percent shouldBe 0.8f + result.right?.percent shouldBe 0.7f + result.case?.percent shouldBe 0.5f + result.lastSeenAt shouldBe now + } + + @Test + fun `preserves existing slots when live value is null`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.9f, now.minusSeconds(60)), + lastSeenAt = now.minusSeconds(120), + ) + val result = liveDevice(leftBattery = null, caseBattery = 0.6f).toCachedState(existing, now) + result.shouldNotBeNull() + result.left?.percent shouldBe 0.9f + result.case?.percent shouldBe 0.6f + } + } + + @Nested + inner class Skips { + + @Test + fun `returns null for cached-only device`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = mockk(relaxed = true)) + device.toCachedState(existing = null, now = now).shouldBeNull() + } + + @Test + fun `returns null when no profile`() { + val device = PodDevice(profileId = null, ble = mockDualPod(caseBattery = 0.5f), aap = null) + device.toCachedState(existing = null, now = now).shouldBeNull() + } + + @Test + fun `returns null when all battery values null`() { + liveDevice(leftBattery = null, rightBattery = null, caseBattery = null) + .toCachedState(existing = null, now = now) + .shouldBeNull() + } + + @Test + fun `returns null when state unchanged`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.8f, now), + right = CachedDeviceState.CachedBatterySlot(0.7f, now), + case = CachedDeviceState.CachedBatterySlot(0.5f, now), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + isHeadsetCharging = false, + lastSeenAt = now, + ) + liveDevice().toCachedState(existing, now).shouldBeNull() + } + + @Test + fun `returns new state when battery changed`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.9f, now), + lastSeenAt = now, + ) + liveDevice(leftBattery = 0.8f).toCachedState(existing, now).shouldNotBeNull() + } + + @Test + fun `returns new state when lastSeenAt drifted over 1 minute`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.8f, now.minusSeconds(120)), + right = CachedDeviceState.CachedBatterySlot(0.7f, now.minusSeconds(120)), + case = CachedDeviceState.CachedBatterySlot(0.5f, now.minusSeconds(120)), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + isHeadsetCharging = false, + lastSeenAt = now.minusSeconds(120), + ) + liveDevice().toCachedState(existing, now).shouldNotBeNull() + } + } +}