feat: Add earbud battery time-remaining estimate

This commit is contained in:
darken
2026-07-01 17:58:12 +02:00
committed by Matthias Urhahn
parent f626d70538
commit 9606a33f15
21 changed files with 1072 additions and 28 deletions
@@ -212,6 +212,37 @@ object MockPodDataProvider {
),
)
/**
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
*/
fun dualPodFullyLoaded(): PodDevice = PodDevice(
profileId = "preview-dual-full",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
pod = AapSetting.PrimaryPod.Pod.LEFT,
),
),
),
)
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
profileId = "preview-dual-reactions",
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -85,6 +85,8 @@ class GeneralSettings @Inject constructor(
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val batteryEstimateEnabled = dataStore.createValue("ui.overview.battery_estimate.enabled", true)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -65,6 +65,7 @@ import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
@@ -333,6 +334,7 @@ fun OverviewScreen(
showDebug = state.isDebug,
now = state.now,
isCollapsed = isCollapsed,
batteryEstimate = state.estimateFor(device),
onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) }
} else null,
@@ -407,6 +409,7 @@ private fun PodDeviceCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit,
@@ -417,6 +420,7 @@ private fun PodDeviceCard(
device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -426,6 +430,7 @@ private fun PodDeviceCard(
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -444,7 +449,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.dualPodFullyLoaded(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
@@ -457,6 +462,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
showReactionsHint = true,
userExpandedIds = setOf("preview-single"),
batteryEstimates = mapOf(
"preview-dual-full" to BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, isLearned = false),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, isLearned = false),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
),
),
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -23,6 +23,8 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -59,6 +61,7 @@ class OverviewViewModel @Inject constructor(
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -85,6 +88,8 @@ class OverviewViewModel @Inject constructor(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val batteryEstimateEnabled: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
/**
@@ -120,8 +125,16 @@ class OverviewViewModel @Inject constructor(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
generalSettings.batteryEstimateEnabled.flow,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimateEnabled, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimateEnabled = batteryEstimateEnabled,
batteryEstimates = batteryEstimates,
)
}
init {
@@ -210,6 +223,8 @@ class OverviewViewModel @Inject constructor(
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimateEnabled = uiSettings.batteryEstimateEnabled,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -228,9 +243,22 @@ class OverviewViewModel @Inject constructor(
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
/**
* Time-remaining estimate to show for [device], or null when the user disabled the feature,
* the device isn't live (no estimate for cached/offline cards), or no rate has been learned.
*/
fun estimateFor(device: PodDevice): BatteryEstimate? {
if (!batteryEstimateEnabled) return null
if (!device.isLive) return null
val profileId = device.profileId ?: return null
return batteryEstimates[profileId]
}
/** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy {
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
@@ -60,12 +60,14 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
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.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.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant
@@ -76,6 +78,7 @@ fun DualPodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -192,6 +195,7 @@ fun DualPodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
@@ -233,6 +239,7 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
)
PodGauge(
@@ -245,6 +252,7 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
)
}
@@ -299,6 +307,7 @@ private fun PodGauge(
isMicrophone: Boolean,
showMicrophone: Boolean,
modifier: Modifier = Modifier,
timeRemaining: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -356,16 +365,27 @@ private fun PodGauge(
Spacer(modifier = Modifier.height(6.dp))
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage, with the time-remaining estimate inline next to it
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.alignByBaseline(),
)
if (timeRemaining != null) {
Text(
text = "· $timeRemaining",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline(),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
@@ -454,10 +474,32 @@ private fun CaseRow(
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, isLearned = false),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, isLearned = false),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
// isLearned = true: rate seeded from persisted history on reconnect, before this session has
// gathered enough live samples.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, isLearned = true),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, isLearned = true),
),
onDeviceSettings = {},
)
}
@@ -57,10 +57,12 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
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.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@@ -73,6 +75,7 @@ fun SinglePodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -178,6 +181,7 @@ fun SinglePodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage + time remaining, stacked inside the ring
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
softWrap = false,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -333,6 +350,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
batteryEstimate = BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon
@@ -60,6 +61,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onBatteryEstimateEnabledChanged = { enabled -> vm.setBatteryEstimateEnabled(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -78,6 +80,7 @@ fun GeneralSettingsScreen(
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onBatteryEstimateEnabledChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
@@ -215,6 +218,21 @@ fun GeneralSettingsScreen(
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_overview_battery_estimate_label),
subtitle = stringResource(R.string.settings_overview_battery_estimate_description),
icon = Icons.TwoTone.Schedule,
onClick = { onBatteryEstimateEnabledChanged(!state.batteryEstimateEnabled) },
trailingContent = {
Switch(
checked = state.batteryEstimateEnabled,
onCheckedChange = onBatteryEstimateEnabledChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
@@ -286,6 +304,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
batteryEstimateEnabled = true,
themeState = ThemeState(),
)
@@ -301,6 +320,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
onBatteryEstimateEnabledChanged = {},
)
}
@@ -316,5 +336,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
onBatteryEstimateEnabledChanged = {},
)
}
@@ -35,6 +35,7 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val batteryEstimateEnabled: Boolean,
val themeState: ThemeState,
)
@@ -44,9 +45,10 @@ class GeneralSettingsViewModel @Inject constructor(
combine(
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { showNotif, keepNotif ->
generalSettings.batteryEstimateEnabled.flow,
) { showNotif, keepNotif, batteryEstimate ->
@Suppress("USELESS_CAST")
arrayOf<Any>(showNotif as Any, keepNotif as Any)
arrayOf<Any>(showNotif as Any, keepNotif as Any, batteryEstimate as Any)
},
combine(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -68,6 +70,7 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
batteryEstimateEnabled = general[2] as Boolean,
themeState = themeState,
)
}.asLiveState()
@@ -102,6 +105,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setBatteryEstimateEnabled(enabled: Boolean) {
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
generalSettings.batteryEstimateEnabled.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) {
@@ -0,0 +1,115 @@
package eu.darken.capod.monitor.core.battery
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
*/
@Singleton
class BatteryDrainStore @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val storeDir by lazy {
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
}
private val lock = Mutex()
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
init {
appScope.launch { loadAll() }
}
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
val dir = storeDir
if (!dir.exists()) return@withLock
val loaded = mutableMapOf<ProfileId, DrainProfile>()
dir.listFiles()
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
?.forEach { file ->
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
try {
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
file.delete()
}
}
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
_profiles.value = loaded
}
}
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
val file = id.toFile()
val tmp = File(file.parentFile, "${file.name}.tmp")
try {
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
if (!tmp.renameTo(file)) {
// renameTo can fail across some filesystems; fall back to a direct write.
file.writeText(tmp.readText())
tmp.delete()
}
_profiles.value += (id to profile)
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
tmp.delete()
}
}
}
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
id.toFile().delete()
_profiles.value -= id
}
}
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
storeDir.listFiles()?.forEach { it.delete() }
_profiles.value = emptyMap()
}
}
companion object {
private val TAG = logTag("Monitor", "BatteryDrainStore")
private const val PREFIX = "drainrate_"
private const val SUFFIX = ".json"
}
}
@@ -0,0 +1,25 @@
package eu.darken.capod.monitor.core.battery
/**
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
* [right] for dual-pod devices. Slots with no usable estimate are null.
*/
data class BatteryEstimate(
val left: Pod? = null,
val right: Pod? = null,
val headset: Pod? = null,
) {
/**
* @property minutesRemaining smoothed estimate of minutes until this pod empties
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
* @property isLearned true when the rate came from persisted history rather than the live session
*/
data class Pod(
val minutesRemaining: Int,
val fractionPerHour: Float,
val isLearned: Boolean,
)
val hasAny: Boolean get() = left != null || right != null || headset != null
}
@@ -0,0 +1,292 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
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.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Learns each device's battery drain rate from observed levels over time and turns it into a
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
* is available immediately on reconnect.
*
* Lifecycle: [monitor] is launched by the foreground monitor service (see
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
*/
@Singleton
class BatteryEstimator @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val drainStore: BatteryDrainStore,
private val timeSource: TimeSource,
) {
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
private enum class Slot { LEFT, RIGHT, HEADSET }
private class SlotHistory {
private val samples = ArrayDeque<DrainSample>()
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
val size: Int get() = samples.size
fun record(sample: DrainSample) {
samples.addLast(sample)
while (samples.size > RING_SIZE) samples.removeFirst()
}
fun clear() = samples.clear()
fun toList(): List<DrainSample> = samples.toList()
}
private class DeviceTracker {
var modeBucket: String = MODE_UNKNOWN
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/** Smoothed displayed minutes, per pod. */
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
var lastUpdateMs: Long? = null
// Keyed by "<bucket>/<slot>".
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
/**
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
* during a single session blend against a fixed baseline instead of runaway-converging.
*/
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
fun clearSlots() = slots.values.forEach { it.clear() }
fun resetWindow() {
clearSlots()
lastMinutes.clear()
sessionBaseline.clear()
}
}
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices -> process(devices) }
.onCompletion {
// The collector stops with the monitor service; drop session state so the UI can't
// keep showing a stale estimate and the next session re-seeds from persistence.
trackers.clear()
_estimates.value = emptyMap()
}
.map { }
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
private suspend fun process(devices: List<PodDevice>) {
// Only profiles with a single, unambiguous live candidate. DeviceMonitor keeps multiple
// same-profile devices when there's no IRK-verified match, and blending two physical
// devices' levels would be garbage — skip those.
val unambiguous = devices
.filter { it.profileId != null && it.isLive }
.groupBy { it.profileId!! }
.filter { (_, group) -> group.size == 1 }
.mapValues { (_, group) -> group.single() }
val next = _estimates.value.toMutableMap()
// Drop estimates for profiles no longer live/unambiguous this emission (offline gating).
next.keys.retainAll(unambiguous.keys)
for ((profileId, device) in unambiguous) {
val estimate = updateTracker(profileId, device)
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
}
_estimates.value = next
}
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice): BatteryEstimate? {
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
val nowMs = timeSource.elapsedRealtime()
val bucket = device.modeBucket()
// A long gap since the last update means the device was out of range / reconnected — the
// prior window is stale and must not be extended across the absence.
val gap = tracker.lastUpdateMs?.let { nowMs - it }
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
tracker.lastUpdateMs = nowMs
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
// then start fresh so OFF-rate samples never blend into ON-rate.
if (tracker.modeBucket != bucket) {
persistFromWindow(profileId, tracker, tracker.modeBucket, nowMs, force = true)
tracker.resetWindow()
tracker.modeBucket = bucket
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val charging = device.liveCharging(slot)
val fraction = device.liveFraction(slot)
when {
charging == true -> { // charging → battery is rising, not draining
history.clear()
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop this pod's smoothing
}
fraction == null -> history.clear() // unavailable reading → just drop this slot's window
else -> {
val last = history.lastFraction
when {
last == null -> history.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastMinutes.remove(slot)
}
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
else -> Unit // ~unchanged, no new information
}
}
}
}
persistFromWindow(profileId, tracker, bucket, nowMs, force = false)
return computeEstimate(profileId, tracker, device, bucket)
}
/** Computes an independent estimate for each pod (left / right / headset). */
private fun computeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
): BatteryEstimate? {
val estimate = BatteryEstimate(
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT),
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT),
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET),
)
return estimate.takeIf { it.hasAny }
}
private fun slotEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
slot: Slot,
): BatteryEstimate.Pod? {
if (device.liveCharging(slot) == true) return null
val fraction = device.liveFraction(slot) ?: return null
val liveRate = DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
val rate = liveRate ?: learnedRate(profileId, bucket, slot) ?: return null
val minutes = DrainModel.minutesRemaining(fraction, rate) ?: return null
val smoothed = DrainModel.blendMinutes(tracker.lastMinutes[slot], minutes)
tracker.lastMinutes[slot] = smoothed
return BatteryEstimate.Pod(
minutesRemaining = smoothed,
fractionPerHour = rate,
isLearned = liveRate == null,
)
}
/**
* Persists each pod's live drain rate under its (bucket, slot) key, at most once per
* [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence) unless [force]d (mode change).
*/
private suspend fun persistFromWindow(
profileId: ProfileId,
tracker: DeviceTracker,
bucket: String,
nowMs: Long,
force: Boolean,
) {
val existing = drainStore.profiles.value[profileId] ?: DrainProfile()
var rates = existing.rates
var changed = false
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val liveRate = DrainModel.slopeFractionPerHour(history.toList()) ?: continue
val key = rateKey(bucket, slot)
val lastPersist = tracker.lastPersistAtMs[key]
if (!force && lastPersist != null && nowMs - lastPersist < PERSIST_INTERVAL_MS) continue
tracker.lastPersistAtMs[key] = nowMs
// Blend against the rate stored when this session began, captured once, so a single long
// session's repeated writes can't dominate prior history by re-blending their own output.
if (!tracker.sessionBaseline.containsKey(key)) {
tracker.sessionBaseline[key] = rates[key]?.fractionPerHour
}
val blended = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate)
rates = rates + (key to DrainProfile.LearnedRate(
fractionPerHour = blended,
sampleCount = history.size,
updatedAt = timeSource.now(),
))
changed = true
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(blended)}/hr" }
}
if (changed) drainStore.save(profileId, existing.copy(rates = rates))
}
private fun learnedRate(profileId: ProfileId, bucket: String, slot: Slot): Float? {
val profile = drainStore.profiles.value[profileId] ?: return null
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour
}
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
// cache); learning from a re-stamped stale reading would poison the rate.
private fun PodDevice.liveFraction(slot: Slot): Float? {
val value = when (slot) {
Slot.LEFT -> aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
Slot.RIGHT -> aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
}
return value?.takeIf { isKnownBattery(it) }
}
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
}
companion object {
private val TAG = logTag("Monitor", "BatteryEstimator")
private const val RING_SIZE = 32
private const val EPSILON = 0.001f
private const val MODE_UNKNOWN = "UNKNOWN"
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
/** A gap longer than this between updates means the device was away — reset its window. */
private const val STALE_GAP_MS = 15 * 60_000L
}
}
@@ -0,0 +1,113 @@
package eu.darken.capod.monitor.core.battery
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* A single battery observation for one slot (left / right / headset).
*
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
*/
data class DrainSample(
val atElapsedMs: Long,
val fraction: Float,
)
/**
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
* be unit-tested without Android, coroutines, or persistence.
*
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
*/
object DrainModel {
/** Minimum samples before a live regression is trusted. */
const val MIN_SAMPLES = 4
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
const val MIN_SPAN_MS = 3 * 60_000L
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
const val MIN_TOTAL_DROP = 0.03f
/**
* Only samples within this window of the newest one feed the regression. Drops stale points
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
* hours of absence.
*/
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
const val RATE_MIN = 0.02f
const val RATE_MAX = 0.80f
/** Estimates above this are implausible and suppressed. */
const val MAX_MINUTES = 24 * 60
/** Smoothing for the displayed minutes (higher = more responsive). */
const val MINUTES_ALPHA = 0.3f
/** Smoothing for the persisted per-mode learned rate. */
const val LEARN_ALPHA = 0.3f
/**
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
*/
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
if (samples.size < MIN_SAMPLES) return null
// Restrict to the recent window so a gap before the newest sample can't span the fit.
val newestMs = samples.last().atElapsedMs
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
if (recent.size < MIN_SAMPLES) return null
val first = recent.first()
val last = recent.last()
if (last.atElapsedMs - first.atElapsedMs < MIN_SPAN_MS) return null
if (first.fraction - last.fraction < MIN_TOTAL_DROP) return null
val n = recent.size.toDouble()
var sumX = 0.0
var sumY = 0.0
var sumXY = 0.0
var sumXX = 0.0
for (s in recent) {
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
val y = s.fraction.toDouble()
sumX += x
sumY += y
sumXY += x * y
sumXX += x * x
}
val denominator = n * sumXX - sumX * sumX
if (abs(denominator) < 1e-9) return null
// Negative slope == draining; flip to a positive drain rate.
val rate = (-((n * sumXY - sumX * sumY) / denominator)).toFloat()
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
return rate
}
/**
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
*/
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
return minutes.takeIf { it in 1..MAX_MINUTES }
}
/** Exponential moving average over the displayed minutes, to avoid a jumpy number. */
fun blendMinutes(previous: Int?, next: Int, alpha: Float = MINUTES_ALPHA): Int =
if (previous == null) next else (next * alpha + previous * (1f - alpha)).roundToInt()
/** Exponential moving average over the persisted learned rate across sessions. */
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
if (previous == null) next else next * alpha + previous * (1f - alpha)
}
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
*/
@Serializable
data class DrainProfile(
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
) {
@Serializable
data class LearnedRate(
/** Drain rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
@SerialName("fractionPerHour") val fractionPerHour: Float,
@SerialName("sampleCount") val sampleCount: Int,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
}
@@ -29,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
@@ -87,6 +88,7 @@ class MonitorService : Service() {
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver
@Inject lateinit var batteryEstimator: BatteryEstimator
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -358,6 +360,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
batteryEstimator.monitor()
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
else context.getString(R.string.general_value_not_available_label)
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return when {
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
}
}
@DrawableRes
fun getBatteryDrawable(percent: Float): Int = when {
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
@@ -11,6 +11,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.cache.DeviceStateCache
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import kotlinx.serialization.json.Json
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings,
private val deviceStateCache: DeviceStateCache,
private val batteryDrainStore: BatteryDrainStore,
@SerializationCapod private val json: Json,
) {
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" }
deviceStateCache.delete(profileId)
batteryDrainStore.delete(profileId)
}
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
suspend fun clear() = mutex.withLock {
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
deviceStateCache.deleteAll()
batteryDrainStore.deleteAll()
}
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
+5
View File
@@ -137,6 +137,8 @@
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
<string name="settings_overview_battery_estimate_label">Estimate time remaining</string>
<string name="settings_overview_battery_estimate_description">Show an estimated time until the earbuds run out, learned from how fast their battery drains. Even when enabled, it only appears after the battery has dropped enough to measure the drain — usually several minutes of active use, and not while charging.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -310,6 +312,9 @@
<string name="last_seen_x">Last seen: %s</string>
<string name="first_seen_x">First seen: %s</string>
<string name="battery_cached_label">Last known \u00B7 %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Show notifications</string>
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
@@ -12,6 +12,8 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.AppleDeviceProfile
@@ -56,6 +58,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var batteryEstimator: BatteryEstimator
private val timeSource: TimeSource = TestTimeSource()
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
@@ -68,6 +71,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
private lateinit var fakeBatteryEstimateEnabled: FakeDataStoreValue<Boolean>
@BeforeEach
fun setup() {
@@ -83,6 +87,7 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -101,6 +106,11 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
every { it.batteryEstimateEnabled } returns fakeBatteryEstimateEnabled.mock
}
batteryEstimator = mockk<BatteryEstimator>().also {
every { it.estimates } returns MutableStateFlow(emptyMap<String, BatteryEstimate>())
}
monitorModeResolver = mockk<MonitorModeResolver>().also {
@@ -139,6 +149,7 @@ class OverviewViewModelTest : BaseTest() {
profilesRepo = profilesRepo,
aapManager = mockk(relaxed = true),
monitorModeResolver = monitorModeResolver,
batteryEstimator = batteryEstimator,
timeSource = timeSource,
)
@@ -0,0 +1,148 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.TimeSource
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class BatteryEstimatorTest : BaseTest() {
private val now = Instant.parse("2026-04-02T12:00:00Z")
private fun device(
profileId: String?,
left: Float?,
right: Float?,
charging: Boolean = false,
): PodDevice {
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
val batteries = buildMap {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
}
return PodDevice(profileId = profileId, ble = null, aap = AapPodState(batteries = batteries))
}
private fun estimator(
emissions: List<List<PodDevice>>,
stored: Map<String, DrainProfile> = emptyMap(),
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
): BatteryEstimator {
val deviceMonitor = mockk<DeviceMonitor> {
every { devices } returns flowOf(*emissions.toTypedArray())
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(stored)
coEvery { save(any(), any()) } returns Unit
}
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany clockMs
every { now() } returns now
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
}
/**
* Runs the estimator over its (finite) device flow and returns the last non-empty estimate map
* seen *during* collection. monitor() clears estimates on completion (it stops with the service),
* so we capture the live value as it is produced rather than reading it after the flow ends.
*/
private suspend fun TestScope.collectEstimate(estimator: BatteryEstimator): Map<String, BatteryEstimate> {
val captured = mutableListOf<Map<String, BatteryEstimate>>()
backgroundScope.launch { estimator.estimates.collect { captured += it } }
estimator.monitor().collect {}
return captured.lastOrNull { it.isNotEmpty() } ?: emptyMap()
}
@Test
fun `cached-only device is not sampled`() = runTest(UnconfinedTestDispatcher()) {
// ble == null && aap == null -> not live -> ignored.
val offline = PodDevice(profileId = "p1", ble = null, aap = null)
collectEstimate(estimator(listOf(listOf(offline)))) shouldBe emptyMap()
}
@Test
fun `ambiguous same-profile devices are skipped`() = runTest(UnconfinedTestDispatcher()) {
val a = device("p1", left = 0.80f, right = 0.80f)
val b = device("p1", left = 0.50f, right = 0.50f)
collectEstimate(estimator(listOf(listOf(a, b)))) shouldBe emptyMap()
}
@Test
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true))),
stored = stored,
)
)
result shouldBe emptyMap()
}
@Test
fun `a learned rate seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
// One emission, only one sample -> no live regression -> must fall back to learned rate.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe true
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@Test
fun `a steady discharge yields a live estimate`() = runTest(UnconfinedTestDispatcher()) {
// 5 snapshots, 1% lost every 4 minutes -> 15%/hr -> 0.15 fraction/hr.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe false
}
@Test
fun `pods draining at different rates get independent estimates`() = runTest(UnconfinedTestDispatcher()) {
// Left drains faster (1.25%/step) than right (1%/step) over the same 4-minute steps.
val emissions = (0 until 5).map { i ->
listOf(device("p1", left = 0.80f - i * 0.0125f, right = 0.80f - i * 0.01f))
}
val estimate = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull()
val left = estimate.left.shouldNotBeNull()
val right = estimate.right.shouldNotBeNull()
left.isLearned shouldBe false
right.isLearned shouldBe false
// Faster-draining left pod must empty sooner than the right.
(left.minutesRemaining < right.minutesRemaining) shouldBe true
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
updatedAt = now,
)
}
@@ -0,0 +1,109 @@
package eu.darken.capod.monitor.core.battery
import io.kotest.matchers.floats.plusOrMinus
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class DrainModelTest : BaseTest() {
/** Samples draining at a constant rate, [perMinute] fraction lost per minute. */
private fun drainingSamples(
start: Float,
perMinute: Float,
count: Int,
stepMinutes: Long = 4,
): List<DrainSample> = (0 until count).map { i ->
DrainSample(
atElapsedMs = i * stepMinutes * 60_000L,
fraction = start - perMinute * (i * stepMinutes),
)
}
@Test
fun `slope recovers a constant drain rate in fraction per hour`() {
// 0.25% per minute == 15% per hour == 0.15 fraction/hour.
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))
rate.shouldNotBeNull()
rate shouldBe (0.15f plusOrMinus 0.01f)
}
@Test
fun `rate is a fraction not a percentage`() {
// Sanity guard against a 100x unit error: a ~15%/hr drain must be ~0.15, never ~15.
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))!!
(rate < 1f) shouldBe true
}
@Test
fun `too few samples yields null`() {
DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 3)).shouldBeNull()
}
@Test
fun `a too-short window is rejected`() {
// 4 samples 30s apart: enough points, but span < MIN_SPAN_MS.
val samples = (0 until 4).map { DrainSample(it * 30_000L, 0.80f - it * 0.01f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `a negligible drop is rejected`() {
// Long enough window but total drop below MIN_TOTAL_DROP.
val samples = (0 until 5).map { DrainSample(it * 5 * 60_000L, 0.80f - it * 0.002f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `a charging-style increase is not a drain`() {
val samples = (0 until 5).map { DrainSample(it * 4 * 60_000L, 0.50f + it * 0.02f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `an implausibly fast drain is rejected`() {
// 4 rapid 1% ticks spanning > MIN_SPAN but dropping far too fast (~120%/hr).
val samples = (0 until 5).map { DrainSample(it * 60_000L, 0.80f - it * 0.02f) }
.let { it + DrainSample(it.size * 60_000L, 0.70f) } // keep span > 3 min
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `minutesRemaining divides level by rate`() {
// 50% left at 0.15/hr -> 0.5 / 0.15 * 60 = 200 minutes.
DrainModel.minutesRemaining(0.50f, 0.15f) shouldBe 200
}
@Test
fun `minutesRemaining rejects a non-positive rate`() {
DrainModel.minutesRemaining(0.50f, 0f).shouldBeNull()
}
@Test
fun `minutesRemaining suppresses absurd estimates`() {
// Extremely slow rate -> beyond MAX_MINUTES -> suppressed.
DrainModel.minutesRemaining(1.0f, 0.0001f).shouldBeNull()
}
@Test
fun `blendMinutes seeds then smooths`() {
DrainModel.blendMinutes(previous = null, next = 100) shouldBe 100
// 0.3 * 200 + 0.7 * 100 = 130
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
}
@Test
fun `blendRate seeds then smooths`() {
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
DrainModel.blendRate(previous = 0.1f, next = 0.2f, alpha = 0.3f) shouldBe (0.13f plusOrMinus 0.0001f)
}
@Test
fun `regression denominator is well conditioned for spread samples`() {
// Guard that real spread input produces a finite, positive rate (no divide-by-zero path).
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.90f, 0.003f, count = 8))!!
(rate.isFinite() && rate > 0f) shouldBe true
}
}
@@ -31,6 +31,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
generalSettings = mockk(relaxed = true),
settings = settings,
deviceStateCache = mockk(relaxed = true),
batteryDrainStore = mockk(relaxed = true),
json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true },
)