feat: Add unified device state cache for persistent battery display

Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile.

Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached.
This commit is contained in:
darken
2026-04-02 13:56:09 +02:00
parent 19d61ca5cb
commit e91243e577
20 changed files with 902 additions and 231 deletions
@@ -155,7 +155,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `devices passed through when permissions granted`() = runTest(testDispatcher) {
val device = PodDevice(ble = mockk(relaxed = true), aap = null)
val device = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
devicesFlow.value = listOf(device)
val vm = createViewModel()
@@ -166,18 +166,14 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `profiledDevices returns only devices with non-null profile`() {
val withProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = null
}
val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile },
profileId = "test-id",
ble = mockk(relaxed = true),
aap = null,
)
val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile },
profileId = null,
ble = mockk(relaxed = true),
aap = null,
)
@@ -197,18 +193,14 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `unmatchedDevices returns only devices with null profile`() {
val withProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = null
}
val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile },
profileId = "test-id",
ble = mockk(relaxed = true),
aap = null,
)
val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile },
profileId = null,
ble = mockk(relaxed = true),
aap = null,
)
@@ -0,0 +1,201 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
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 PodDeviceCacheTest : BaseTest() {
private val fiveMinAgo = Instant.parse("2026-03-31T11:55:00Z")
private val oneHourAgo = Instant.parse("2026-03-31T11:00:00Z")
private val cachedState = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
address = "AA:BB:CC:DD:EE:FF",
left = CachedDeviceState.CachedBatterySlot(0.8f, fiveMinAgo),
right = CachedDeviceState.CachedBatterySlot(0.7f, fiveMinAgo),
case = CachedDeviceState.CachedBatterySlot(0.5f, oneHourAgo),
headset = null,
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = true,
lastSeenAt = fiveMinAgo,
)
/**
* DualApplePods extends DualBlePodSnapshot, HasCase, HasChargeDetectionDual, etc.
* Using it as the mock type ensures all interface casts in PodDevice work correctly.
*/
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
}
@Nested
inner class CacheFallback {
@Test
fun `battery falls back to cache when live sources are null`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.batteryLeft shouldBe 0.8f
device.batteryRight shouldBe 0.7f
device.batteryCase shouldBe 0.5f
device.batteryHeadset.shouldBeNull()
}
@Test
fun `live BLE takes precedence over cache`() {
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.6f, caseBattery = 0.3f),
aap = null,
cached = cachedState,
)
device.batteryLeft shouldBe 0.9f
device.batteryRight shouldBe 0.6f
device.batteryCase shouldBe 0.3f
}
@Test
fun `live AAP takes precedence over both BLE and cache`() {
val aapState = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.95f, AapPodState.ChargingState.NOT_CHARGING),
)
)
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.5f),
aap = aapState,
cached = cachedState,
)
device.batteryLeft shouldBe 0.95f // AAP wins
device.batteryRight shouldBe 0.7f // BLE null -> cache
device.batteryCase shouldBe 0.5f // BLE null -> cache
}
@Test
fun `charging falls back to cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.isLeftPodCharging shouldBe false
device.isCaseCharging shouldBe true
}
@Test
fun `model falls back to cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.model shouldBe PodModel.AIRPODS_PRO3
}
@Test
fun `seenLastAt falls back to cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.seenLastAt shouldBe fiveMinAgo
}
@Test
fun `profileId falls back to cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.profileId shouldBe "test-profile"
}
}
@Nested
inner class IsLive {
@Test
fun `isLive true when BLE present`() {
val device = PodDevice(profileId = "test-profile", ble = mockDualPod(), aap = null, cached = cachedState)
device.isLive shouldBe true
}
@Test
fun `isLive true when AAP present`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = AapPodState(), cached = cachedState)
device.isLive shouldBe true
}
@Test
fun `isLive false when only cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.isLive shouldBe false
}
}
@Nested
inner class StalenessDetection {
@Test
fun `isBatteryCached true when all live null and cache has values`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.isBatteryCached shouldBe true
}
@Test
fun `isBatteryCached false when all live sources have data`() {
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f),
aap = null,
cached = cachedState,
)
device.isBatteryCached shouldBe false
}
@Test
fun `isBatteryCached true when BLE present but pod batteries are null`() {
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f),
aap = null,
cached = cachedState,
)
device.isBatteryCached shouldBe true
}
@Test
fun `isBatteryCached false when no cache`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = null)
device.isBatteryCached shouldBe false
}
@Test
fun `cachedBatteryAt returns oldest cached slot timestamp`() {
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
device.cachedBatteryAt shouldBe oneHourAgo
}
@Test
fun `cachedBatteryAt null when live data covers all slots`() {
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f),
aap = null,
cached = cachedState,
)
device.cachedBatteryAt.shouldBeNull()
}
@Test
fun `cachedBatteryAt returns only timestamp of slots that fell through`() {
val device = PodDevice(
profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f),
aap = null,
cached = cachedState,
)
device.cachedBatteryAt.shouldNotBeNull()
device.cachedBatteryAt shouldBe fiveMinAgo
}
}
}
@@ -41,7 +41,7 @@ class PodDeviceTest : BaseTest() {
@Test
fun `BLE-only device exposes battery from BLE`() {
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null)
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = null)
device.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe false
}
@@ -49,7 +49,7 @@ class PodDeviceTest : BaseTest() {
@Test
fun `capabilities come from model features`() {
val device = PodDevice(
ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
profileId = null, ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
aap = null,
)
device.hasDualPods shouldBe true
@@ -61,7 +61,7 @@ class PodDeviceTest : BaseTest() {
@Test
fun `Beats Solo 3 has no dual pods or case`() {
val device = PodDevice(
ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 },
profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 },
aap = null,
)
device.hasDualPods shouldBe false
@@ -79,7 +79,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mockDualPod(), aap = aap)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
device.isAapConnected shouldBe true
device.ancMode.shouldNotBeNull()
device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
@@ -87,13 +87,13 @@ class PodDeviceTest : BaseTest() {
@Test
fun `ANC mode is null when not AAP connected`() {
val device = PodDevice(ble = mockDualPod(), aap = null)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null)
device.ancMode.shouldBeNull()
}
@Test
fun `null BLE gives UNKNOWN model`() {
val device = PodDevice(ble = null, aap = null)
val device = PodDevice(profileId = null, ble = null, aap = null)
device.model shouldBe PodModel.UNKNOWN
}
@@ -102,7 +102,7 @@ class PodDeviceTest : BaseTest() {
val id = BlePodSnapshot.Id()
val meta = mockk<BlePodSnapshot.Meta>(relaxed = true)
val device = PodDevice(
ble = mockk(relaxed = true) {
profileId = null, ble = mockk(relaxed = true) {
every { identifier } returns id
every { this@mockk.meta } returns meta
},
@@ -114,7 +114,7 @@ class PodDeviceTest : BaseTest() {
@Test
fun `identity properties null when BLE null`() {
val device = PodDevice(ble = null, aap = null)
val device = PodDevice(profileId = null, ble = null, aap = null)
device.identifier.shouldBeNull()
device.meta.shouldBeNull()
}
@@ -124,7 +124,7 @@ class PodDeviceTest : BaseTest() {
val now = Instant.now()
val earlier = now.minusSeconds(60)
val device = PodDevice(
ble = mockk(relaxed = true) {
profileId = null, ble = mockk(relaxed = true) {
every { seenLastAt } returns now
every { seenFirstAt } returns earlier
every { signalQuality } returns 0.75f
@@ -140,7 +140,7 @@ class PodDeviceTest : BaseTest() {
@Test
fun `signal timing defaults when BLE null`() {
val device = PodDevice(ble = null, aap = null)
val device = PodDevice(profileId = null, ble = null, aap = null)
device.seenLastAt.shouldBeNull()
device.seenFirstAt.shouldBeNull()
device.signalQuality shouldBe 0f
@@ -155,7 +155,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false
every { (this@mockk as HasCase).isCaseCharging } returns true
}
val device = PodDevice(ble = mock, aap = null)
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isLeftPodCharging shouldBe true
device.isRightPodCharging shouldBe false
device.isCaseCharging shouldBe true
@@ -170,7 +170,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
}
val device = PodDevice(ble = mock, aap = null)
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isLeftInEar shouldBe true
device.isRightInEar shouldBe false
device.isBeingWorn shouldBe false
@@ -192,7 +192,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isEitherPodInEar shouldBe true
}
@@ -203,7 +203,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
}
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isEitherPodInEar shouldBe true
}
@@ -222,7 +222,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftInEar shouldBe true
device.isRightInEar shouldBe false
}
@@ -242,7 +242,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftInEar shouldBe false
device.isRightInEar shouldBe true
}
@@ -261,7 +261,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isBeingWorn shouldBe true
}
@@ -279,7 +279,7 @@ class PodDeviceTest : BaseTest() {
),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isBeingWorn shouldBe false
}
@@ -301,7 +301,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftInEar shouldBe true
device.isRightInEar shouldBe false
}
@@ -322,7 +322,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), // AAP says LEFT
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftInEar shouldBe true // AAP wins
device.isRightInEar shouldBe false
}
@@ -343,7 +343,7 @@ class PodDeviceTest : BaseTest() {
// No PrimaryPod setting — falls back to BLE
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftInEar shouldBe false
device.isRightInEar shouldBe true // BLE says RIGHT is primary
}
@@ -361,7 +361,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftPodMicrophone shouldBe true // AAP says LEFT
device.isRightPodMicrophone shouldBe false
}
@@ -374,7 +374,7 @@ class PodDeviceTest : BaseTest() {
every { isRightPodMicrophone } returns true
}
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftPodMicrophone shouldBe false
device.isRightPodMicrophone shouldBe true // BLE fallback
}
@@ -394,7 +394,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.RIGHT),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftPodMicrophone shouldBe false
device.isRightPodMicrophone shouldBe true
}
@@ -414,7 +414,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftPodMicrophone shouldBe true
device.isRightPodMicrophone shouldBe false
}
@@ -425,27 +425,27 @@ class PodDeviceTest : BaseTest() {
connectionState = AapPodState.ConnectionState.READY,
pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE,
)
val device = PodDevice(ble = mockDualPod(), aap = aap)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
@Test
fun `pendingAncMode null when no AAP`() {
val device = PodDevice(ble = mockDualPod(), aap = null)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null)
device.pendingAncMode.shouldBeNull()
}
@Test
fun `pendingAncMode null when not set`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(ble = mockDualPod(), aap = aap)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
device.pendingAncMode.shouldBeNull()
}
@Test
fun `icon and label properties delegate to BLE`() {
val device = PodDevice(
ble = mockk(relaxed = true) {
profileId = null, ble = mockk(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { iconRes } returns 42
},
@@ -456,14 +456,14 @@ class PodDeviceTest : BaseTest() {
@Test
fun `rawDataHex empty when BLE null`() {
val device = PodDevice(ble = null, aap = null)
val device = PodDevice(profileId = null, ble = null, aap = null)
device.rawDataHex shouldBe emptyList()
}
@Test
fun `battery falls back to BLE when AAP battery is null`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap)
device.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe true
}
@@ -476,7 +476,7 @@ class PodDeviceTest : BaseTest() {
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.79f, AapPodState.ChargingState.NOT_CHARGING),
),
)
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap)
device.batteryLeft shouldBe 0.79f // AAP 1% granularity wins over BLE 10%
}
@@ -492,7 +492,7 @@ class PodDeviceTest : BaseTest() {
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
),
)
val device = PodDevice(ble = mock, aap = aap)
val device = PodDevice(profileId = null, ble = mock, aap = aap)
device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging
}
@@ -508,7 +508,7 @@ class PodDeviceTest : BaseTest() {
every { this@mockk.address } returns bleRpa
every { meta } returns ApplePods.AppleMeta(profile = profile)
}
val device = PodDevice(ble = ble, aap = null)
val device = PodDevice(profileId = null, ble = ble, aap = null)
device.address shouldBe bondedAddress
device.bleAddress shouldBe bleRpa
}
@@ -520,7 +520,7 @@ class PodDeviceTest : BaseTest() {
every { model } returns PodModel.AIRPODS_PRO3
every { signalQuality } returns bleQuality
}
return PodDevice(ble = ble, aap = aap)
return PodDevice(profileId = null, ble = ble, aap = aap)
}
@Test
@@ -627,13 +627,13 @@ class PodDeviceTest : BaseTest() {
@Test
fun `bleKeyState - null BLE returns NONE`() {
val device = PodDevice(ble = null, aap = null)
val device = PodDevice(profileId = null, ble = null, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@Test
fun `bleKeyState - non-Apple BLE returns NONE`() {
val device = PodDevice(ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null)
val device = PodDevice(profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@@ -644,7 +644,7 @@ class PodDeviceTest : BaseTest() {
every { meta } returns ApplePods.AppleMeta(isIRKMatch = false)
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
}
val device = PodDevice(ble = ble, aap = null)
val device = PodDevice(profileId = null, ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@@ -655,7 +655,7 @@ class PodDeviceTest : BaseTest() {
every { meta } returns ApplePods.AppleMeta(isIRKMatch = true)
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
}
val device = PodDevice(ble = ble, aap = null)
val device = PodDevice(profileId = null, ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.IRK_ONLY
}
@@ -669,7 +669,7 @@ class PodDeviceTest : BaseTest() {
private = ProximityPayload.Private(UByteArray(8)),
)
}
val device = PodDevice(ble = ble, aap = null)
val device = PodDevice(profileId = null, ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
}
}
@@ -0,0 +1,163 @@
package eu.darken.capod.reaction.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()
}
}
}