Skip to content

Battery: See estimated charge times and details for each reading - #86

Merged
d4rken merged 11 commits into
mainfrom
feat/battery-insights
Aug 18, 2026
Merged

Battery: See estimated charge times and details for each reading#86
d4rken merged 11 commits into
mainfrom
feat/battery-insights

Conversation

@d4rken

@d4rken d4rken commented Aug 18, 2026

Copy link
Copy Markdown
Member

What changed

Three additions to the "Battery & charging" screen, built on the charge data Amply already records, so the app is useful on devices where charge control isn't supported:

  • Charge-time estimates from your own recorded charges: time to 80% and 100%, average speed, and a breakdown of where the time goes. A one-line remaining-time estimate also appears in the dashboard's charging card while a charge is running.
  • A tile grid with sparklines for the live readings, replacing the plain label/value list. Every reading that was on the screen before is still there.
  • A per-metric detail screen, opened by tapping a tile: the full charge curve for that reading, its minimum, average and maximum, and a plain-language explanation of what it means.

Estimates come only from charges Amply has actually recorded, so they appear once recording is on and a couple of charges cover the same range. A target Amply has never observed says so instead of guessing, and figures read as a countdown only while the battery is genuinely taking charge.

The detail rows below the tiles are now grouped as "This battery" and "This charger" instead of "Charging"/"Health"/"Electrical", and the row previously labelled "Charge counter" is now "Charge remaining" — it's the charge currently in the cell, not the battery's capacity.

Technical Context

  • Metric minimum/average/maximum come from raw samples before decimation, not from the drawn curve: the downsampler keeps a uniform stride, so an extreme lasting a few samples never survives into the chart. The average is time-weighted to match how ChargeSessionSummary.avgPowerMilliwatts is already folded, so one session can't show two different averages on two screens.
  • A 10% band is usable only once two distinct sessions have crossed it. Counting observations instead would let a single charge corroborate itself, since one charge produces ~10 observations per band. Provenance and average speed count only the sessions behind the figures actually displayed.
  • Band extraction ignores any 1% step spanning a non-BATTERY_STATUS_CHARGING sample. That, not a duration cutoff, is what stops an OEM limit hold from being recorded as a very slow charge — a cutoff would also discard genuinely slow trickle charging, biasing estimates optimistic in exactly the 80-100% stretch this feature explains.
  • The shared history model refreshes on the identities of recent finished sessions plus the retention window, and the fold filters samples by the retention cutoff. Sessions expire by endedAtWallMillis while samples expire by their own wallMillis, so a session held at a limit for days and ended recently keeps its id while losing its early samples.
  • The hub reads a bounded curve, never the unbounded one — a session at an OEM limit stays open for days. Tile tappability is derived from availability computed before decimation, so a metric whose samples all land on dropped indices is still openable.
  • Worth close review: ChargeBandExtractor's state machine (first step of a run discarded as it starts mid-level, plus jump/decrease/backwards-time handling) and ChargeTimeEstimator.project's same-type-versus-pooled fallback.
  • No changes to any charge-control adapter, the capability gate, or the Shizuku/WSS paths.

d4rken added 11 commits August 18, 2026 15:37
…raw samples

The curve points now carry the raw voltage and current the samples have
always persisted, so per-metric surfaces can read them without a schema
change. Unlike power they are not gated on BATTERY_STATUS_CHARGING and
the current keeps its recorded sign: both are directional observations
that stay meaningful while the battery is draining, whereas power is an
unsigned magnitude that would read as a charge rate.

CurveAggregates computes min/avg/max per metric from the undecimated
samples. Taking them off the plotted curve would be wrong: decimation
thins with a uniform stride, so a short temperature or current extreme
is simply not among the surviving points and a printed "Maximum" would
not be the session's. The average is time-weighted the same way the
session summary's average power is folded online (left-Riemann, gaps
capped at 10 minutes) — the recorder samples on level change as well as
on its timer, so a plain mean would over-weight fast charging and put
two different "average power" numbers for one session on two screens.
Sums accumulate in Long/Double because microamp readings overflow an Int
after a few hundred samples.

sessionMetrics() returns the decimated curve and those exact aggregates
from one pass over the same raw samples, so a chart and the statistics
beside it can never disagree.
The "Now" section opens with six tiles — level, charge power, voltage,
current, temperature and status — each showing the live value and, where
the shown charge recorded a shape worth drawing, a sparkline behind it.
No field disappears: every reading that was a detail row is either a tile
or still a row, and the "Not reported" / "Not charging" fallbacks are
unchanged.

The grid is a Column of Rows, not a LazyVerticalGrid: the hub is itself a
LazyColumn and nesting a lazy grid inside one throws. Each row is sized to
its taller tile so a label that wraps at a large font scale cannot stagger
the grid.

A tile navigates when the charge recorded any sample for that metric, not
when the samples vary: a constant reading is still worth opening, since
min == avg == max is an answer and the detail chart plots a zero-range
series on a real axis. Only the sparkline needs variation, and it decides
that itself — a self-normalized series with no range would otherwise draw
a flat midline that reads as a plotted trend but is really the "no range"
fallback. The chevron therefore appears exactly where a tap does
something.

Sparkline is a new minimal canvas rather than LineChart with everything
switched off: LineChart always renders its legend row and reserves an
end-label gutter, neither of which can be disabled, so at tile size the
result would be mostly chrome. It takes real chart points because battery
samples are not evenly spaced in time — spacing them equally would draw a
shape the session never had.

The hub's curve comes from the teaser for a live charge (that window is
already bounded on purpose) and from a new bounded read for a finished
one, so a session left open for days at an OEM limit can never trigger an
unbounded reload per appended sample. The read is built inside the
flatMapLatest so opening the hub never creates stats.db for a user who
has not enabled recording.
Tapping a hub tile opens that metric alone: its latest recorded value,
its curve on a real axis in its own unit, its minimum/average/maximum,
and a plain-language explainer of what the reading means.

The statistics come from the repository's aggregates over the raw
samples, never from the plotted curve — decimation thins with a uniform
stride, so a brief extreme is not among the plotted points and a
"Maximum" recomputed there would be wrong. The chart is LineChart
directly rather than the shared curve chart, which is hard-wired to the
three-series level/power/temperature plot.

The selection is persisted as the pair (session id, metric), written and
cleared together. Persisting the metric alone and letting the session
follow the hub's teaser would silently swap the chart to a different
charge under an unchanged title — a charge starting while the screen is
open, or a process death after the teaser moved on, would both do it. A
saved metric name this build no longer knows clears the selection rather
than throwing on restore.

An absent statistics record hides the row instead of printing zeros, an
empty curve falls back to the chart's own empty label, and a session that
no longer resolves (retention, cleared data) shows the same missing
notice the session detail screen uses rather than an eternal spinner.
The battery hub gets a charge-time card and the dashboard's charging card
gets a single remaining-time line, both projected only from charges this
device has actually recorded. No live extrapolation and no capacity
back-derivation: the figures are what this phone did on this kind of
charger, or they are absent and say so.

The extractor turns a session's samples into completed 1% step durations
under a strict state machine. A step counts only when its start was an
observed transition, so a session's first step — which began before
recording did — is discarded, as is the first step after any break. A
step spanning a non-CHARGING sample is dropped outright: that is the hold
filter, and it is deliberately not a duration cutoff, which would both
admit holds shorter than the cutoff and discard genuinely slow trickle
charging, biasing the 80-100% stretch optimistic. A session parked at an
OEM limit therefore contributes nothing structurally, because it never
completes the step out of the level it is held at.

The estimator is split into a cached history fold and a cheap projection,
so the estimate counts down with the battery instead of being pinned to
the level the model was built at. Rates are per-session-per-band medians
and a band is usable only once two distinct sessions have crossed it: one
charge produces about ten observations inside a 10% band, so an
observation floor would let a single charge pass as corroborated history.
Projections are stratified by charger type with an explicitly labelled
pooled fallback, since a median mixing slow wireless with fast wired
describes neither. Any target crossing an unusable band is null, so a
user who always stops at 80% is told the full-charge figure is unknown
rather than shown an extrapolation over a stretch never observed. The
band split segments are independently nullable for the same reason.

Wording is gated on the battery actually taking charge. A countdown is a
claim that the device is moving toward the target, so unplugged — or held
at a limit, where the platform reports NOT_CHARGING while the session is
still open — the identical figures read as a reference instead. The
dashboard line refuses outright in those cases rather than falling back
to softer copy.

One shared singleton owns the fold for both surfaces, refreshed on the
finished-session count with an explicit distinctUntilChanged: Room
invalidates per table, so every per-tick write to the open session
re-emits an unchanged count and would otherwise re-extract ten sessions
every recorder tick for the whole charge. Nothing touches Room until the
flow is collected, and the whole pipeline is gated on the capture
preference, so a user who never enabled recording never gets stats.db
created by an estimate.
…rawn curve

The hub's sparkline curves are decimated (300 raw samples down to 60) before
any surface sees them, and a tile decided whether to offer its detail screen
by scanning those survivors. A metric reported only intermittently can lose
every one of its readings to the uniform stride, so the tile refused to open a
screen that had data to show — the detail screen reads the undecimated samples.

Metric presence is now computed from the raw points before decimation and
carried alongside the curve: on the live session for a charge in progress, and
with the bounded hub curve for a finished one. The drawn curve is unchanged.

Fixes review finding F1.
…tention window

Two ways the shared history model went stale, and one way it flashed.

The fold rebuilt on the bare finished-session count, so a newly sealed charge
and a retention purge landing in the same Room invalidation window cancelled
out and left both surfaces quoting history that is gone. It now keys on the
ids of the recent finished sessions, which changes whenever a charge is added,
removed or replaced, while still ignoring the per-tick writes to the open
session an ongoing charge produces.

Retention also expires sessions by their end stamp but samples by their own,
so a charge that sat at an OEM limit for days and ended recently keeps its row
while its early samples are already outside the window. The fold now drops
samples before the retention cutoff itself, which makes it describe what
retention will leave regardless of when the purge runs, and the retention
setting joins the refresh key because it decides that cutoff.

Dropped the "onStart { emit(Loading) }": it sat upstream of the shareIn, so it
re-ran on every upstream restart and handed a subscriber returning after the
stop timeout a replayed Ready followed by a fresh Loading, visibly blinking the
charge-time card back to its loading line. First-load Loading already comes
from the downstream initial state and the capture-off branch.

Fixes review findings F2, F5.
…produced them

The provenance line and the average-speed figure were taken from every session
behind any individually usable band of the stratum, no matter which stretches
the card ended up showing. Two charges that only corroborated an isolated
0-10% band could therefore be counted in "From 4 charges" and pulled into the
speed median at 40%, where nothing they contributed to was on screen.

Each band now carries the sessions its median was taken across, a projected
span carries the union over the bands it consumed, and the estimate unions only
the spans that produced a figure. When no target and no split segment can be
projected there is no estimate at all, so the surface falls to its
not-enough-data state instead of a card whose provenance rests on nothing shown.

The pooled fallback also mislabelled itself. It engaged whenever the same-type
history produced no target, but the 80% target is null by rule from 80% up, so
at 82% with same-type history that stops below 80 the card claimed to be drawn
"across all charger types" and rendered the pooled split although same-type
history exists and describes everything else on it (seen on a device during QA).
Pooling now only takes over when it answers a target the same-type history
could not.

Fixes review findings F3, F6.
The stat tile drew its value in a fixed-height box with a single line, so
"Plugged in, not charging" was clipped to "Plugged in, n…" on a Pixel 7a.
The value box is now a minimum height with up to two lines, ellipsis kept
as the last resort; numbers still fit one line, so every other tile renders
as before, and the row already stretches both tiles to the taller one.

The status tile also takes a smaller value style (titleMedium against the
numbers' headlineSmall). Status is a state label rather than a measurement,
so it is legitimately secondary to the figures beside it, and the smaller
size leaves the wrapped sentence room at larger font scales.

The regression test pins the geometry the report came from: half of a 411dp
screen, asserting the laid-out value has no visual overflow rather than only
that the string exists, since semantics carry the full text even when it is
visually truncated. One screenshot fixture now renders plugged-but-not-
charging so the wrapped value is visible in a captured shot.
…ucing

The charge-power tile is half a screen wide and fits roughly twelve
characters, so "Not charging" wrapped or ellipsised in a slot whose
neighbours are numbers. It now renders an em dash in
onSurfaceVariant, with a "Not charging" content description so
TalkBack still gets the words the width could not carry.

The dash is confined to the tile and to the not-charging case, on
both counts deliberately:

- The detail row on the session screen is label-left/value-right and
  has no width problem, so it keeps the words. chargePowerFallbackRes
  stays a purely semantic helper; the tile calls a second function
  next to it that returns shown text and spoken text together, so no
  caller compares string ids.
- "Not reported" never collapses into the dash. It states a device
  capability - the platform exposed no figure - rather than a charging
  state, and a dash there would claim an observation nothing made. A
  charging battery with no reported current still reads "Not
  reported".
Two lines of titleMedium hold "Plugged in, not charging" at normal font
scale, but not at 2x: 22 of the 24 characters fit the 160dp text area of
a half-width tile on a 411dp screen, so the value still ellipsised for
anyone using a large accessibility font.

BatteryStatTile takes a valueMaxLines parameter (default 2, unchanged for
every other tile) and the status tile passes 3. The third line exists for
large font scales rather than for normal rendering: the value box is a
minimum height, so the text still occupies only the lines it fills and
ordinary rendering is untouched.

The test renders the same 411dp geometry at fontScale = 2f and asserts the
laid-out value has no visual overflow; the normal-scale case stays, so the
two together pin both the wrap and the headroom the smaller style plus the
third line buy.
Promoting five readings into the tile grid left the detail sections named
after what they used to hold ("Charging", "Health", "Electrical"), which no
longer described their contents. The rows are now split by what the fact is
about: "This battery" carries technology, battery health, charge cycles and
charge remaining, "This charger" carries the power source and the charger's
advertised maximum.

"Charge counter" was the raw Android property name (BATTERY_PROPERTY_CHARGE_
COUNTER) surfacing in the UI and read as the cell's capacity. It is the charge
currently in the cell, tracking the battery level, so it now reads "Charge
remaining". "Health" becomes "Battery health", which also removes the
title/row text collision the hub test had to work around.

The battery section is deliberately first: unplugged, both charger rows read
"Not reported", so a charger-first order would open the list with two empty
rows in the most common state.
@d4rken d4rken added the enhancement New feature or request label Aug 18, 2026
@d4rken
d4rken merged commit cff8729 into main Aug 18, 2026
12 checks passed
@d4rken
d4rken deleted the feat/battery-insights branch August 18, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant