From 985b73754e11bb36fa807103442fcfbaf75354bd Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 6 May 2026 11:43:45 +0200 Subject: [PATCH] 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. --- .../main/ui/overview/cards/DualPodsCard.kt | 10 +++--- .../main/ui/overview/cards/SinglePodsCard.kt | 19 ++++++----- .../cards/components/CompactBatterySummary.kt | 32 ++++++++++--------- .../main/ui/widget/ComposeWidgetPreview.kt | 13 ++++---- .../main/ui/widget/GlanceWidgetContent.kt | 13 ++++---- .../capod/main/ui/widget/WidgetDeviceKey.kt | 8 ++--- .../main/ui/widget/WidgetRenderStateMapper.kt | 9 +++--- .../eu/darken/capod/monitor/core/PodDevice.kt | 25 +++++++++------ .../core/cache/DeviceStateCacheExtensions.kt | 27 +++++++++------- .../ui/MonitorNotificationViewFactory.kt | 7 ++-- .../core/apple/ble/PodDeviceExtensions.kt | 19 +++++------ .../capod/reaction/ui/popup/PopUpContent.kt | 15 ++++----- .../capod/monitor/core/PodDeviceCacheTest.kt | 3 +- .../monitor/core/cache/ToCachedStateTest.kt | 31 ++++++++++++++++++ 14 files changed, 135 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 36dedbac..df5472c6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -67,8 +67,6 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent -import eu.darken.capod.pods.core.apple.ble.toBatteryFloat -import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull import java.time.Instant @Composable @@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded( ) { PodGauge( iconRes = device.leftPodIcon, - batteryPercent = device.batteryLeft.toBatteryFloat(), + batteryPercent = device.batteryLeft, chargingState = device.leftPodChargingState ?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, isInEar = device.isLeftInEar ?: false, @@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded( PodGauge( iconRes = device.rightPodIcon, - batteryPercent = device.batteryRight.toBatteryFloat(), + batteryPercent = device.batteryRight, chargingState = device.rightPodChargingState ?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, isInEar = device.isRightInEar ?: false, @@ -360,7 +358,7 @@ private fun PodGauge( // Battery percentage Text( - text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()), + text = formatBatteryPercent(context, batteryPercent), style = MaterialTheme.typography.titleMedium, color = if (batteryPercent >= 0f) { MaterialTheme.colorScheme.onSurface @@ -412,7 +410,7 @@ private fun CaseRow( ) BatteryCapsule( - percent = device.batteryCase.toBatteryFloat(), + percent = device.batteryCase, modifier = Modifier .weight(1f) .height(8.dp), diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index d88c021f..456f2732 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.ble.batteryProgress import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent +import eu.darken.capod.pods.core.apple.ble.isKnownBattery import java.time.Instant @OptIn(ExperimentalLayoutApi::class) @@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded( ) { val context = LocalContext.current - val clamped = device.batteryHeadset?.coerceIn(0f, 1f) + val percent = device.batteryHeadset + val isKnown = isKnownBattery(percent) val animatedProgress by animateFloatAsState( - targetValue = clamped ?: 0f, + targetValue = batteryProgress(percent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "gaugeProgress", ) val ringColor = when { - clamped == null -> MaterialTheme.colorScheme.surfaceVariant - clamped > 0.30f -> MaterialTheme.colorScheme.primary - clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary + !isKnown -> MaterialTheme.colorScheme.surfaceVariant + percent > 0.30f -> MaterialTheme.colorScheme.primary + percent >= 0.15f -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.error } @@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded( ) // Progress ring - if (clamped != null) { + if (isKnown) { CircularProgressIndicator( progress = { animatedProgress }, modifier = Modifier.size(88.dp), @@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded( // Battery text inside ring Text( - text = formatBatteryPercent(context, device.batteryHeadset), + text = formatBatteryPercent(context, percent), style = MaterialTheme.typography.headlineSmall, - color = if (device.batteryHeadset != null) { + color = if (isKnown) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt index 25306fc3..2d9447e8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt @@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.darken.capod.R import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.ble.batteryProgress import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent +import eu.darken.capod.pods.core.apple.ble.isKnownBattery @Composable fun CompactBatterySummary( device: PodDevice, modifier: Modifier = Modifier, ) { - val hasAnyBattery = device.batteryLeft != null - || device.batteryRight != null - || device.batteryHeadset != null - || device.batteryCase != null + val hasAnyBattery = isKnownBattery(device.batteryLeft) + || isKnownBattery(device.batteryRight) + || isKnownBattery(device.batteryHeadset) + || isKnownBattery(device.batteryCase) Surface( modifier = modifier @@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) { percent = device.batteryRight, ) - if (device.hasCase && device.batteryCase != null) { + if (device.hasCase && isKnownBattery(device.batteryCase)) { Spacer(modifier = Modifier.weight(1f)) MiniCaseCluster(device = device) } @@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) { iconRes = null, percent = device.batteryHeadset, ) - if (device.hasCase && device.batteryCase != null) { + if (device.hasCase && isKnownBattery(device.batteryCase)) { Spacer(modifier = Modifier.weight(1f)) MiniCaseCluster(device = device) } @@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() { @Composable private fun MiniPodRing( iconRes: Int?, - percent: Float?, + percent: Float, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val clamped = percent?.coerceIn(0f, 1f) + val isKnown = isKnownBattery(percent) val animatedProgress by animateFloatAsState( - targetValue = clamped ?: 0f, + targetValue = batteryProgress(percent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "miniGaugeProgress", ) val ringColor = when { - clamped == null -> MaterialTheme.colorScheme.surfaceVariant - clamped > 0.30f -> MaterialTheme.colorScheme.primary - clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary + !isKnown -> MaterialTheme.colorScheme.surfaceVariant + percent > 0.30f -> MaterialTheme.colorScheme.primary + percent >= 0.15f -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.error } @@ -155,7 +157,7 @@ private fun MiniPodRing( trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeCap = StrokeCap.Round, ) - if (clamped != null) { + if (isKnown) { CircularProgressIndicator( progress = { animatedProgress }, modifier = Modifier.size(28.dp), @@ -177,7 +179,7 @@ private fun MiniPodRing( Text( text = formatBatteryPercent(context, percent), style = MaterialTheme.typography.titleSmall, - color = if (percent != null) { + color = if (isKnown) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -204,7 +206,7 @@ private fun MiniCaseCluster( ) Spacer(modifier = Modifier.width(6.dp)) BatteryCapsule( - percent = device.batteryCase ?: -1f, + percent = device.batteryCase, modifier = Modifier .width(36.dp) .height(6.dp), diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt index b471b4b4..b9e59276 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper -import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull +import eu.darken.capod.pods.core.apple.ble.isKnownBattery import kotlin.math.roundToInt @Composable @@ -160,7 +160,7 @@ private fun SinglePodPreview( colorFilter = iconTint, ) Text( - text = formatPercent(state.percent.toBatteryOrNull()), + text = formatPercent(state.percent), fontSize = 12.sp, color = textColor, modifier = Modifier.padding(horizontal = 8.dp), @@ -311,7 +311,7 @@ private fun PodItemRow( colorFilter = iconTint, ) Text( - text = formatPercent(percent.toBatteryOrNull()), + text = formatPercent(percent), fontSize = 12.sp, color = textColor, modifier = Modifier.padding(horizontal = 4.dp), @@ -354,7 +354,7 @@ private fun TinyPodItem( colorFilter = iconTint, ) Text( - text = formatPercent(percent.toBatteryOrNull()), + text = formatPercent(percent), fontSize = 12.sp, color = textColor, maxLines = 1, @@ -382,9 +382,8 @@ private fun DeviceLabel( } } -private fun formatPercent(percent: Float?): String { - return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—" -} +private fun formatPercent(percent: Float): String = + if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—" @Preview2 @Composable diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt index c731081e..9f1e212d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt @@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import eu.darken.capod.R import eu.darken.capod.main.ui.MainActivity -import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull +import eu.darken.capod.pods.core.apple.ble.isKnownBattery import kotlin.math.roundToInt @Composable @@ -122,7 +122,7 @@ private fun GlanceSinglePod( colorFilter = iconTint, ) Text( - text = formatGlancePercent(state.percent.toBatteryOrNull()), + text = formatGlancePercent(state.percent), style = textStyle, modifier = GlanceModifier.padding(horizontal = 8.dp), ) @@ -270,7 +270,7 @@ private fun GlancePodItem( colorFilter = iconTint, ) Text( - text = formatGlancePercent(percent.toBatteryOrNull()), + text = formatGlancePercent(percent), style = textStyle, modifier = GlanceModifier.padding(horizontal = 4.dp), ) @@ -312,7 +312,7 @@ private fun GlanceTinyPodItem( colorFilter = iconTint, ) Text( - text = formatGlancePercent(percent.toBatteryOrNull()), + text = formatGlancePercent(percent), style = textStyle, maxLines = 1, modifier = GlanceModifier.padding(start = 4.dp), @@ -341,6 +341,5 @@ private fun GlanceDeviceLabel( private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb)) -private fun formatGlancePercent(percent: Float?): String { - return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—" -} +private fun formatGlancePercent(percent: Float): String = + if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—" diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt index 59d9f626..f47d7969 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt @@ -14,10 +14,10 @@ internal data class WidgetDeviceKey( val profileId: String?, val profileLabel: String?, val model: PodModel, - val batteryLeft: Float?, - val batteryRight: Float?, - val batteryCase: Float?, - val batteryHeadset: Float?, + val batteryLeft: Float, + val batteryRight: Float, + val batteryCase: Float, + val batteryHeadset: Float, val isLeftPodCharging: Boolean?, val isRightPodCharging: Boolean?, val isCaseCharging: Boolean?, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt index 2d1f84e3..dfb9718c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt @@ -7,7 +7,6 @@ import eu.darken.capod.R import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable -import eu.darken.capod.pods.core.apple.ble.toBatteryFloat object WidgetRenderStateMapper { @@ -43,15 +42,15 @@ object WidgetRenderStateMapper { layout = layout, deviceLabel = profileLabel ?: device.getLabel(context), leftIcon = device.leftPodIcon, - leftPercent = device.batteryLeft.toBatteryFloat(), + leftPercent = device.batteryLeft, leftCharging = device.isLeftPodCharging == true, leftInEar = device.isLeftInEar == true, rightIcon = device.rightPodIcon, - rightPercent = device.batteryRight.toBatteryFloat(), + rightPercent = device.batteryRight, rightCharging = device.isRightPodCharging == true, rightInEar = device.isRightInEar == true, caseIcon = device.caseIcon, - casePercent = device.batteryCase.toBatteryFloat(), + casePercent = device.batteryCase, caseCharging = device.isCaseCharging == true, ) @@ -63,7 +62,7 @@ object WidgetRenderStateMapper { layout = layout, deviceLabel = profileLabel ?: device.getLabel(context), headsetIcon = device.iconRes, - percent = device.batteryHeadset.toBatteryFloat(), + percent = device.batteryHeadset, batteryIcon = getBatteryDrawable(device.batteryHeadset), charging = device.isHeadsetBeingCharged == true, worn = device.isBeingWorn == true, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 6b143a8a..94c77238 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -10,6 +10,7 @@ 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.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot @@ -138,18 +139,24 @@ data class PodDevice( } } - // Battery — AAP preferred, BLE fallback, then cached - val batteryLeft: Float? - get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent + // Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f) + // for unknown to keep the type primitive; this is the boundary that previously emitted + // Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites. + val batteryLeft: Float + get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + ?: cached?.left?.percent ?: BATTERY_UNKNOWN - val batteryRight: Float? - get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent + val batteryRight: Float + get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent + ?: cached?.right?.percent ?: BATTERY_UNKNOWN - val batteryCase: Float? - get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent + val batteryCase: Float + get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent + ?: cached?.case?.percent ?: BATTERY_UNKNOWN - val batteryHeadset: Float? - get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent + val batteryHeadset: Float + get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + ?: cached?.headset?.percent ?: BATTERY_UNKNOWN /** True when at least one displayed battery value was filled from cache (not live). */ val isBatteryCached: Boolean diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt index 74b84e30..71112b8a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache import eu.darken.capod.common.SystemTimeSource import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot +import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.devices.HasCase +import eu.darken.capod.pods.core.apple.ble.isKnownBattery import java.time.Duration import java.time.Instant @@ -24,15 +26,17 @@ fun PodDevice.toCachedState( if (!isLive) return null val pid = profileId ?: return null - val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent - val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent - val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent - val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + // RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache). + // Reading the unified getter would re-stamp stale cached readings as fresh live data. + val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN + val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN + val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN + val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN val liveDeviceInfo = aap?.deviceInfo - if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null && liveDeviceInfo == null) { - return null - } + if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) && + !isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null + ) return null val newState = CachedDeviceState( profileId = pid, @@ -61,15 +65,14 @@ fun PodDevice.toCachedState( } private fun mergeBatterySlot( - livePercent: Float?, + livePercent: Float, existing: CachedBatterySlot?, now: Instant, ): CachedBatterySlot? { - val live: Float = livePercent ?: return existing - val current: CachedBatterySlot = existing ?: return CachedBatterySlot(live, now) - + if (!isKnownBattery(livePercent)) return existing + val current = existing ?: return CachedBatterySlot(livePercent, now) val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1) - return if (current.percent == live && !isStale) current else CachedBatterySlot(live, now) + return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now) } private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean { diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt index 415ce0ed..c407e8ac 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt @@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable +import eu.darken.capod.pods.core.apple.ble.isKnownBattery import javax.inject.Inject import kotlin.math.roundToInt @@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor( setTextViewText(R.id.device, device.getLabel(context)) } - private fun percentToInt(percent: Float?): Int { - if (percent == null) return 0 - return (percent * 100).roundToInt().coerceIn(0, 100) - } + private fun percentToInt(percent: Float): Int = + if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0 } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt index a119bbce..6560d5b7 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt @@ -21,17 +21,18 @@ import kotlin.math.roundToInt const val BATTERY_UNKNOWN = -1f -fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN +fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f -fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f } +fun batteryProgress(percent: Float): Float = + if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f -fun formatBatteryPercent(context: Context, percent: Float?): String = - percent?.let { "${(it * 100).roundToInt()}%" } - ?: context.getString(R.string.general_value_not_available_label) +fun formatBatteryPercent(context: Context, percent: Float): String = + if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" + else context.getString(R.string.general_value_not_available_label) @DrawableRes -fun getBatteryDrawable(percent: Float?): Int = when { - percent == null -> R.drawable.ic_baseline_battery_unknown_24 +fun getBatteryDrawable(percent: Float): Int = when { + !isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24 percent > 0.95f -> R.drawable.ic_baseline_battery_full_24 percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24 percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24 @@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when { else -> R.drawable.ic_baseline_battery_0_bar_24 } -fun getBatteryIcon(percent: Float?): ImageVector = when { - percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown +fun getBatteryIcon(percent: Float): ImageVector = when { + !isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown percent > 0.95f -> Icons.TwoTone.BatteryFull percent > 0.80f -> Icons.TwoTone.Battery6Bar percent > 0.65f -> Icons.TwoTone.Battery5Bar diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index 46c7479d..44280c6d 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import eu.darken.capod.pods.core.apple.ble.getBatteryIcon -import eu.darken.capod.pods.core.apple.ble.toBatteryFloat -import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull @Composable fun PopUpContent( @@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) { // Left pod BatteryColumn( iconRes = device.leftPodIcon, - batteryPercent = device.batteryLeft.toBatteryFloat(), + batteryPercent = device.batteryLeft, isCharging = device.isLeftPodCharging ?: false, modifier = Modifier.weight(1f), ) @@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) { if (device.hasCase) { BatteryColumn( iconRes = device.caseIcon, - batteryPercent = device.batteryCase.toBatteryFloat(), + batteryPercent = device.batteryCase, isCharging = device.isCaseCharging ?: false, modifier = Modifier.weight(1f), ) @@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) { // Right pod BatteryColumn( iconRes = device.rightPodIcon, - batteryPercent = device.batteryRight.toBatteryFloat(), + batteryPercent = device.batteryRight, isCharging = device.isRightPodCharging ?: false, modifier = Modifier.weight(1f), ) @@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) { private fun SinglePodContent(device: PodDevice) { BatteryColumn( iconRes = device.iconRes, - batteryPercent = device.batteryHeadset.toBatteryFloat(), + batteryPercent = device.batteryHeadset, isCharging = device.isHeadsetBeingCharged ?: false, ) } @@ -157,7 +155,6 @@ private fun BatteryColumn( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val nullablePercent = batteryPercent.toBatteryOrNull() Column( modifier = modifier, @@ -184,14 +181,14 @@ private fun BatteryColumn( ) } else { Icon( - imageVector = getBatteryIcon(nullablePercent), + imageVector = getBatteryIcon(batteryPercent), contentDescription = null, modifier = Modifier.size(16.dp), ) } Spacer(modifier = Modifier.width(2.dp)) Text( - text = formatBatteryPercent(context, nullablePercent), + text = formatBatteryPercent(context, batteryPercent), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt index 15d7841c..1ed635e9 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt index 9886c5e0..c25d6665 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt @@ -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(