fix: Eliminate Float? boxing from battery display and cache merge

Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.

Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
This commit is contained in:
darken
2026-05-06 11:54:25 +02:00
committed by Matthias Urhahn
parent e4e4190523
commit 985b73754e
14 changed files with 135 additions and 96 deletions
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.monitor.core.cache.CachedDeviceState
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.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
device.batteryLeft shouldBe 0.8f
device.batteryRight shouldBe 0.7f
device.batteryCase shouldBe 0.5f
device.batteryHeadset.shouldBeNull()
device.batteryHeadset shouldBe BATTERY_UNKNOWN
}
@Test
@@ -252,6 +252,37 @@ class ToCachedStateTest : BaseTest() {
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "9999"
}
@Test
fun `DeviceInfo-only update preserves existing slot timestamps (no live refresh)`() {
// Regression: if toCachedState consults the unified PodDevice.batteryX getter (which
// falls back to cache), an AAP-only DeviceInfo update would re-stamp every slot with
// `now`, refreshing stale cached readings indefinitely. Raw live extraction prevents
// that.
val oldStamp = now.minusSeconds(3600)
val existing = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, oldStamp),
right = CachedDeviceState.CachedBatterySlot(0.7f, oldStamp),
case = CachedDeviceState.CachedBatterySlot(0.5f, oldStamp),
marketingVersion = "OLD",
lastSeenAt = oldStamp,
)
val device = PodDevice(
profileId = "test-profile",
ble = null,
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "NEW")),
profileModel = PodModel.AIRPODS_PRO3,
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.marketingVersion shouldBe "NEW"
result.left?.updatedAt shouldBe oldStamp
result.right?.updatedAt shouldBe oldStamp
result.case?.updatedAt shouldBe oldStamp
result.left?.percent shouldBe 0.8f
}
}
private fun deviceInfo(