fix: Replace nullable Float composable params to avoid ART native crash

ComposerImpl.changed() boxes Float? and calls Float.equals() at the ART native level, triggering a known crash. Non-null Float params use the primitive overload with no boxing.

Introduces BATTERY_UNKNOWN sentinel, toBatteryFloat(), and toBatteryOrNull() extensions to eliminate Float? from all composable signatures and WidgetRenderState data classes.
This commit is contained in:
darken
2026-03-20 19:12:22 +00:00
committed by Matthias Urhahn
parent c6c7cd7858
commit 6e9b275936
8 changed files with 50 additions and 36 deletions
@@ -56,6 +56,8 @@ import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
@@ -164,7 +166,7 @@ fun DualPodsCard(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
@@ -175,7 +177,7 @@ fun DualPodsCard(
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
@@ -218,7 +220,7 @@ fun DualPodsCard(
@Composable
private fun PodGauge(
iconRes: Int,
batteryPercent: Float?,
batteryPercent: Float,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
@@ -227,15 +229,15 @@ private fun PodGauge(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = batteryPercent?.coerceIn(0f, 1f)
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -261,7 +263,7 @@ private fun PodGauge(
)
// Progress ring
if (clamped != null) {
if (clamped >= 0f) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(80.dp),
@@ -284,9 +286,9 @@ private fun PodGauge(
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent != null) {
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -335,7 +337,7 @@ private fun CaseRow(
)
BatteryCapsule(
percent = device.batteryCasePercent,
percent = device.batteryCasePercent.toBatteryFloat(),
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -40,18 +40,18 @@ private val CapsuleShape = RoundedCornerShape(6.dp)
@Composable
fun BatteryCapsule(
percent: Float?,
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = percent?.coerceIn(0f, 1f)
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val animatedFraction by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -62,7 +62,7 @@ fun BatteryCapsule(
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped != null) {
if (clamped >= 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
@@ -30,6 +30,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.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -191,7 +192,7 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
@@ -293,7 +294,7 @@ private fun WidgetContainer(
@Composable
private fun PodItemRow(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textColor: Color,
@@ -313,7 +314,7 @@ private fun PodItemRow(
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
@@ -26,6 +26,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.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -105,7 +106,7 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
@@ -203,7 +204,7 @@ private fun GlanceWidgetRoot(
@Composable
private fun GlancePodItem(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textStyle: TextStyle,
@@ -222,7 +223,7 @@ private fun GlancePodItem(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
@@ -18,15 +18,15 @@ sealed class WidgetRenderState {
val isWide: Boolean,
val deviceLabel: String?,
@DrawableRes val leftIcon: Int,
val leftPercent: Float?,
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
@DrawableRes val rightIcon: Int,
val rightPercent: Float?,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
@DrawableRes val caseIcon: Int,
val casePercent: Float?,
val casePercent: Float,
val caseCharging: Boolean,
) : WidgetRenderState()
@@ -37,7 +37,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedIconColor: Int,
val deviceLabel: String?,
@DrawableRes val headsetIcon: Int,
val percent: Float?,
val percent: Float,
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
object WidgetRenderStateMapper {
@@ -46,15 +47,15 @@ object WidgetRenderStateMapper {
isWide = isWide,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeftPodPercent,
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRightPodPercent,
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
casePercent = (device as? HasCase)?.batteryCasePercent,
casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(),
caseCharging = device is HasCase && device.isCaseCharging,
)
@@ -65,7 +66,7 @@ object WidgetRenderStateMapper {
resolvedIconColor = iconColor,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadsetPercent,
percent = device.batteryHeadsetPercent.toBatteryFloat(),
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
worn = device is HasEarDetection && device.isBeingWorn,
@@ -8,6 +8,12 @@ import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
@@ -40,6 +40,8 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.getSignalQuality
@Composable
@@ -127,7 +129,7 @@ private fun DualPodContent(device: DualPodDevice) {
// Left pod
BatteryColumn(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
@@ -135,7 +137,7 @@ private fun DualPodContent(device: DualPodDevice) {
if (hasCase != null) {
BatteryColumn(
iconRes = hasCase.caseIcon,
batteryPercent = hasCase.batteryCasePercent,
batteryPercent = hasCase.batteryCasePercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
}
@@ -143,7 +145,7 @@ private fun DualPodContent(device: DualPodDevice) {
// Right pod
BatteryColumn(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
}
@@ -153,17 +155,18 @@ private fun DualPodContent(device: DualPodDevice) {
private fun SinglePodContent(device: SinglePodDevice) {
BatteryColumn(
iconRes = device.iconRes,
batteryPercent = device.batteryHeadsetPercent,
batteryPercent = device.batteryHeadsetPercent.toBatteryFloat(),
)
}
@Composable
private fun BatteryColumn(
iconRes: Int,
batteryPercent: Float?,
batteryPercent: Float,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val nullablePercent = batteryPercent.toBatteryOrNull()
Column(
modifier = modifier,
@@ -183,13 +186,13 @@ private fun BatteryColumn(
horizontalArrangement = Arrangement.Center,
) {
Icon(
painter = painterResource(getBatteryDrawable(batteryPercent)),
painter = painterResource(getBatteryDrawable(nullablePercent)),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = formatBatteryPercent(context, batteryPercent),
text = formatBatteryPercent(context, nullablePercent),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,