fix(monitor): Prevent NPE in cache merge from freezing device flow

Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
This commit is contained in:
darken
2026-05-03 14:20:49 +02:00
committed by Matthias Urhahn
parent 82dc88faac
commit 7d93e772b4
6 changed files with 299 additions and 28 deletions
@@ -3,6 +3,8 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.cache.CachedDeviceState
@@ -22,6 +24,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
@@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
@@ -536,6 +540,96 @@ class DeviceMonitorTest : BaseTest() {
devices.size shouldBe 2
}
@AfterEach
fun resetBugsReporter() {
Bugs.reporter = null
}
/**
* Regression: a NPE in the cache merge path used to throw out of `onEach { persistLiveDevices }`,
* cancelling the upstream `combine` and freezing every downstream observer (overview, widgets,
* etc.) for the rest of the process lifetime. The persist loop now catches and reports.
*
* Throws from inside `toCachedState` (via a BLE getter) rather than from `save()` so the test
* locks in that the catch covers the actual NPE boundary, not just the cache I/O boundary.
*/
@Test
fun `flow keeps emitting after persist failure and only reports the bug once per profile`() =
runTest(testDispatcher) {
val reporter = mockk<AutomaticBugReporter>(relaxed = true)
Bugs.reporter = reporter
val bleFlow = MutableStateFlow(listOf(mockThrowingDualBlePodWithProfile(testProfile)))
val aapFlow = MutableStateFlow(emptyMap<BluetoothAddress, AapPodState>())
val cacheFlow = MutableStateFlow<Map<String, CachedDeviceState>>(emptyMap())
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(listOf(testProfile))
val blePodMonitor: BlePodMonitor = mockk { every { devices } returns bleFlow }
val aapManager: AapConnectionManager = mockk { every { allStates } returns aapFlow }
val deviceStateCache: DeviceStateCache = mockk(relaxed = true) {
every { cachedStates } returns cacheFlow
coEvery { load(any()) } answers { cacheFlow.value[firstArg<String>()] }
}
val profilesRepo: DeviceProfilesRepo = mockk { every { profiles } returns profilesFlow }
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
val bluetoothManager: BluetoothManager2 = mockk {
every { connectedDevices } returns MutableStateFlow(emptyList())
}
val monitor = DeviceMonitor(
appScope = backgroundScope,
blePodMonitor = blePodMonitor,
aapManager = aapManager,
bluetoothManager = bluetoothManager,
deviceStateCache = deviceStateCache,
profilesRepo = profilesRepo,
aapLifecycleManager = aapLifecycleManager,
timeSource = timeSource,
)
val received = mutableListOf<List<PodDevice>>()
val collector = backgroundScope.launch {
monitor.devices.collect { received += it }
}
advanceUntilIdle()
val initialEmissionCount = received.size
initialEmissionCount shouldNotBe 0
// Trigger more emissions; toCachedState keeps throwing inside the persist loop, but
// the flow must survive instead of cancelling its upstream combine.
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
advanceUntilIdle()
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
advanceUntilIdle()
// The flow survived: at least two more emissions arrived after the failing merge.
(received.size - initialEmissionCount) shouldBe 2
// The cache write must NOT have been attempted — the failure was upstream of save().
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
// Dedup: even though merge failed on every emission for the same profile, only one report.
verify(exactly = 1) { reporter.notify(any()) }
collector.cancel()
}
/**
* A live BLE pod whose battery getter throws on read. Used to simulate the NPE that R8/JIT
* was producing inside the cache merge path — the throw originates inside `toCachedState`,
* before `save()` is called.
*/
private fun mockThrowingDualBlePodWithProfile(profile: DeviceProfile): BlePodSnapshot {
val bleMeta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = profile
}
return mockk<DualBlePodSnapshot>(relaxed = true) {
every { meta } returns bleMeta
every { this@mockk.model } returns profile.model
every { seenFirstAt } returns Instant.parse("2026-04-05T17:50:00Z")
every { seenLastAt } returns Instant.parse("2026-04-05T18:00:00Z")
every { batteryLeftPodPercent } throws NullPointerException("synthetic merge failure")
}
}
@Test
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
val bleFlow = MutableStateFlow(listOf(mockLiveDualBlePodWithProfile(testProfile)))
@@ -71,4 +71,22 @@ class CachedDeviceStateMigrationTest : BaseTest() {
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
state.deviceInfo!!.marketingVersion shouldBe "8454480"
}
@Test
fun `deviceInfo is non-null when only new earbud-serial fields are present`() {
val state = CachedDeviceState(
profileId = "earbud-only",
model = PodModel.AIRPODS_PRO3,
leftEarbudSerial = "L-9",
rightEarbudSerial = "R-9",
marketingVersion = "8888",
lastSeenAt = java.time.Instant.ofEpochMilli(1767364074000L),
)
val info = state.deviceInfo!!
info.leftEarbudSerial shouldBe "L-9"
info.rightEarbudSerial shouldBe "R-9"
info.marketingVersion shouldBe "8888"
info.name shouldBe ""
}
}
@@ -2,6 +2,8 @@ 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.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -154,5 +156,119 @@ class ToCachedStateTest : BaseTest() {
.toCachedState(existing, now)
.shouldNotBeNull()
}
@Test
fun `returns new state when only earbud serial changes`() {
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,
deviceName = "AirPods",
serialNumber = "",
firmwareVersion = "",
leftEarbudSerial = "OLD-LEFT",
rightEarbudSerial = "R-1",
marketingVersion = "1234",
lastSeenAt = now,
)
val device = PodDevice(
profileId = "test-profile",
ble = mockDualPod(leftBattery = 0.8f, rightBattery = 0.7f, caseBattery = 0.5f),
aap = AapPodState(
deviceInfo = deviceInfo(
leftEarbudSerial = "NEW-LEFT",
rightEarbudSerial = "R-1",
marketingVersion = "1234",
),
),
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.leftEarbudSerial shouldBe "NEW-LEFT"
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "1234"
}
@Test
fun `returns new state when only marketing version changes`() {
val existing = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
isHeadsetCharging = false,
deviceName = "AirPods",
serialNumber = "",
firmwareVersion = "",
marketingVersion = "1234",
lastSeenAt = now,
)
val device = PodDevice(
profileId = "test-profile",
ble = mockDualPod(leftBattery = 0.8f),
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "5678")),
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.marketingVersion shouldBe "5678"
}
}
@Nested
inner class DeviceInfoOnly {
@Test
fun `persists DeviceInfo when no live battery is available`() {
// Mirror how DeviceMonitor.aapOnlyForPersistence builds an AAP-only PodDevice:
// profileModel and profileAddress carry the model/address since `ble` is null.
val device = PodDevice(
profileId = "test-profile",
ble = null,
aap = AapPodState(
deviceInfo = deviceInfo(
name = "Pro 3",
leftEarbudSerial = "L-1",
rightEarbudSerial = "R-1",
marketingVersion = "9999",
),
),
profileModel = PodModel.AIRPODS_PRO3,
profileAddress = "AA:BB:CC:DD:EE:FF",
)
val result = device.toCachedState(existing = null, now = now).shouldNotBeNull()
result.model shouldBe PodModel.AIRPODS_PRO3
result.address shouldBe "AA:BB:CC:DD:EE:FF"
result.deviceName shouldBe "Pro 3"
result.leftEarbudSerial shouldBe "L-1"
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "9999"
}
}
private fun deviceInfo(
name: String = "AirPods",
serialNumber: String = "",
firmwareVersion: String = "",
leftEarbudSerial: String? = null,
rightEarbudSerial: String? = null,
marketingVersion: String? = null,
) = AapDeviceInfo(
name = name,
modelNumber = "",
manufacturer = "",
serialNumber = serialNumber,
firmwareVersion = firmwareVersion,
leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial,
marketingVersion = marketingVersion,
)
}