feat(battery): Warn when a pod's listening time drops by half

The per-pod listening-time estimate was only visible behind the info icon on
the device settings header card. A pod that reaches half its rated listening
hours or less now raises a banner on the device settings screen, tapping it
opens the same detail sheet.

A displayed number needs less backing than one that raises a warning, so a
reading is only promoted into the banner when its slot has accumulated at
least 8 listening sessions across its qualifying rates and the newest of
those rates is at most 60 days old. The banner reads the already-gated state
field, so the per-profile battery estimate toggle suppresses it too.

The detail sheet's visibility moves out of DeviceInfoCard so both the info
icon and the banner can open it.
This commit is contained in:
darken
2026-08-25 23:42:23 +02:00
committed by Matthias Urhahn
parent 2356726417
commit 1330c77ed8
8 changed files with 491 additions and 73 deletions
@@ -45,6 +45,7 @@ import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.BatteryRuntimeWarningBanner
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
@@ -240,6 +241,9 @@ fun DeviceSettingsScreen(
val features = device?.model?.features
val enabled = device?.isAapReady == true
val isPro = state.isPro
// Hoisted out of DeviceInfoCard: the runtime warning banner opens the same detail sheet as the
// card's info icon.
var showDeviceDetails by rememberSaveable { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -306,13 +310,13 @@ fun DeviceSettingsScreen(
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
@@ -331,6 +335,8 @@ fun DeviceSettingsScreen(
detailItems = detailItems,
canRename = device.isAapReady,
onRename = onDeviceNameChange,
showDetails = showDeviceDetails,
onShowDetailsChange = { showDeviceDetails = it },
)
}
}
@@ -345,6 +351,19 @@ fun DeviceSettingsScreen(
}
}
// Listening time has dropped far below the model's rating — details live in the sheet
val runtimeWarning = state.batteryRuntimeWarning
if (device != null && device.hasSelectedPairedDevice && runtimeWarning != null) {
item("battery_runtime_warning") {
BatteryRuntimeWarningBanner(
slot = runtimeWarning.slot,
percent = runtimeWarning.reading.percent,
onClick = { showDeviceDetails = true },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// Not nearby — no live BLE; settings require the device to be present
if (device != null && device.hasSelectedPairedDevice &&
device.ble == null && !state.isClassicallyConnected
@@ -160,9 +160,15 @@ class DeviceSettingsViewModel @Inject constructor(
null
}
}
val now = timeSource.now()
// The runtime figure rides on the same learned data as the estimate — the per-device
// toggle governs both.
val batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model, now) }
State(
device = device,
now = timeSource.now(),
now = now,
isPro = upgrade.isPro,
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing,
@@ -174,12 +180,10 @@ class DeviceSettingsViewModel @Inject constructor(
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true,
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
// Health rides on the same learned data as the estimate — the per-device toggle
// governs both.
batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
// A model with a rating WILL eventually produce a health figure — surface the
batteryHealth = batteryHealth,
batteryRuntimeWarning = batteryHealth?.lowestPromotable
?.takeIf { it.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT },
// A model with a rating WILL eventually produce a runtime figure — surface the
// feature as "still determining" until the listening sessions accumulate. Without
// a paired device the listening gate can never open, so no promise is made.
batteryHealthPending = device != null &&
@@ -212,10 +216,14 @@ class DeviceSettingsViewModel @Inject constructor(
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
/** Derived per-pod share of the rated listening time (1..100 each), or null when there
* isn't enough learned data. */
val batteryHealth: BatteryHealth.PerPod? = null,
/** True when health CAN be derived for this device (rated model, feature on) — shows the
* "still determining" placeholder while [batteryHealth] is null. */
/** The worst pod whose runtime has dropped to [BatteryHealth.LOW_RUNTIME_PERCENT] or below
* on enough recent evidence to warn about — drives the warning banner. */
val batteryRuntimeWarning: BatteryHealth.SlotReading? = null,
/** True when a runtime figure CAN be derived for this device (rated model, feature on) —
* shows the "still determining" placeholder while [batteryHealth] is null. */
val batteryHealthPending: Boolean = false,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
@@ -0,0 +1,98 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.monitor.core.battery.BatteryHealth
@Composable
internal fun BatteryRuntimeWarningBanner(
slot: BatteryHealth.Slot,
percent: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val podLabel = when (slot) {
BatteryHealth.Slot.LEFT -> stringResource(R.string.pods_dual_left_label)
BatteryHealth.Slot.RIGHT -> stringResource(R.string.pods_dual_right_label)
BatteryHealth.Slot.HEADSET -> stringResource(R.string.device_settings_battery_runtime_warning_headset_label)
}
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.padding(end = 10.dp)
.size(20.dp),
)
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.device_settings_battery_runtime_warning_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
)
Text(
text = stringResource(
R.string.device_settings_battery_runtime_warning_description,
podLabel,
percent,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
}
}
@Preview2
@Composable
private fun BatteryRuntimeWarningBannerEarbudPreview() = PreviewWrapper {
BatteryRuntimeWarningBanner(
slot = BatteryHealth.Slot.LEFT,
percent = 42,
onClick = {},
)
}
@Preview2
@Composable
private fun BatteryRuntimeWarningBannerHeadsetPreview() = PreviewWrapper {
BatteryRuntimeWarningBanner(
slot = BatteryHealth.Slot.HEADSET,
percent = 50,
onClick = {},
)
}
@@ -53,12 +53,13 @@ internal fun DeviceInfoCard(
detailItems: List<DeviceDetailItem> = emptyList(),
canRename: Boolean = false,
onRename: (String) -> Unit = {},
showDetails: Boolean = false,
onShowDetailsChange: (Boolean) -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) showBottomSheet = false
if (detailItems.isEmpty()) onShowDetailsChange(false)
}
if (showRenameDialog && deviceInfo != null) {
@@ -72,10 +73,10 @@ internal fun DeviceInfoCard(
)
}
if (showBottomSheet && detailItems.isNotEmpty()) {
if (showDetails && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { showBottomSheet = false },
onDismiss = { onShowDetailsChange(false) },
)
}
@@ -103,7 +104,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.weight(1f),
)
if (showInfoIconInModelRow) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = { onShowDetailsChange(true) }) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -144,7 +145,7 @@ internal fun DeviceInfoCard(
} else null,
)
if (showInfoIconInNameRow) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = { onShowDetailsChange(true) }) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -159,7 +160,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = { onShowDetailsChange(true) }) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -2,72 +2,129 @@ package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
/**
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
* Derives how much of a model's rated listening time a pod still delivers, from learned drain rates
* vs that rating: a pod that only lasts 4.5h of a rated 6h reads as ~75%. Nothing on the wire
* exposes Apple's real health/cycle data, and a short runtime can just as well come from loud
* volume, cold weather or a hungry codec — so this is a runtime figure, not a cell-health verdict.
*
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* It is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
* the UI.
* ratings are listening figures — general rates include idle wear and would flatter the result.
* Within a pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping
* remaining confounds (volume, calls, cold) in either direction. It remains an estimate — label it
* as such in the UI.
*
* A figure that is merely displayed needs less backing than one that raises a warning, hence
* [Reading.isPromotable].
*/
object BatteryHealth {
/** A learned rate must have accumulated this many separate sessions before it counts. */
const val MIN_UPDATE_COUNT = 3
/**
* Sessions a slot must have accumulated across all its qualifying rates before its figure may
* be promoted into a warning. `updateCount` rises once per listening session, so this is
* roughly a week of real use.
*/
const val MIN_PROMOTE_UPDATE_COUNT = 8
/** A slot's newest qualifying rate may be at most this old, or its figure is too stale to warn on. */
val MAX_PROMOTE_AGE: Duration = Duration.ofDays(60)
/** At or below this percentage of the rated listening time, a promotable reading warrants a warning. */
const val LOW_RUNTIME_PERCENT = 50
enum class Slot { LEFT, RIGHT, HEADSET }
/**
* @param percent share of the rated listening time this pod still reaches, 1..100
* @param isPromotable whether enough recent evidence backs [percent] to act on it
*/
data class Reading(
val percent: Int,
val isPromotable: Boolean,
)
data class SlotReading(
val slot: Slot,
val reading: Reading,
)
data class PerPod(
val left: Int? = null,
val right: Int? = null,
val headset: Int? = null,
val left: Reading? = null,
val right: Reading? = null,
val headset: Reading? = null,
) {
val hasAny: Boolean get() = left != null || right != null || headset != null
/** The worst reading that rests on enough recent evidence to act on, or null if none does. */
val lowestPromotable: SlotReading?
get() = listOfNotNull(
left?.let { SlotReading(Slot.LEFT, it) },
right?.let { SlotReading(Slot.RIGHT, it) },
headset?.let { SlotReading(Slot.HEADSET, it) },
)
.filter { it.reading.isPromotable }
.minByOrNull { it.reading.percent }
}
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
fun estimate(profile: DrainProfile?, model: PodModel, now: Instant): PerPod? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
return PerPod(
left = slotPercent(profile, spec, "LEFT"),
right = slotPercent(profile, spec, "RIGHT"),
headset = slotPercent(profile, spec, "HEADSET"),
left = slotReading(profile, spec, Slot.LEFT, now),
right = slotReading(profile, spec, Slot.RIGHT, now),
headset = slotReading(profile, spec, Slot.HEADSET, now),
).takeIf { it.hasAny }
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
private fun slotReading(
profile: DrainProfile,
spec: PodModel.BatterySpec,
slot: Slot,
now: Instant,
): Reading? {
val qualifying = profile.listeningRates.mapNotNull { (key, rate) ->
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
// future-format data and must not feed a health figure.
// future-format data and must not feed a runtime figure.
val parts = key.split('/')
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
if (parts.size != 2 || parts[1] != slot.name) return@mapNotNull null
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
(1f / specHours) / rate.fractionPerHour
(1f / specHours) / rate.fractionPerHour to rate
}
if (ratios.isEmpty()) return null
if (qualifying.isEmpty()) return null
val sorted = ratios.sorted()
val sorted = qualifying.map { it.first }.sorted()
val median = if (sorted.size % 2 == 1) {
sorted[sorted.size / 2]
} else {
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
}
return (median * 100f).roundToInt().coerceIn(1, 100)
val sessions = qualifying.sumOf { it.second.updateCount }
val newest = qualifying.maxOf { it.second.updatedAt }
return Reading(
percent = (median * 100f).roundToInt().coerceIn(1, 100),
isPromotable = sessions >= MIN_PROMOTE_UPDATE_COUNT &&
Duration.between(newest, now) <= MAX_PROMOTE_AGE,
)
}
/**
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
* the shorter one would systematically flatter health, the longer one would slander it.
* the shorter one would systematically flatter the figure, the longer one would slander it.
* Malformed or unrecognized bucket keys yield null (entry is skipped).
*/
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
+3
View File
@@ -498,6 +498,9 @@
<string name="device_settings_info_battery_health_right_label">Right Battery Health (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Still determining, check back later</string>
<string name="device_settings_battery_runtime_warning_title">Listening time is much shorter than rated</string>
<string name="device_settings_battery_runtime_warning_description">%1$s: about %2$d%% of the rated listening time. Tap for details.</string>
<string name="device_settings_battery_runtime_warning_headset_label">These headphones</string>
<string name="device_settings_info_details_label">Device Details</string>
<string name="device_settings_info_details_action">Show device details</string>
<string name="device_settings_info_status_label">Status</string>
@@ -576,7 +576,103 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 50, isPromotable = false),
)
}
@Test
fun `low runtime on recent evidence raises a warning`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = timeSource.now(),
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryRuntimeWarning shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.LEFT,
reading = BatteryHealth.Reading(percent = 50, isPromotable = true),
)
}
@Test
fun `low runtime on thin evidence raises no warning`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1,
updatedAt = timeSource.now(),
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
val state = vm.state.first()
state.batteryHealth?.left?.percent shouldBe 50
state.batteryRuntimeWarning shouldBe null
}
@Test
fun `the warning follows the per-device estimate toggle`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = timeSource.now(),
)
),
)
)
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryRuntimeWarning shouldBe null
}
@Test
@@ -6,27 +6,33 @@ import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Duration
import java.time.Instant
class BatteryHealthTest : BaseTest() {
private fun rate(fractionPerHour: Float, updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT) =
DrainProfile.LearnedRate(
fractionPerHour = fractionPerHour,
sampleCount = 10,
updateCount = updateCount,
updatedAt = Instant.EPOCH,
)
private val now: Instant = Instant.parse("2026-01-01T00:00:00Z")
private fun rate(
fractionPerHour: Float,
updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT,
updatedAt: Instant = now,
) = DrainProfile.LearnedRate(
fractionPerHour = fractionPerHour,
sampleCount = 10,
updateCount = updateCount,
updatedAt = updatedAt,
)
@Test
fun `health is the ratio of rated to learned drain`() {
fun `runtime is the ratio of rated to learned drain`() {
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
fun `health is computed per pod`() {
fun `runtime is computed per pod`() {
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
// diverge — each pod gets its own figure instead of one masking the other.
val profile = DrainProfile(
@@ -35,21 +41,21 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
)
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
health.left shouldBe 50
health.right shouldBe 100
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull()
health.left?.percent shouldBe 50
health.right?.percent shouldBe 100
health.headset shouldBe null
}
@Test
fun `health is capped at 100`() {
// Idle-heavy usage drains slower than the listening rating — never report over-health.
fun `runtime is capped at 100`() {
// Idle-heavy usage drains slower than the listening rating — never report over 100%.
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 100
}
@Test
fun `health uses the median across a pod's learned rates`() {
fun `runtime uses the median across a pod's learned rates`() {
// Three qualifying LEFT entries at 100% / 50% / 25% equivalent -> the median (50%) wins,
// so a single gentle idle session can't inflate the figure and one hard session can't
// tank it.
@@ -60,7 +66,7 @@ class BatteryHealthTest : BaseTest() {
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
@@ -68,19 +74,19 @@ class BatteryHealthTest : BaseTest() {
val profile = DrainProfile(
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
fun `models without a rating have no health`() {
fun `models without a rating have no runtime figure`() {
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.UNKNOWN, now).shouldBeNull()
}
@Test
fun `no profile or no qualifying rates yields null`() {
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2, now).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -89,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
model = PodModel.AIRPODS_PRO.name,
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -105,7 +111,7 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -113,15 +119,145 @@ class BatteryHealthTest : BaseTest() {
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
// ON rating — not 40% of the OFF one.
val profile = DrainProfile(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX, now).shouldNotBeNull()
health.headset?.percent shouldBe 50
health.left shouldBe null
}
@Test
fun `a reading is promotable with enough recent sessions`() {
val profile = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `sessions are summed across a pod's qualifying rates`() {
// Neither entry alone clears the promote floor, together they do.
val half = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT / 2
val profile = DrainProfile(
listeningRates = mapOf(
"ON/LEFT" to rate(1f / 3f, updateCount = half),
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - half),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `exactly the promote session floor is enough`() {
val atFloor = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
BatteryHealth.estimate(atFloor, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
val belowFloor = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1),
)
)
BatteryHealth.estimate(belowFloor, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe false
}
@Test
fun `only the newest qualifying rate decides staleness`() {
val stale = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minusSeconds(1)
val profile = DrainProfile(
listeningRates = mapOf(
"ON/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = stale),
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = now),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `a reading exactly at the age limit still promotes`() {
val atLimit = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(
1f / 3f,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE),
),
)
)
BatteryHealth.estimate(atLimit, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
val pastLimit = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(
1f / 3f,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minus(Duration.ofSeconds(1)),
),
)
)
BatteryHealth.estimate(pastLimit, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe false
}
@Test
fun `lowestPromotable picks the worst pod that has enough evidence`() {
val perPod = BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 60, isPromotable = true),
right = BatteryHealth.Reading(percent = 40, isPromotable = true),
)
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.RIGHT,
reading = BatteryHealth.Reading(percent = 40, isPromotable = true),
)
}
@Test
fun `lowestPromotable ignores readings without enough evidence`() {
val perPod = BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
headset = BatteryHealth.Reading(percent = 70, isPromotable = true),
)
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.HEADSET,
reading = BatteryHealth.Reading(percent = 70, isPromotable = true),
)
}
@Test
fun `lowestPromotable is null when nothing qualifies`() {
BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
).lowestPromotable.shouldBeNull()
BatteryHealth.PerPod().lowestPromotable.shouldBeNull()
}
@Test
fun `exactly the low runtime threshold counts as low`() {
// Pro 2 rated 6h; 3h learned -> 50%, the threshold itself must still warn.
val profile = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
val lowest = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().lowestPromotable.shouldNotBeNull()
lowest.reading.percent shouldBe BatteryHealth.LOW_RUNTIME_PERCENT
(lowest.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT) shouldBe true
}
}