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