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