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(