fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging

- While charging, the gauge showed the bare runtime projection when no charge
  rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
  charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
  in the case provides around 1 hour of listening"), normalized against the
  rated listening hours, so the ETA is present from the very first charge; a
  live fit still takes over within minutes
This commit is contained in:
darken
2026-07-02 10:17:19 +02:00
parent 177d5366f4
commit 96d2de1f51
6 changed files with 70 additions and 23 deletions
@@ -240,7 +240,9 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatEstimateText(context, it) },
timeRemaining = batteryEstimate?.left?.let {
formatEstimateText(context, it, isCharging = device.isLeftPodCharging == true)
},
)
PodGauge(
@@ -253,7 +255,9 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatEstimateText(context, it) },
timeRemaining = batteryEstimate?.right?.let {
formatEstimateText(context, it, isCharging = device.isRightPodCharging == true)
},
)
}
@@ -406,14 +410,17 @@ private fun PodGauge(
}
/**
* The gauge's small estimate line: while charging with a usable rate, the time until full
* (language-neutral "⚡ 25m"); otherwise the usual time-remaining ("2h 15m"). Shared with
* The gauge's small estimate line: while charging, the time until full (language-neutral "⚡ 25m")
* or NOTHING — a bare runtime number next to a charging chip ("1% · 4m") inevitably reads as a
* four-minute charge. The runtime estimate only shows while not charging. Shared with
* [SinglePodsCard] (same package).
*/
internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod): String =
pod.minutesUntilCharged
?.let { context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, it)) }
?: formatBatteryDurationShort(context, pod.minutesRemaining)
internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod, isCharging: Boolean): String? = when {
pod.minutesUntilCharged != null ->
context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, pod.minutesUntilCharged))
isCharging -> null
else -> formatBatteryDurationShort(context, pod.minutesRemaining)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -267,10 +267,12 @@ private fun ColumnScope.SinglePodsCardExpanded(
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset
val headsetEstimate = batteryEstimate?.headset?.let {
formatEstimateText(context, it, isCharging = device.isHeadsetBeingCharged == true)
}
if (headsetEstimate != null) {
Text(
text = formatEstimateText(context, headsetEstimate),
text = headsetEstimate,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -348,7 +348,13 @@ class BatteryEstimator @Inject constructor(
val live = if (history.direction == SlotHistory.Direction.CHARGE) {
DrainModel.chargeSlopeFractionPerHour(history.toList())
} else null
val rate = live ?: learnedChargeRate(profileId, device, slot) ?: return null
// Rate preference mirrors the drain side: measured, then learned, then Apple's published
// quick-charge claim ("5 minutes in the case = ~1 hour of listening") — so an ETA exists
// even on the very first charge.
val rate = live
?: learnedChargeRate(profileId, device, slot)
?: device.model.batterySpec?.chargeFractionPerHour
?: return null
val lastRise = tracker.lastRiseMs[slot] ?: return null
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
@@ -27,7 +27,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -45,7 +45,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -67,7 +67,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f),
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -90,7 +90,7 @@ enum class PodModel(
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -122,7 +122,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -148,7 +148,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f),
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -182,7 +182,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -216,7 +216,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -251,7 +251,7 @@ enum class PodModel(
hasSleepDetection = true,
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f),
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -272,7 +272,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A2096"), // headphones
),
@@ -290,7 +290,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3184"), // headphones
),
@@ -312,7 +312,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3454"), // headphones
),
@@ -606,5 +606,14 @@ enum class PodModel(
val listeningHoursAncOn: Float? = null,
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
val listeningHoursAncOff: Float? = null,
/**
* Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's
* published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of
* listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two
* listening ratings it's normalized against the LONGER one, so the seeded "until charged"
* never under-promises. Seeds the charge ETA on the very first charge, before any charge
* rate has been measured; a live fit takes over within minutes.
*/
val chargeFractionPerHour: Float? = null,
)
}
@@ -267,6 +267,16 @@ class BatteryEstimatorTest : BaseTest() {
left.minutesUntilCharged shouldBe 28
}
@Test
fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) {
// Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim
// (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15
}
@Test
fun `a stored charge rate seeds time-until-charged immediately`() = runTest(UnconfinedTestDispatcher()) {
// First charging emission, no live fit possible yet -> the persisted rate answers at once.
@@ -155,6 +155,19 @@ class ModelFeaturesTest : BaseTest() {
}
}
@Test
fun `every battery spec carries a plausible quick-charge rate`() {
// Derived from Apple's published quick-charge claims; must sit inside the band the live
// fit is validated against, or the seed could never be corrected by a measurement.
PodModel.entries.forEach { model ->
val spec = model.batterySpec ?: return@forEach
withClue(model.name) {
val rate = spec.chargeFractionPerHour
(rate != null && rate in 0.25f..4.0f) shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()