feat(battery): Show battery estimates in widget and expanded notification

This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent b297ad8564
commit 52a7850c1d
18 changed files with 430 additions and 88 deletions
@@ -21,11 +21,13 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
@@ -229,7 +231,7 @@ internal fun HomescreenWidgetContent() {
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
),
).withLocalizedPreviewEstimates(LocalContext.current),
)
}
}
+14 -2
View File
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -36,6 +40,7 @@ open class App : Application() {
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -62,8 +67,15 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles()
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
combine(
deviceMonitor.devicesWithProfiles(),
batteryEstimator.estimates,
) { devices, estimates -> devices to estimates }
// Estimate display values can change while the device key is stable (e.g. a charge
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
.distinctUntilChangedBy { (devices, estimates) ->
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
}
.throttleLatest(1000)
.onEach {
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
@@ -25,6 +25,7 @@ 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.battery.estimateFor
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -247,12 +248,7 @@ class OverviewViewModel @Inject constructor(
* Time-remaining estimate to show for [device], or null when the device has the estimate
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
*/
fun estimateFor(device: PodDevice): BatteryEstimate? {
if (!device.batteryEstimateEnabled) return null
if (!device.isLive) return null
val profileId = device.profileId ?: return null
return batteryEstimates[profileId]
}
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
/** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy {
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.takeFor
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
fun batteryEstimator(): BatteryEstimator
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
// Empty while the monitor service isn't running — estimates simply stay hidden.
val rawEstimate by config.profileId
?.let { pid ->
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
}
?.collectAsState(initial = null)
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
)
} else {
WidgetRenderState.Message(
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
)
).withLocalizedPreviewEstimates(context)
provideContent {
GlanceWidgetContent(state = previewState, context = context)
@@ -96,9 +96,9 @@ private fun DualPodPreview(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
DeviceLabel(
label = state.deviceLabel,
@@ -111,8 +111,8 @@ private fun DualPodPreview(
BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
DeviceLabel(
label = state.deviceLabel,
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -297,39 +298,54 @@ private fun PodItemRow(
textColor: Color,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: Modifier = Modifier,
) {
Row(
Column(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
fontSize = 12.sp,
color = textColor,
maxLines = 1,
)
}
}
@@ -385,6 +401,8 @@ private fun DeviceLabel(
private fun formatPercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@Preview2
@Composable
private fun PreviewDualTiny11() = PreviewWrapper {
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -257,37 +258,49 @@ private fun GlancePodItem(
textStyle: TextStyle,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
Column(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
style = textStyle,
maxLines = 1,
)
}
}
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel)
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
}
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
}
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val isAapConnected: Boolean,
val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
enum class BatteryLayout {
TINY_COLUMN,
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
val leftEstimate: String?,
@DrawableRes val rightIcon: Int,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
val rightEstimate: String?,
@DrawableRes val caseIcon: Int,
val casePercent: Float,
val caseCharging: Boolean,
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
val estimate: String?,
) : WidgetRenderState()
data class Message(
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
leftPercent = 0.85f,
leftCharging = false,
leftInEar = true,
leftEstimate = "4h 55m",
rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f,
rightCharging = true,
rightInEar = false,
rightEstimate = "25m",
caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f,
caseCharging = false,
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
estimate = "14h 30m",
)
}
}
/**
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
*/
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
leftEstimate = formatBatteryDurationShort(context, 295),
rightEstimate = formatBatteryDurationShort(context, 25),
)
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
object WidgetRenderStateMapper {
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
hasConfiguredProfile: Boolean,
profileLabel: String?,
layout: BatteryLayout = BatteryLayout.NARROW,
estimate: BatteryEstimate? = null,
): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme)
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
caseIcon = device.caseIcon,
casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true,
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
)
device != null -> WidgetRenderState.Message(
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
}
}
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
@ColorInt
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
val bgColor = theme.backgroundColor
@@ -0,0 +1,40 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.PodDevice
/**
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
* device is live (no estimates for cached/offline cards), and it maps to a profile.
*/
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
device.batteryEstimateEnabled && device.isLive && device.profileId != null
}
/**
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
* widget, notification) so the display rules can't drift apart.
*/
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
device.profileId?.let { this[it] }?.takeFor(device)
/**
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
* projection. [charging] should be the same flag that drives the visible charging indicator,
* so the shown duration never contradicts the bolt icon next to it.
*/
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
charging -> minutesUntilCharged
else -> minutesRemaining
}
/**
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
* anything. Everything else on the estimate (rates, source) churns without visible effect —
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
*/
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
left?.displayMinutes(device.isLeftPodCharging == true),
right?.displayMinutes(device.isRightPodCharging == true),
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
).takeUnless { key -> key.all { it == null } }
@@ -29,7 +29,10 @@ 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.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
@@ -224,16 +227,34 @@ class MonitorService : Service() {
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
}
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
currentDevice to settings
val monitorJob = combine(
deviceFlow,
notificationSettingsFlow,
batteryEstimator.estimates,
) { currentDevice, settings, estimates ->
NotificationInput(
device = currentDevice,
settings = settings,
estimate = currentDevice?.let { estimates.estimateFor(it) },
)
}
.onEach { (currentDevice, settings) ->
// The estimates map churns on fields the notification doesn't display (and for other
// profiles' devices) — only re-notify when something visible changed.
.distinctUntilChangedBy { input ->
Triple(
input.device?.toNotificationKey(),
input.settings,
input.device?.let { input.estimate?.displayKey(it) },
)
}
.onEach { (currentDevice, settings, estimate) ->
latestNotificationSettings = settings
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
notifications.getNotification(
currentDevice,
estimate = estimate,
showHint = settings.useExtraNotification,
),
)
@@ -241,7 +262,7 @@ class MonitorService : Service() {
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
is ExtraNotificationAction.Post -> notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
notifications.getNotificationConnected(action.device),
notifications.getNotificationConnected(action.device, estimate),
)
ExtraNotificationAction.Cancel -> notificationManager.cancel(
MonitorNotifications.NOTIFICATION_ID_CONNECTED
@@ -430,6 +451,12 @@ internal fun buildMonitorModeState(
hasAapSession = aapStates.isNotEmpty(),
)
internal data class NotificationInput(
val device: PodDevice?,
val settings: NotificationSettings,
val estimate: BatteryEstimate?,
)
private data class NotificationDeviceKey(
val profileId: String?,
val label: String?,
@@ -449,6 +476,7 @@ private data class NotificationDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val iconRes: Int,
val leftPodIcon: Int,
val rightPodIcon: Int,
@@ -474,6 +502,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
iconRes = iconRes,
leftPodIcon = leftPodIcon,
rightPodIcon = rightPodIcon,
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
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.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@@ -83,13 +86,13 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
fun createBigContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device, estimate)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
else -> createUnknownDeviceBig(device)
}
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_big
).apply {
@@ -97,7 +100,11 @@ class MonitorNotificationViewFactory @Inject constructor(
val leftPercent = device.batteryLeft
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
setTextViewText(
R.id.pod_left_label,
formatBatteryPercent(context, leftPercent) +
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
)
val isLeftPodCharging = device.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = device.isLeftInEar ?: false
@@ -117,14 +124,18 @@ class MonitorNotificationViewFactory @Inject constructor(
val rightPercent = device.batteryRight
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
setTextViewText(
R.id.pod_right_label,
formatBatteryPercent(context, rightPercent) +
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
)
val isRightPodCharging = device.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = device.isRightInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_big
).apply {
@@ -132,7 +143,11 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
setTextViewText(
R.id.headphones_battery_label,
formatBatteryPercent(context, headsetPercent) +
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
)
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
@@ -154,4 +169,7 @@ class MonitorNotificationViewFactory @Inject constructor(
private fun percentToInt(percent: Float): Int =
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
}
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
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.ble.formatBatteryPercent
import javax.inject.Inject
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
private fun getBuilder(
device: PodDevice?,
channelId: String,
showHint: Boolean = false
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): NotificationCompat.Builder {
if (device == null) {
return baseBuilder(channelId).apply {
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomContentView(notificationViewFactory.createContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
setContentTitle("$batteryText ~ $stateText")
setSubText(null)
log(TAG, VERBOSE) { "updatingNotification(): $device" }
}
}
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
fun getNotification(
podDevice: PodDevice?,
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
fun getNotificationConnected(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
fun getStartupNotification(): Notification =
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
@@ -45,7 +45,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="95%" />
tools:text="95% · 5h 40m" />
<LinearLayout
android:layout_width="36dp"
@@ -163,7 +163,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="92%" />
tools:text="92% · 5h 25m" />
<LinearLayout
android:layout_width="36dp"
@@ -64,7 +64,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="95%" />
tools:text="95% · 14h 30m" />
<ImageView
android:id="@+id/headphones_charging"
@@ -73,6 +73,14 @@ class WidgetDeviceKeyTest : BaseTest() {
.toWidgetKey() shouldNotBe base
}
@Test
fun `battery estimate toggle alters key`() {
val enabled = dualDevice().toWidgetKey()
val disabled = dualDevice(batteryEstimateEnabled = false).toWidgetKey()
disabled shouldNotBe enabled
}
@Test
fun `labels and wear state alter key`() {
val dualBase = dualDevice().toWidgetKey()
@@ -126,6 +134,7 @@ class WidgetDeviceKeyTest : BaseTest() {
label: String? = dualProfile.label,
ble: BlePodSnapshot? = FakeDualBlePod(profile = dualProfile),
aap: AapPodState? = null,
batteryEstimateEnabled: Boolean = true,
) = PodDevice(
profileId = dualProfile.id,
label = label,
@@ -133,6 +142,7 @@ class WidgetDeviceKeyTest : BaseTest() {
aap = aap,
profileAddress = dualProfile.address,
profileModel = dualProfile.model,
batteryEstimateEnabled = batteryEstimateEnabled,
)
private fun singleDevice(
@@ -0,0 +1,154 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.PodDevice
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BatteryEstimateExtensionsTest : BaseTest() {
private val estimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 120, fractionPerHour = 0.2f, source = BatteryEstimate.Source.LIVE),
)
private fun device(
profileId: String? = "profile-1",
live: Boolean = true,
enabled: Boolean = true,
): PodDevice = mockk {
every { this@mockk.profileId } returns profileId
every { this@mockk.isLive } returns live
every { this@mockk.batteryEstimateEnabled } returns enabled
}
@Test
fun `estimateFor returns the profile's estimate for an enabled live device`() {
mapOf("profile-1" to estimate).estimateFor(device()) shouldBe estimate
}
@Test
fun `estimateFor is null when the per-device toggle is off`() {
mapOf("profile-1" to estimate).estimateFor(device(enabled = false)).shouldBeNull()
}
@Test
fun `estimateFor is null for cached-only devices`() {
mapOf("profile-1" to estimate).estimateFor(device(live = false)).shouldBeNull()
}
@Test
fun `estimateFor is null for anonymous devices`() {
mapOf("profile-1" to estimate).estimateFor(device(profileId = null)).shouldBeNull()
}
@Test
fun `estimateFor is null when no estimate exists for the profile`() {
mapOf("other-profile" to estimate).estimateFor(device()).shouldBeNull()
emptyMap<String, BatteryEstimate>().estimateFor(device()).shouldBeNull()
}
@Test
fun `takeFor applies the same gating to an already-resolved estimate`() {
estimate.takeFor(device()) shouldBe estimate
estimate.takeFor(device(enabled = false)).shouldBeNull()
estimate.takeFor(device(live = false)).shouldBeNull()
estimate.takeFor(device(profileId = null)).shouldBeNull()
}
@Test
fun `displayMinutes shows the runtime projection while not charging`() {
val pod = BatteryEstimate.Pod(
minutesRemaining = 300,
fractionPerHour = 0.2f,
source = BatteryEstimate.Source.LIVE,
minutesUntilCharged = 25,
)
pod.displayMinutes(charging = false) shouldBe 300
}
@Test
fun `displayMinutes swaps to the charge ETA while charging`() {
val pod = BatteryEstimate.Pod(
minutesRemaining = 300,
fractionPerHour = 0.2f,
source = BatteryEstimate.Source.LIVE,
minutesUntilCharged = 25,
)
pod.displayMinutes(charging = true) shouldBe 25
}
@Test
fun `displayMinutes shows nothing while charging without a usable ETA`() {
val pod = BatteryEstimate.Pod(
minutesRemaining = 300,
fractionPerHour = 0.2f,
source = BatteryEstimate.Source.LIVE,
minutesUntilCharged = null,
)
pod.displayMinutes(charging = true).shouldBeNull()
}
private fun chargingDevice(
leftCharging: Boolean = false,
rightCharging: Boolean = false,
headsetCharging: Boolean = false,
): PodDevice = mockk {
every { isLeftPodCharging } returns leftCharging
every { isRightPodCharging } returns rightCharging
every { isHeadsetBeingCharged } returns headsetCharging
}
private fun pod(
minutes: Int = 300,
rate: Float = 0.2f,
source: BatteryEstimate.Source = BatteryEstimate.Source.LIVE,
untilCharged: Int? = null,
) = BatteryEstimate.Pod(
minutesRemaining = minutes,
fractionPerHour = rate,
source = source,
minutesUntilCharged = untilCharged,
)
@Test
fun `invisible estimate churn keeps the display key stable`() {
// Rate and source change constantly while the displayed minutes stay put — render
// triggers deduped on the display key must not fire for those.
val before = BatteryEstimate(left = pod(rate = 0.20f, source = BatteryEstimate.Source.LEARNED))
val after = BatteryEstimate(left = pod(rate = 0.21f, source = BatteryEstimate.Source.LIVE))
before.displayKey(chargingDevice()) shouldBe after.displayKey(chargingDevice())
}
@Test
fun `displayed minutes changes alter the display key`() {
val before = BatteryEstimate(left = pod(minutes = 300))
val after = BatteryEstimate(left = pod(minutes = 299))
before.displayKey(chargingDevice()) shouldNotBe after.displayKey(chargingDevice())
}
@Test
fun `display key selects the charge ETA per charging slot`() {
val full = BatteryEstimate(
left = pod(minutes = 300, untilCharged = 25),
right = pod(minutes = 280, untilCharged = null),
headset = null,
)
full.displayKey(chargingDevice(leftCharging = true, rightCharging = true)) shouldBe listOf(25, null, null)
full.displayKey(chargingDevice()) shouldBe listOf(300, 280, null)
}
@Test
fun `display key is null when nothing would be rendered`() {
// A charging pod without a usable ETA renders exactly like no estimate at all.
val suppressed = BatteryEstimate(left = pod(minutes = 300, untilCharged = null))
suppressed.displayKey(chargingDevice(leftCharging = true)).shouldBeNull()
}
}