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
@@ -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),
@@ -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
@@ -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),
@@ -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
@@ -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 ""
@@ -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?,
@@ -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,
@@ -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
@@ -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 {
@@ -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
}
@@ -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
@@ -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,
@@ -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(