From 1330c77ed80091283ca9058832cb085b832d6876 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:31:15 +0200 Subject: [PATCH] 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. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 25 ++- .../devicesettings/DeviceSettingsViewModel.kt | 28 ++- .../cards/BatteryRuntimeWarningBanner.kt | 98 +++++++++ .../ui/devicesettings/cards/DeviceInfoCard.kt | 15 +- .../monitor/core/battery/BatteryHealth.kt | 105 +++++++--- app/src/main/res/values/strings.xml | 3 + .../DeviceSettingsViewModelTest.kt | 98 ++++++++- .../monitor/core/battery/BatteryHealthTest.kt | 192 +++++++++++++++--- 8 files changed, 491 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryRuntimeWarningBanner.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index d3b2c617..55d048ab 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index bcfbbe46..b80e6b0e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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() diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryRuntimeWarningBanner.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryRuntimeWarningBanner.kt new file mode 100644 index 00000000..19645c97 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryRuntimeWarningBanner.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt index 92d17c2e..985445ba 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt @@ -53,12 +53,13 @@ internal fun DeviceInfoCard( detailItems: List = 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), diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt index ee745e71..37e24639 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt @@ -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 "/" — 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? { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0c8a415..39339ef3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,6 +498,9 @@ Right Battery Health (est.) ~%1$d%% Still determining, check back later + Listening time is much shorter than rated + %1$s: about %2$d%% of the rated listening time. Tap for details. + These headphones Device Details Show device details Status diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 78c4edc1..c92e7012 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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(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(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(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 diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt index 0aca9372..2b5b1a42 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt @@ -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 + } }