Skip to content

Commit 5e9aaa0

Browse files
authored
Dashboard: Show charging speed and wattage in more places (#55)
* Battery: Read the charger-advertised maximum from the battery broadcast The sticky battery broadcast carries EXTRA_MAX_CHARGING_CURRENT and EXTRA_MAX_CHARGING_VOLTAGE: what the connected supply claims it can deliver. Both land on BatteryReadout as nullable fields (absent or non-positive normalizes to null: a device with nothing connected reports 0, which is "no charger", not "a 0 W charger"). StatsPowerCalculator gains advertisedMaxMilliwatts for that pair, with AOSP BatteryStatus' 5 V fallback when only the current is reported, plus readout-taking overloads so callers don't re-derive the charge-power gate. The existing implausibility cap applies to the advertised figure too. Also adds the strings the upcoming wattage surfaces need. * Dashboard: Title the charging card by charging speed The card headline said only "Charging" whatever the charger delivered. It now classifies the measured draw into slowly / (plain) / fast / very fast. The slow and fast bars are AOSP SettingsLib's own bucket values (5 W / 7.5 W), applied to the measured draw rather than the charger-advertised maximum AOSP classifies: the measurement is what the battery actually receives and is the number printed one line below the headline. The very-fast bar at 15 W is ours, twice the AOSP fast bar, so modern 20 W+ chargers aren't all lumped in as merely "fast". A draw that can't be measured keeps the plain title rather than guessing a speed. BatteryEffect moves from main/ui/dashboard to battery/ui: its tri-state "is the battery positively not charging" semantics are needed by the battery hub and the session detail screen, which must not import a dashboard presentation file. It gains chargePowerFallbackRes, the shared rule for what to print in place of a withheld wattage. * Battery: Show live charge power in the hub and the session detail The wattage was only ever visible in the dashboard card's reading line and as a recorded peak/average. The battery hub's electrical section now carries "Charge power" (the gated live figure, so a discharge draw can never read as charge power) and "Charger max" (the supply's advertised capability, read through only while something is connected). The charge session detail gains a live "Power now" row, but only while the viewed session is the open one the caller vouched for: the detail query is by id and unrestricted, so it can resolve a dangling open row from an earlier boot that the boot-scoped live query rejects. The activity therefore attributes the readout only when the viewed id equals the live session's id. Both surfaces distinguish a withheld figure from a missing one: "Not charging" where the battery positively reported it isn't taking charge, "Not reported" everywhere else. * Fix: Read the charger-maximum extras by their literal keys BatteryManager.EXTRA_MAX_CHARGING_CURRENT/VOLTAGE are @hide constants: BatteryService puts both into ACTION_BATTERY_CHANGED, but the symbols are absent from the public SDK's android.jar, so referencing them fails to compile. The extras themselves are readable (only the constants are missing), so the reader uses the stable AOSP string keys instead. No reflection is involved.
1 parent 2bdede6 commit 5e9aaa0

18 files changed

Lines changed: 608 additions & 39 deletions

app/src/main/java/eu/darken/amply/battery/core/BatteryReader.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class BatteryReader @Inject constructor(
5151
currentNowMicroamps = manager.propertyOrAbsent(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW),
5252
chargeCounterMicroampHours = manager.propertyOrAbsent(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER),
5353
cycleCount = cycleCount(battery),
54+
maxChargingCurrentMicroamps = battery.getIntExtra(EXTRA_MAX_CHARGING_CURRENT, ABSENT),
55+
maxChargingVoltageMicrovolts = battery.getIntExtra(EXTRA_MAX_CHARGING_VOLTAGE, ABSENT),
5456
)
5557
}
5658

@@ -66,5 +68,11 @@ class BatteryReader @Inject constructor(
6668

6769
// BatteryManager.EXTRA_CYCLE_COUNT — inlined to avoid a hard API-34 symbol reference.
6870
const val EXTRA_CYCLE_COUNT = "android.os.extra.CYCLE_COUNT"
71+
72+
// BatteryManager.EXTRA_MAX_CHARGING_CURRENT/VOLTAGE are @hide: BatteryService puts them into
73+
// ACTION_BATTERY_CHANGED, but the constants are absent from the public SDK. The literal keys
74+
// are stable AOSP identifiers.
75+
const val EXTRA_MAX_CHARGING_CURRENT = "max_charging_current"
76+
const val EXTRA_MAX_CHARGING_VOLTAGE = "max_charging_voltage"
6977
}
7078
}

app/src/main/java/eu/darken/amply/battery/core/BatteryReadout.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ data class BatteryReadout(
2323
val currentNowMicroamps: Int? = null,
2424
val chargeCounterMicroampHours: Int? = null,
2525
val cycleCount: Int? = null,
26+
/**
27+
* Charger-advertised maximum, not a measurement: what the connected supply says it can deliver.
28+
* Only meaningful while something is connected, and never a substitute for the measured draw.
29+
*
30+
* Sourced from the `max_charging_current` / `max_charging_voltage` extras of
31+
* [android.content.Intent.ACTION_BATTERY_CHANGED]. The framework populates them, but their
32+
* `BatteryManager` constants are `@hide` and absent from the public SDK, so `BatteryReader` reads
33+
* the literal AOSP keys.
34+
*/
35+
val maxChargingCurrentMicroamps: Int? = null,
36+
val maxChargingVoltageMicrovolts: Int? = null,
2637
) {
2738
/**
2839
* External power is reported. A null (not reported) [plugged] collapses conservatively to false —

app/src/main/java/eu/darken/amply/battery/core/BatteryReadoutFactory.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ object BatteryReadoutFactory {
2929
currentNowMicroamps: Int = ABSENT,
3030
chargeCounterMicroampHours: Int = ABSENT,
3131
cycleCount: Int = ABSENT,
32+
maxChargingCurrentMicroamps: Int = ABSENT,
33+
maxChargingVoltageMicrovolts: Int = ABSENT,
3234
): BatteryReadout = BatteryReadout(
3335
levelPercent = percentOf(level, scale),
3436
status = status.orNull(),
@@ -42,6 +44,10 @@ object BatteryReadoutFactory {
4244
currentNowMicroamps = currentNowMicroamps.orNull(),
4345
chargeCounterMicroampHours = chargeCounterMicroampHours.orNull(),
4446
cycleCount = cycleCount.orNull(),
47+
// The charger extras are advertised capabilities: a device that reports them while nothing is
48+
// connected reports 0, which is "no charger" rather than "a 0 W charger".
49+
maxChargingCurrentMicroamps = maxChargingCurrentMicroamps.positiveOrNull(),
50+
maxChargingVoltageMicrovolts = maxChargingVoltageMicrovolts.positiveOrNull(),
4551
)
4652

4753
/** Percent only when the level/scale pair is internally consistent; otherwise `null`. */
@@ -51,4 +57,6 @@ object BatteryReadoutFactory {
5157
}
5258

5359
private fun Int.orNull(): Int? = if (this == ABSENT) null else this
60+
61+
private fun Int.positiveOrNull(): Int? = if (this == ABSENT || this <= 0) null else this
5462
}

app/src/main/java/eu/darken/amply/main/ui/dashboard/BatteryEffect.kt renamed to app/src/main/java/eu/darken/amply/battery/ui/BatteryEffect.kt

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
package eu.darken.amply.main.ui.dashboard
1+
package eu.darken.amply.battery.ui
22

33
import android.os.BatteryManager
4+
import androidx.annotation.StringRes
5+
import eu.darken.amply.R
46
import eu.darken.amply.battery.core.BatteryReadout
57
import eu.darken.amply.stats.core.StatsPowerCalculator
68

@@ -76,15 +78,31 @@ sealed interface BatteryEffect {
7678
}
7779

7880
/**
79-
* Charge power for the charging card's reading line, or `null` when no figure may be shown.
81+
* Charge power for a live reading line, or `null` when no figure may be shown.
8082
*
8183
* Delegates to [StatsPowerCalculator.chargeMilliwatts] rather than re-deriving the rule, so the
8284
* live headline and the recorded curve/peak/average can never disagree about what counts as charge
8385
* power.
8486
*/
85-
fun chargePowerMilliwatts(readout: BatteryReadout): Int? = StatsPowerCalculator.chargeMilliwatts(
86-
batteryStatus = readout.status,
87-
plugged = readout.onCharger,
88-
voltageMillivolts = readout.voltageMillivolts,
89-
currentNowMicroamps = readout.currentNowMicroamps,
90-
)
87+
fun chargePowerMilliwatts(readout: BatteryReadout): Int? = StatsPowerCalculator.chargeMilliwatts(readout)
88+
89+
/**
90+
* What to show in place of a withheld charge power, as a string resource.
91+
*
92+
* "Not charging" is only used where the battery *positively* reported that it isn't taking charge
93+
* (unplugged, held at a limit, full). Everywhere else the figure is missing rather than zero — a
94+
* connected device with no usable status has not told us it isn't charging — so it falls back to the
95+
* shared "Not reported".
96+
*/
97+
@StringRes
98+
fun chargePowerFallbackRes(effect: BatteryEffect): Int = when (effect) {
99+
is BatteryEffect.OnBattery,
100+
is BatteryEffect.ConnectedNotCharging,
101+
is BatteryEffect.Full,
102+
-> R.string.battery_value_not_charging
103+
104+
is BatteryEffect.Charging,
105+
is BatteryEffect.Connected,
106+
BatteryEffect.Unknown,
107+
-> R.string.battery_value_not_reported
108+
}

app/src/main/java/eu/darken/amply/main/ui/MainActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,14 @@ class MainActivity : ComponentActivity() {
430430
val statsDetail by statsViewModel.detailState.collectAsState()
431431
StatsSessionDetailScreen(
432432
state = statsDetail,
433+
// Live wattage only for the session that is actually charging now. The
434+
// detail query is by id and unrestricted, so it can resolve a dangling
435+
// open row from an earlier boot that the boot-scoped live query rejects
436+
// — attributing the current draw to it would be a fabrication.
437+
readout = state.batteryReadout.takeIf {
438+
val live = state.stats.live
439+
live != null && statsDetail?.summary?.id == live.id
440+
},
433441
onBack = {
434442
statsViewModel.closeSession()
435443
destination = detailOrigin

app/src/main/java/eu/darken/amply/main/ui/battery/BatteryHubScreen.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import androidx.compose.ui.tooling.preview.Preview
2727
import androidx.compose.ui.unit.dp
2828
import eu.darken.amply.R
2929
import eu.darken.amply.battery.core.BatteryReadout
30+
import eu.darken.amply.battery.ui.BatteryEffect
3031
import eu.darken.amply.battery.ui.batteryHealthLabel
3132
import eu.darken.amply.battery.ui.batteryPlugLabel
3233
import eu.darken.amply.battery.ui.batteryStatusLabel
34+
import eu.darken.amply.battery.ui.chargePowerFallbackRes
3335
import eu.darken.amply.battery.ui.formatChargeCounter
3436
import eu.darken.amply.battery.ui.formatCurrent
3537
import eu.darken.amply.battery.ui.formatTemperature
@@ -38,6 +40,8 @@ import eu.darken.amply.common.compose.AmplyCard
3840
import eu.darken.amply.common.compose.AmplyCardDefaults
3941
import eu.darken.amply.common.compose.AmplyPreview
4042
import eu.darken.amply.common.compose.PreviewWrapper
43+
import eu.darken.amply.stats.core.StatsPowerCalculator
44+
import eu.darken.amply.stats.ui.StatsFormat
4145

4246
/**
4347
* The single battery/charging destination: the current or last charge and the full live readout, led
@@ -185,6 +189,21 @@ private fun ElectricalSection(readout: BatteryReadout) {
185189
stringResource(R.string.battery_detail_current),
186190
formatCurrent(readout.currentNowMicroamps) ?: notReported,
187191
)
192+
// Voltage × current above is a magnitude in either direction; this row is the gated charge
193+
// power, so a discharge draw can never be read here as charge power.
194+
DetailRow(
195+
stringResource(R.string.battery_detail_power),
196+
StatsFormat.power(StatsPowerCalculator.chargeMilliwatts(readout))
197+
?: stringResource(chargePowerFallbackRes(BatteryEffect.from(readout))),
198+
)
199+
// Advertised, not measured: what the connected supply claims. Nothing connected means nothing
200+
// to claim, so the extras are only read through while on a charger.
201+
DetailRow(
202+
stringResource(R.string.battery_detail_charger_max),
203+
readout.takeIf { it.onCharger }
204+
?.let { StatsFormat.power(StatsPowerCalculator.advertisedMaxMilliwatts(it)) }
205+
?: notReported,
206+
)
188207
DetailRow(
189208
stringResource(R.string.battery_detail_charge_counter),
190209
formatChargeCounter(readout.chargeCounterMicroampHours) ?: notReported,
@@ -251,6 +270,9 @@ private val previewCharging = BatteryReadout(
251270
currentNowMicroamps = 1_250_000,
252271
chargeCounterMicroampHours = 3_800_000,
253272
cycleCount = 142,
273+
// A 9 V / 2 A charger advertising itself while the battery measures ~5.2 W.
274+
maxChargingCurrentMicroamps = 2_000_000,
275+
maxChargingVoltageMicrovolts = 9_000_000,
254276
)
255277

256278
@AmplyPreview

app/src/main/java/eu/darken/amply/main/ui/dashboard/ChargingCard.kt

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eu.darken.amply.main.ui.dashboard
22

33
import android.os.BatteryManager
44
import android.os.SystemClock
5+
import androidx.annotation.StringRes
56
import androidx.compose.foundation.layout.Arrangement
67
import androidx.compose.foundation.layout.Column
78
import androidx.compose.foundation.layout.ColumnScope
@@ -22,6 +23,7 @@ import androidx.compose.ui.unit.dp
2223
import eu.darken.amply.R
2324
import eu.darken.amply.battery.core.BatteryReadout
2425
import eu.darken.amply.battery.ui.batteryStatusLabel
26+
import eu.darken.amply.battery.ui.chargePowerMilliwatts
2527
import eu.darken.amply.battery.ui.formatTemperature
2628
import eu.darken.amply.common.compose.AmplyNavigationCard
2729
import eu.darken.amply.common.compose.AmplyPreview
@@ -87,7 +89,7 @@ fun ChargingCard(
8789
onClickLabel = stringResource(R.string.dashboard_charging_open_action),
8890
title = stringResource(
8991
when {
90-
charging -> R.string.dashboard_charging_title_charging
92+
charging -> chargingTitle(chargingSpeed(chargePowerMilliwatts(battery)))
9193
onCharger -> R.string.dashboard_charging_title_connected
9294
else -> R.string.dashboard_charging_title_idle
9395
},
@@ -114,6 +116,18 @@ fun ChargingCard(
114116
}
115117
}
116118

119+
/**
120+
* The headline while charging. An unclassifiable draw (no figure, or a device that reports none)
121+
* stays at the plain "Charging" — the card never guesses a speed it can't measure.
122+
*/
123+
@StringRes
124+
private fun chargingTitle(speed: ChargingSpeed?): Int = when (speed) {
125+
ChargingSpeed.VERY_FAST -> R.string.dashboard_charging_title_very_fast
126+
ChargingSpeed.FAST -> R.string.dashboard_charging_title_fast
127+
ChargingSpeed.SLOW -> R.string.dashboard_charging_title_slow
128+
ChargingSpeed.NORMAL, null -> R.string.dashboard_charging_title_charging
129+
}
130+
117131
/**
118132
* "82% · Charging · 12.3 W · 31.4 °C" — the live reading.
119133
*
@@ -242,6 +256,19 @@ private val previewCharging = BatteryReadout(
242256
currentNowMicroamps = 2_050_000,
243257
)
244258

259+
// ~0.6 W — a trickle from a weak supply.
260+
private val previewChargingSlow = previewCharging.copy(
261+
levelPercent = 41,
262+
currentNowMicroamps = 150_000,
263+
)
264+
265+
// ~19.8 W — a dual-cell pack reporting its own voltage on a high-power charger.
266+
private val previewChargingVeryFast = previewCharging.copy(
267+
levelPercent = 22,
268+
voltageMillivolts = 9_000,
269+
currentNowMicroamps = 2_200_000,
270+
)
271+
245272
private val previewHolding = previewCharging.copy(
246273
levelPercent = 80,
247274
status = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
@@ -297,6 +324,36 @@ private fun ChargingCardLivePreview() = PreviewWrapper {
297324
}
298325
}
299326

327+
@AmplyPreview
328+
@Composable
329+
private fun ChargingCardSpeedPreview() = PreviewWrapper {
330+
// The headline follows the measured draw: trickle, fast (the default fixture), and a high-power
331+
// charger. A device that reports no draw keeps the plain "Charging".
332+
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
333+
ChargingCard(
334+
presentation = ChargingCardPresentation.Live(session = previewLiveSession),
335+
readout = previewChargingSlow,
336+
onOpenHub = {},
337+
onRetryCapture = {},
338+
nowElapsedRealtimeMillis = 4_320_000L,
339+
)
340+
ChargingCard(
341+
presentation = ChargingCardPresentation.Live(session = previewLiveSession),
342+
readout = previewChargingVeryFast,
343+
onOpenHub = {},
344+
onRetryCapture = {},
345+
nowElapsedRealtimeMillis = 4_320_000L,
346+
)
347+
ChargingCard(
348+
presentation = ChargingCardPresentation.Live(session = previewLiveSession),
349+
readout = previewCharging.copy(currentNowMicroamps = null),
350+
onOpenHub = {},
351+
onRetryCapture = {},
352+
nowElapsedRealtimeMillis = 4_320_000L,
353+
)
354+
}
355+
}
356+
300357
@AmplyPreview
301358
@Composable
302359
private fun ChargingCardConnectedPreview() = PreviewWrapper {

app/src/main/java/eu/darken/amply/main/ui/dashboard/ChargingCardPresentation.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,31 @@ sealed interface ChargingCardPresentation {
103103
}
104104
}
105105
}
106+
107+
/** How fast the battery is being charged, for the card's headline. */
108+
enum class ChargingSpeed {
109+
SLOW,
110+
NORMAL,
111+
FAST,
112+
VERY_FAST,
113+
}
114+
115+
/**
116+
* Buckets a measured charge power, or `null` when there is no figure to classify — an unknown speed
117+
* is not a normal one, and the caller falls back to the plain "Charging" headline.
118+
*
119+
* The SLOW and FAST bars are AOSP SettingsLib's own bucket values (`config_chargingSlowlyThreshold`
120+
* 5 W / `config_chargingFastThreshold` 7.5 W), applied here to the *measured* draw rather than to the
121+
* charger-advertised maximum AOSP uses — the measurement is what the battery actually receives, and it
122+
* is the number shown one line below the headline. VERY_FAST at 15 W is Amply's own, twice the AOSP
123+
* fast bar, so the modern 20 W+ chargers that would otherwise all read "fast" are distinguishable.
124+
*/
125+
fun chargingSpeed(milliwatts: Int?): ChargingSpeed? {
126+
if (milliwatts == null || milliwatts <= 0) return null
127+
return when {
128+
milliwatts < 5_000 -> ChargingSpeed.SLOW
129+
milliwatts > 15_000 -> ChargingSpeed.VERY_FAST
130+
milliwatts > 7_500 -> ChargingSpeed.FAST
131+
else -> ChargingSpeed.NORMAL
132+
}
133+
}

app/src/main/java/eu/darken/amply/main/ui/dashboard/DashboardScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import eu.darken.amply.fullcharge.core.InterruptionReason
8484
import eu.darken.amply.fullcharge.core.policyOrNull
8585
import eu.darken.amply.main.core.formatReport
8686
import eu.darken.amply.battery.core.BatteryReadout
87+
import eu.darken.amply.battery.ui.BatteryEffect
8788
import eu.darken.amply.main.ui.setup.AccessSetupGuide
8889
import eu.darken.amply.main.ui.setup.OemGuideCard
8990
import eu.darken.amply.main.ui.setup.UnsupportedDeviceCard

app/src/main/java/eu/darken/amply/stats/core/StatsPowerCalculator.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package eu.darken.amply.stats.core
22

33
import android.os.BatteryManager
4+
import eu.darken.amply.battery.core.BatteryReadout
45
import kotlin.math.abs
56

67
/**
@@ -18,6 +19,9 @@ object StatsPowerCalculator {
1819
/** 250 W — generously above any phone/tablet charger; anything larger is a bad OEM reading. */
1920
const val MAX_PLAUSIBLE_MILLIWATTS = 250_000
2021

22+
/** AOSP's assumed charging voltage when a charger advertises a current but no voltage. */
23+
private const val DEFAULT_CHARGING_VOLTAGE_MICROVOLTS = 5_000_000
24+
2125
fun milliwatts(voltageMillivolts: Int?, currentNowMicroamps: Int?): Int? {
2226
if (voltageMillivolts == null || currentNowMicroamps == null) return null
2327
if (voltageMillivolts <= 0) return null
@@ -49,4 +53,39 @@ object StatsPowerCalculator {
4953
if (batteryStatus != BatteryManager.BATTERY_STATUS_CHARGING) return null
5054
return milliwatts(voltageMillivolts, currentNowMicroamps)
5155
}
56+
57+
/** [chargeMilliwatts] straight off a readout; `null` readout (nothing observed) yields `null`. */
58+
fun chargeMilliwatts(readout: BatteryReadout?): Int? {
59+
if (readout == null) return null
60+
return chargeMilliwatts(
61+
batteryStatus = readout.status,
62+
plugged = readout.onCharger,
63+
voltageMillivolts = readout.voltageMillivolts,
64+
currentNowMicroamps = readout.currentNowMicroamps,
65+
)
66+
}
67+
68+
/**
69+
* What the connected charger *advertises* it can deliver, in milliwatts — not a measurement, and
70+
* never a stand-in for [chargeMilliwatts].
71+
*
72+
* A missing/non-positive voltage falls back to 5 V, matching AOSP's own `BatteryStatus`: chargers
73+
* that report a current but no voltage are USB-spec 5 V supplies, and dropping the figure entirely
74+
* would hide the more commonly reported half of the pair.
75+
*/
76+
fun advertisedMaxMilliwatts(maxCurrentMicroamps: Int?, maxVoltageMicrovolts: Int?): Int? {
77+
if (maxCurrentMicroamps == null || maxCurrentMicroamps <= 0) return null
78+
val microvolts = maxVoltageMicrovolts?.takeIf { it > 0 } ?: DEFAULT_CHARGING_VOLTAGE_MICROVOLTS
79+
val mw = (maxCurrentMicroamps.toLong() / 1000L) * (microvolts.toLong() / 1000L) / 1000L
80+
if (mw <= 0 || mw > MAX_PLAUSIBLE_MILLIWATTS) return null
81+
return mw.toInt()
82+
}
83+
84+
fun advertisedMaxMilliwatts(readout: BatteryReadout?): Int? {
85+
if (readout == null) return null
86+
return advertisedMaxMilliwatts(
87+
maxCurrentMicroamps = readout.maxChargingCurrentMicroamps,
88+
maxVoltageMicrovolts = readout.maxChargingVoltageMicrovolts,
89+
)
90+
}
5291
}

0 commit comments

Comments
 (0)