Battery: See estimated charge times and details for each reading - #86
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
ChargeSessionSummary.avgPowerMilliwattsis already folded, so one session can't show two different averages on two screens.BATTERY_STATUS_CHARGINGsample. 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.endedAtWallMilliswhile samples expire by their ownwallMillis, so a session held at a limit for days and ended recently keeps its id while losing its early samples.ChargeBandExtractor's state machine (first step of a run discarded as it starts mid-level, plus jump/decrease/backwards-time handling) andChargeTimeEstimator.project's same-type-versus-pooled fallback.