Skip to content

Charging: Check whether your phone's charge limit actually works - #89

Merged
d4rken merged 28 commits into
mainfrom
fix/qualification-run-hardening
Aug 19, 2026
Merged

Charging: Check whether your phone's charge limit actually works#89
d4rken merged 28 commits into
mainfrom
fix/qualification-run-hardening

Conversation

@d4rken

@d4rken d4rken commented Aug 19, 2026

Copy link
Copy Markdown
Member

What changed

On devices where Amply cannot confirm that a charge limit actually works, it can now offer to find
out. The check drives the limit itself: it caps below the current level and waits for charging to
stop, raises the cap and waits for charging to resume, then caps once more. Only a limit the charging
hardware really honours behaves that way on command, so the result settles a question Amply cannot
answer by watching alone (a battery resting below a limit looks the same as one paused by heat or a
weak charger).

The check is offered where it is useful and withheld where it is not: a device whose limit a
maintainer already verified has nothing to prove, and a build already known to charge past its limit
is not asked again. A run reports a pass, a refusal, or an inconclusive result, and produces a report
the user can send in to help get their build supported.

Before a run starts, the pre-check now shows the figures behind its own verdict rather than a bare
verdict: the current battery level, the level a run needs, whether the phone is charging, and a rough
time estimate. Those figures update live, so a user waiting to reach the required level sees them move
and sees the start button become available, instead of having to leave the screen and come back.

Throughout, the user's own charge setting is treated as owed work. It comes back when the check ends,
when the user stops it, when the charger is unplugged, and when the phone restarts partway through,
including when the app is killed between deciding the result and writing it down.

This feature is available in debug and beta builds only. It is switched off in release builds, so no
released user can reach it.

Technical Context

  • A qualification run can only ever refute a limit, never confirm one, and the code is built
    around that asymmetry. Nothing passively observable distinguishes a cap hold from a thermal or
    supply-side pause, so a pass is scoped to the exact build that produced it and a refutation is
    terminal for that build. The two catastrophic outcomes the design exists to prevent are a pass
    recorded for a limit that does not work, and a refusal recorded against hardware that does.
  • Finalizing a run was a sequence of separate durable writes (decide the outcome, record the
    evidence, publish the result, clear the record) with only "a finalization started" persisted. A
    failure partway left recovery unable to tell how far it had got, so it substituted an abort and
    contradicted evidence already on disk. Finalization is now a replayable state machine: the decided
    outcome and the measurement it came from are written with the claim, in one transaction, and every
    recovery path replays that instead of inventing a result. Replay is idempotent by construction
    rather than by a ledger, which is why evidence carries the protocol and algorithm versions that
    measured it rather than the running build's.
  • The restore is gated on the run still owning the outstanding restore obligation, checked inside the
    same lock as the write. Without that, a replayed restore could overwrite a persistent policy the
    user chose in between, with nothing left owed to bring it back. Boot recovery correspondingly
    refuses a recovery target a live run owns, and a close-out whose restore fails hands the work back
    to boot recovery rather than dropping it.
  • Two deliberate asymmetries in the charge service are load-bearing and easy to mistake for
    oversights. An outstanding restore keeps the service alive at two stop decisions but not the third,
    and one of those two is additionally gated on no recovery being in flight. Both exclusions exist
    because that stop site is also the recovery job's own tail, and the recovery flow deliberately
    keeps its target when a re-write fails so the next service start retries it. Removing either turns
    a persistently failing restore into a retry loop.
  • The pre-check's figures follow the same never-overclaim rule as the rest of the feature: the
    required level is derived by scanning the real eligibility rule rather than restating its
    arithmetic, so the number shown cannot drift from the number that gates the run; the time estimate
    returns nothing rather than a guess when an input is missing or the implied battery capacity is
    physically impossible, and it is rendered in coarse buckets because it extrapolates an
    instantaneous rate across a non-linear curve; and the battery polling behind it is scoped to the
    screen actually being visible, not to the wizard step, because that poll costs a privileged round
    trip on some adapters. Worth close review: the claim-and-replay path in the run store and runner,
    and the stop decisions in the charge service. The measurement engine is unchanged since its own
    review and is not part of this change.

d4rken added 21 commits August 18, 2026 15:27
Amply's contribution wizard captures which setting key changes across OEM
modes, a mapping. Whether the charging hardware obeys that setting is a
different measurement, and until now an entirely manual one: adb, settings
put in both directions, dumpsys battery sampled by hand, negotiated over
days of email. Two of the eight qualified devices came from contributors
willing to run that protocol repeatedly on request.

This lands the core of an in-app version. EnforcementVerdictEngine's KDoc
already specified it and said it was deliberately not implemented: write a
cap below the current level and watch charging cut, raise it and watch it
resume, cut again, a sequence no thermal pause can imitate.

QualificationRunEngine is pure and JVM-testable, and emits commands rather
than only reacting, since it drives the writes. Two shapes fall out of the
adapter's own supportedPolicies: a variable-cap run (LineageOS 70-95,
Samsung One UI 8 80/85/90/95) caps below the current level and releases by
raising the cap, so the battery is never left uncapped; a fixed-cap run
charges up to the cap first and releases with Unrestricted.

Three properties are load-bearing:

A phase timeout is INCONCLUSIVE, never REFUTED. Refutation stays reachable
only from an observed climb past the cap, the same signal and the same
OVERSHOOT_ALLOWANCE the passive engine uses, so a cold room or a weak
charger can never permanently disable control on a working device.

A run may not refute a mapping it guessed. On a candidate adapter the
commanded value is an assumption: a One UI 6/7 device whose protect_battery
means "cap at 85" charges straight past a commanded 80 while enforcing
perfectly, and recording that as a refutation would lock out a device that
works. It reports CAP_MISMATCH with the observed hold level instead.

Flow is measured by charge counter, not percent, against thresholds
expressed as a fraction of implied full capacity. MagicOS reports a
synthetic level 100 with ~278 mAh of headroom, and a milli-reporting ROM
scales counter and implied capacity together, so the ratio survives it.

The positive verdict lives in its own record rather than as a constant in
EnforcementVerdict. Adding one there would be fail-open: every field has a
default, so a record that lost algorithmVersion decodes as version 0, skips
the version-1 migration branch, and would deserialize into a claim of
enforcement on a build that never earned one. Bumping ALGORITHM_VERSION
would be worse, silently un-refuting every already-refuted device. The new
record's fail-closed guard is an explicit protocolVersion, because there is
no safe default for a one-constant positive enum.

EnforcementStatus gains SELF_QUALIFIED rather than reusing CONFIRMED, which
means a maintainer physically qualified the device and earned a ledger row.
Refutation still outranks it.

applyForQualification is ungated for the same reason restorePersistent is,
but requires a token matching a live run record, so the path does not exist
without a run in flight. Its writes are non-persistent, leaving the user's
protective baseline and the reconnect gesture's arming basis untouched.

The restore is registered as a FullChargeStore recovery target before the
first write, so process death and reboot are covered by shipped, tested
recovery rather than new machinery.

Behind ENABLE_QUALIFICATION_RUN, debug and beta only.
A cross-model review of the previous commit found nine defects, several of
them able to produce exactly the two outcomes the feature exists to avoid:
claiming a cap works when it does not, and permanently disabling a device
whose cap does work. This addresses all of them.

The measurement is rebuilt around a within-run control. A new BASELINE
phase lifts the cap and measures how fast charge actually goes into this
battery from this charger, and every later phase is judged against that
number: a cut requires the rate to fall tenfold below it, a resume requires
a third of it back. The previous design confirmed a cut from elapsed time
without a fixed-size rise, which meant a phone charging steadily at 100 mA
under a cap that does nothing sat under the bar forever and passed. That
bar was justified in the code as "an order of magnitude below any real
charge current"; the reasoning was backwards, and no absolute threshold can
be right for an unknown battery, charger and unit convention at once. A
baseline too slow to measure against now ends NO_BASELINE rather than
proceeding.

Refutation is terminal, so it now needs three things instead of one: the
level must exceed where the phase's window opened, the measured rate must
agree that charge is going in, and the commanded write must have had time
to land. Previously a single point of gauge drift refuted — and on a
variable-cap run, which deliberately starts above its cap, the first such
point always did. The near-full guard is also checked before the climb, so
a battery that simply tops off is inconclusive rather than refuting.

The runner no longer guesses. It refuses to start when the current policy
is unreadable, rather than substituting the adapter's protective default
and then persistently restoring that guess over an unrecognized native
mode. It refuses while a charge rule owns the policy, instead of
suspending the rule's cohort and discarding the baseline that rule still
owed back. On a native change or a full-charge session starting mid-run it
deliberately does not restore, because the newer choice is the user's.

The shared recovery slot gets an owner-scoped clear
(clearPendingRecoveryTargetIfOwnedBy): a widget or tile write can store a
newer target while a run is finishing, and clearing unconditionally
dropped that obligation with nothing left to repay it.

A pass now licenses only the policies the run exercised, via
AdapterSupport.licensedPolicies, enforced at the write path as well as the
display. The evidence record's own KDoc promised this; the tier ignored it
and unlocked the whole adapter.

The evidence store gains a credibility check. protocolVersion alone was not
fail-closed, because the outcome enum has exactly one constant and it is
the positive one: a record carrying only a matching build and protocol
version decoded as a pass with no adapter, cap, signal or policies.

runActive is stamped onto each battery tick at capture time rather than
read when the passive recorder gets round to it, so ticks taken during a
run cannot be evaluated after it finished and combine with the restored cap
into a terminal refutation of charging the run itself commanded.

Two smaller fixes: the tick loop merges into the stored record inside one
transaction, so a concurrent cancel is not overwritten and lost; and the
run start nudges the charge service, which nothing did before — a run's
record existed but no ticks ever reached it.

Corrects the previous commit message and privileged-access.md on one point:
avoiding a positive constant in EnforcementVerdict was justified there as
strictly fail-open, which was overstated. A record missing algorithmVersion
decodes as 0 and is scoped out anyway. The separation stands on the two
records describing different things.
A rule that activates while a guided run is in flight captures the run's
temporary policy as the baseline it owes the user back, and writes that
back when it stops matching. The user's real charge setting is then gone
with nothing left that remembers it, and the run reads the rule's write as
CONFIGURATION_DRIFT and deliberately does not restore either.

ruleOwnsPolicy() was only consulted at eligibility time, which covers a
rule that is already active when a run starts but not one that starts
matching mid-run. The guard therefore sits in RuleApplier.evaluate, the
single entry point every evaluation goes through: guarding
ChargeSessionService.evaluateBattery instead would miss
ACTION_EVALUATE_RULES, which calls evaluateRules straight from the command
path, so a Bluetooth connection or a rule edit could still activate.
licensedPolicies() returned null both when there was no evidence at all
and when evidence existed but none of its exercised policy ids parsed.
Null means "no restriction" downstream, so the second case handed the
SELF_QUALIFIED tier the adapter's entire policy list — the opposite of
what a pass claims, and reachable without any protocol-version bump: an
app update that changes a stable-id format is enough.

The two cases are now distinct. Null is only "no evidence"; evidence that
resolves to nothing is an empty list, and resolveEnforcement drops the
tier on an empty licence and falls through to the opt-in or candidate
branch instead of widening.
…p of the policy

A second review of the guided run found several ways it could still reach
one of the two outcomes it exists to prevent: claiming a cap works when it
does not, or permanently disabling a device whose cap does work.

The measurement window no longer starts before there is anything to
measure. enterPhase used to schedule the window at write time and take its
starting readings from that same sample, so every rate divided charge
accumulated over (settle + window) by (window). A working cap that takes a
minute to engage gains a point during the settle period, and five minutes
later the stale starting percent plus that accumulation reads as charging
past the cap — a terminal refutation of a device that works. The scheduled
opening and the anchor are now separate: the window opens on the first
sample at or after its eligible time, and takes its readings and its clock
from that sample. Rates, hold confirmation and phase budgets all measure
from the anchor, so a late first sample cannot make an active flow look
cut by counting unobserved time, and no phase completes, refutes or times
out before its window anchors.

That eligible time is derived from an apply-ack the runner persists after
a write succeeds, not from when the engine emitted the command: the write
in between can take seconds, and the configured-state drift check keys on
the same instant for the same reason — a readback taken while the write is
still in flight legitimately reports the previous phase's policy, and
aborting there ends the run on CONFIGURATION_DRIFT, which deliberately
does not restore. Samples older than the phase's ack, or than the run
itself, move no state at all.

Positioning is split from measurement. A fixed-cap run gets its CHARGE_UP
phase back, so BASELINE is a bounded ten-minute control in both shapes
rather than doubling as a charge-up up to twenty-four times longer. On top
of the rate floor, leaving BASELINE now requires MIN_BASELINE_UPDATES
observed changes of the accumulation signal: a device whose level or
batched charge counter updates once every twenty minutes otherwise stages
a textbook cut -> resume -> cut out of nothing but when it reported, with
the cap doing nothing. Three updates inside ten minutes put the reporting
period under five, which is what makes a twelve-minute quiet window
evidence. Anything coarser ends SIGNAL_TOO_COARSE.

Each tick's readings now come from one battery snapshot taken on the
runner's own worker and timestamped there, so nowMillis, percent and
chargeCounter describe the same instant. The tick from the watcher only
signals "evaluate now": BATTERY_PROPERTY_CHARGE_COUNTER is a live property
rather than a broadcast extra, so building it under the service's dispatch
lock would both pair a broadcast-time level with a processing-time counter
and put a Binder call somewhere the service keeps free for policy
recovery. The closing sample's readings are also passed into merge()
explicitly, or the phase log records blanks now that the next phase's
anchors start empty.

Run start moves onto the charge service's command queue
(ACTION_QUALIFICATION_START). Run start and full-charge session start both
claim the charge policy, and that queue is the one place they are
serialized against each other, so the two can no longer each observe it
free. The session side returns from the whole ACTION_START branch while a
run is live rather than only skipping beginOrResume: falling through
reaches the pending-recovery arm, which would find the recovery target the
run registered before its first write and start repaying it while the run
keeps writing. Because a session can no longer take ownership mid-run,
SESSION_STARTED goes back to restoring the baseline on abort.

The terminal path claims the record for finalization in one transaction
and decides the outcome from what that claim saw. Finalizing restores the
policy and only then records evidence, which is long enough for a cancel
to land in the middle; previously it read the record before evaluation and
could persist a pass and clear a record the user had cancelled. A cancel
that wins the claim downgrades the outcome, one that arrives after it does
not commit, and a downgrade never turns an abort into a pass.

runActiveNow starts true and is only resolved by startupRepair, which is
serialized with run start. The two errors are not symmetric: a wrong true
costs the passive recorder a few seconds of observation, a wrong false
lets it read charging the run itself commanded as charging past a cap.

Whether a run's commanded values are a guess now travels on
RunEligibility.Eligible instead of being a literal at the start call site,
so the cap-mismatch protection that stops a guessed mapping from producing
a terminal refutation has one source. No path produces a candidate run
yet; candidate selection is a follow-up, and this is the plumbing that
keeps it from re-introducing the literal.
The run's algorithm changed materially after PROTOCOL_VERSION 1: the
within-run baseline control, the coarse-signal refusal and the rule that
a pass licensing nothing is no pass all landed after it. A stored
version-1 pass nevertheless matched QualificationEvidenceStore.scope(),
so a result the current algorithm would never produce kept telling the
user an inert cap protects their battery.

PROTOCOL_VERSION is now 2, which drops those records rather than
migrating them: there is no safe reinterpretation of a positive verdict
measured by an algorithm that could not tell a weak supply from a cap.

Fixes review finding F1.
…ching it

claimForFinalization() persisted finalizing = true and nothing ever gave
it back. A process death between the claim and the clear at the end of
finalization — a window that spans a policy write, slow on a Shizuku
adapter — or any throw inside finalize() made the run record permanent:
startup repair and every later terminal tick found it already claimed and
returned. The run then read as running forever, so charge rules never
evaluated again, full-charge sessions were refused, passive enforcement
stayed suppressed, the foreground service was kept alive, and the policy
the run owed the user was never restored. Only clearing app data got out.

claimForFinalization() takes an optional runId that claims a record even
when it is already claimed, which startup repair passes for a record left
behind by another process — the existing provenance filter is what keeps
it from stealing a finalization still in flight here. finish() now wraps
finalize() and, on a throw, transactionally clears the flag when the
stored runId still matches, so the release can neither resurrect a record
that was already cleared nor unclaim a newer run.

Fixes review finding F2.
Every path inside the baseline phase's BASELINE_WINDOW_MILLIS check
returns, so the PHASE_BUDGET_MILLIS branch below it could never run. It
implied a 25-minute escape the phase does not have; the bounded ten
minute window is itself the budget, and a device that never produces a
usable control ends NO_BASELINE (or SIGNAL_TOO_COARSE) there. The KDoc
now says so.

Fixes review finding F3.
…n and process death

finish() only released its finalization claim for an ordinary exception.
A cancellation - the production path is the charge service's own
lifecycle scope, which onDestroy cancels across a window that spans the
restore write - left the record claimed and finalizing forever: its
provenance still matches this process, so startup repair will not
reclaim it, and every ordinary terminal attempt is refused. The release
now sits in a finally and runs NonCancellable; on the success path the
run-id guard finds nothing to match.

The tick loop also trusted that startup repair had already closed out a
record left by a dead process. Repair can fail (a store write, the
pending recovery target), and since it now releases the claim and
returns, the foreign record stays stored and unclaimed - the engine
would then measure anchors, a hold clock and a baseline rate from
observations this process never made, and could end the run Passed or
Refuted instead of aborting PROCESS_DEATH. onTick refuses any record
this process does not own and closes it out, so a failed repair simply
retries on the next tick.

Fixes review findings F4, F5.
The baseline phase ends at BASELINE_WINDOW_MILLIS (10 minutes), not the
general 25 minute phase budget, so its bar filled to about 40% and then
jumped - in the one phase the user is explicitly asked to wait through.

Fixes review finding F6.
…e back

A finalization that fails on a store write fails its release the same way:
both are DataStore writes, and NonCancellable only keeps the release from
being cancelled, not from throwing. The record then stays claimed with this
process's provenance, so startup repair skips it (not foreign) and every
ordinary terminal path refuses it (already claimed) — the app reads a run as
live for the rest of the process's life, with rules suspended, no session, no
further run, and the baseline the run owes never restored.

onTick now treats any record it finds still claimed as close-out-only and
aborts it with the new FINALIZATION_INTERRUPTED reason, which is the retry
that store recovery otherwise has no way of reaching. The forced reclaim is
only safe under serialization, so finish() runs claim-through-release under a
finalizationMutex: it is reachable from the tick consumer and from the charge
service's scope at once, and without the lock a tick could reclaim a record a
live finalization is still working through, with both restoring the baseline
and writing evidence. The release itself is wrapped so a failing release
cannot replace the finalization's exception with its own.

Fixes review finding F7.
… aborting it

Finalizing a run is four durable steps - decide the terminal, write the
evidence, publish the result, clear the record - but the only thing
persisted was the boolean "a finalization started". A store write failing
between the evidence write and the clear left the next tick with nothing
but that boolean, so it substituted Aborted(FINALIZATION_INTERRUPTED):
the user was told nothing was recorded while the pass that licenses
charge control, or the refutation that withholds it for good, was already
on disk. A failure before the evidence write threw away a terminal that
had already been decided correctly.

The record now carries a FinalizationIntent, written in the same
transaction as the claim and alongside the closing measurement, so
recovery replays what the run decided rather than inventing a substitute.
The claim resolves the cancelled / write-failed downgrade itself - the
stored outcome can never disagree with the one that was applied - and
never overwrites an intent that is already there. Releasing a failed
claim keeps the intent: the release means the attempt did not finish, not
that the outcome was never decided. Every close-out path (the claim
guard, the foreign-provenance guard, the startup repair) replays that
outcome and falls back to an abort only for a record claimed by a build
that wrote none, which is also why finalizing is kept as its own field
rather than derived from the intent.

A Passed or Refuted decided before a process death therefore now
completes in the next process instead of becoming Aborted(PROCESS_DEATH).
Dropping a refutation is the dangerous direction: it leaves the device
holding control it demonstrably does not honour. The replay needs no
bookkeeping to be idempotent - the restore is a no-op write, the
qualification evidence store overwrites unconditionally with
byte-identical content (both evidence records are stamped with the
intent's decision time, not a fresh clock read), the enforcement store
refuses a duplicate for the same scope, and republishing the result flow
is harmless.

Fixes review finding F8.
…t measured it

A finalization intent can be persisted and then replayed by a later
process, and that process may belong to an app update. Reading
PROTOCOL_VERSION and ALGORITHM_VERSION from today's constants at write
time therefore presented a measurement made under a superseded protocol
as one the current protocol produced: the qualification store's scoping
accepted it and it licensed charge control, and a refutation likewise
skipped the enforcement store's per-version migration.

Both stamps now come from the run record. protocolVersion was already
there; enforcementAlgorithmVersion is new and set from the constant at
run start. Its zero fallback is only reachable for a record written
before the field existed, and such a record carries no finalization
intent, so it can never be a cross-version replay - it closes out as
FINALIZATION_INTERRUPTED or PROCESS_DEATH instead.

The replay also covers the composition the measurement is persisted with
the claim for: a replayed finalization now has a test that it publishes
the merged record, phase-log row included, not just the terminal.

Fixes review finding F9.
…wes it

A replayed finalization could revert an explicit persistent choice the
user made in between. The sequence: finalization restores the baseline
and clears its recovery target, its record clear then fails while the
claim release succeeds, and before the next tick the widget's
fixed-limit/unrestricted button writes a policy and clears its own
recovery target. The replay took the close-out-only path and wrote
record.baseline again, so the user's choice was silently reverted with
nothing left owed to bring it back - the close-out bypasses the engine's
CONFIGURATION_DRIFT re-evaluation that used to catch this.

The restore now goes through ChargingRepository.restoreQualification-
BaselineIfOwned, which writes only while the single recovery slot is
still owned by this run. Ownership is exact in both directions: an
attempt that never reached the restore still owns the target, a failed
restore leaves it owned, a completed restore or any newer producer does
not. The check runs inside operationMutex, the same lock as the write -
setPersistentPolicy registers its recovery record before entering that
mutex, so either the replay writes first and the user's write lands on
top, or the replay sees the newer owner and skips. Superseded counts as
restore-complete: the terminal is still published and its evidence still
written, only the policy write is skipped.

QualificationRestoreOutcome is a separate type rather than an
ApplyResult flag so "did not write, no longer ours" cannot collapse into
"wrote and failed", which would strand an owed policy.

Fixes review finding F10.
…easured it

The zero fallback for a run record's enforcementAlgorithmVersion took the
current EnforcementVerdictEngine.ALGORITHM_VERSION, on the premise that a
record without the field can carry no finalization intent and so can never
be replayed across an app update. That premise was wrong: the intent field
shipped one build before the version field, so a record written in that
window decodes with a replayable refutation and a zero version. After the
next algorithm bump the replay would stamp that measurement with a version
the newer algorithm never produced, imposing a refutation on its behalf.

The fallback is now a literal 2, the version every build in that window
measured at, so the evidence keeps its provenance and the enforcement
evidence store decides for itself how a version-2 verdict migrates.

Fixes review finding F11.
Reopening the app dispatches ACTION_CHECK, which resolves to boot recovery
whenever a pending recovery target exists. During a qualification run that
target is the run's own, registered before its first policy write, and the
recovery flow clears it unconditionally. The run then kept commanding
experimental policies while nothing was owed any more, and its finalization
found no owner and skipped the restore, leaving the device on the
experimental cap with the user's configured policy unrecoverable.

startRecovery() now reads the live run and the pending target first and
returns without recovering when the target belongs to that run, the same
ownership rule ACTION_START already applies to a full-charge start. It is
centralised there so ACTION_CHECK, ACTION_RECOVER, the sticky restart and
the post-start branch are all covered once. A target owned by anything else
is still recovered normally while a run exists. Nothing is stranded by
refusing: the run record keeps the qualification watcher enabled, which
holds the foreground service up, so the run's own close-out still restores.

Fixes review finding F12.
The ownership guard that refuses boot recovery of a recovery target a live
run owns relied on the run's own close-out performing the restore instead.
That holds only while the run record exists, and finalize() clears the
record whether or not the restore succeeded.

The stranding sequence: a process death or reboot mid-run, BootReceiver
dispatches ACTION_RECOVER, the guard refuses it, startup repair then closes
the record out and its single baseline write fails — routine at boot, where
the backend or provider is often not ready yet. The recovery target is
correctly left behind, but the record is cleared, so QualificationWatcher
goes quiet and the next continueGestureOrStop() stops the service; that
function decides from the session, the gesture and the watchers and never
looks at a pending recovery target. Nothing re-dispatched recovery, and the
device could sit on the run's experimental policy — its deliberately
less-protective release policy included — until the next foreground launch
or reboot.

finalize() now re-dispatches ACTION_RECOVER when its restore failed, after
clearing the record: with the record gone the guard no longer refuses, so
BootRecoveryFlow's bounded rewrite loop, which exists for exactly this kind
of failure, picks the target up. Keyed on the same restored flag the target
is kept by, so the two paths where nothing is owed (a CONFIGURATION_DRIFT
supersession, and a target that is no longer this run's) cannot trigger it.
It cannot loop: recovery never calls finalize(), and the run record a second
close-out would need is already cleared. The dispatch is fire-and-forget and
takes no lock, so the finalization's lock order is unchanged.

Fixes review finding F13.
…g a dispatch

A qualification close-out whose baseline write fails leaves the recovery
target in place and dispatches ACTION_RECOVER, but clearing the run record
is also what turns QualificationWatcher off, so the dispatch races the
service's own stop decision. A stop decision that wins calls
stopMonitoring(), which discards already-queued work and stopSelf()s;
START_STICKY then redelivers a null intent, not the ACTION_RECOVER that was
in flight. The device would sit on the run's experimental policy, target
still owed, until the next foreground launch or reboot.

The stop decisions now look at the obligation itself: continueGestureOrStop
and evaluateBattery start recovery when a pending target exists that no live
run owns, instead of stopping. The dispatch stays — it covers the service
already having stopped or never having run, which a stop-decision check
cannot. The recovery launch is split out of startRecovery() into
beginRecovery() so these callers skip the ownership guard they have already
evaluated (its refusal branch calls back into continueGestureOrStop, which
would be mutual recursion).

continueGestureOrStop's check is gated on no recovery being in flight,
because that site is also the recovery job's own tail and BootRecoveryFlow
deliberately keeps the pending target when a re-write fails, so the next
service start can retry it. Ungated, a persistently failing restore would
restart itself there forever at the flow's 25s/75s cadence with the service
never stopping. evaluateBattery needs no such gate (it returns early while a
recovery job is active) and restoreAndContinue's post-failure stop is left
untouched for the same anti-loop reason.

Both properties are covered: the race with an already-running instance whose
hand-off intent is never delivered, and the failing-re-write recovery that
must stop through its tail rather than restart itself.

Fixes review finding F14.
The pre-check refused a run with "your battery is too low" and a disabled
Start button, and nothing on screen said how low, how high it had to be,
whether the phone was even charging, or how long the wait was. On a Pixel 6
running LineageOS the real threshold is 73% (lowest cap 70 plus the
variable-cap undershoot) while the battery sat at 64%.

The refusal now carries the level a run needs. It is found by scanning
resolvePlan over every level below the near-full cutoff rather than by
re-deriving the undershoot/headroom arithmetic: the eligibility rule exists
once so the pre-check list, the entry point and the runner agree by
construction, and a second copy of the maths would be a fourth
approximation of it. It is set only for BATTERY_LEVEL — a too-full battery
has to discharge, where a "needed" level would read as a target to charge
towards.

Alongside it, a pure estimate of the time to that level, extrapolated from
the reported charge counter and current. Direction comes from
BATTERY_STATUS_CHARGING plus a plug, never from the current's sign, which
is OEM-defined (the reasoning StatsPowerCalculator.chargeMilliwatts
documents). Every gate returns null rather than a guess: no charge, a
missing input, a level outside 1..99, a target at or below the level, or an
implausible result. It renders in round buckets ("about 40 minutes", "about
an hour", "over an hour") because an instantaneous-rate extrapolation
across a charge curve that flattens near the top cannot support "43
minutes". When the phone is not charging the block says so plainly instead
of estimating, which alone answers the most common cause of the block.

The block is live: while the pre-check step is on screen the figures and
eligibility are both re-resolved on the battery readout cadence, so a user
who reaches the threshold while sitting there sees Start enable instead of
having to leave and come back.

Not addressed here, deliberately: a device whose cap actually holds can
still be stuck below the threshold by its own limit. This makes that
situation legible; what to do about it is a separate decision.
…observed

Two ways the block made a claim it could not back.

A charge counter reported in milli- rather than micro-units, with a
correctly scaled current, produced a confident short estimate: level 50
with a counter of 2 000 implies a 4 mAh pack, so 20 remaining points come
out as 0.03 minutes and render as "About 10 minutes" for what is really a
long wait. The two are independent HAL properties, so one being mis-scaled
while the other is not is reachable. The old guards only judged the final
figure, and the 24-hour ceiling only rejects a lie in the long direction.
The implied capacity is now judged on its own against
QualificationProtocol.MIN_PLAUSIBLE_FULL_MICROAMP_HOURS, which is the same
decision QualificationRunEngine.resolveSignal already makes about the same
input when it decides whether the counter is a usable signal at all.

"Not charging right now" was printed for states where charging is unknown:
a plugged charger with BATTERY_STATUS_UNKNOWN (a valid platform status), an
unrecognised status value, or only the positive half of the plugged/status
pair present. That is the line a waiting user is most likely to act on.
The check is now a real tri-state: true only for a plug plus
BATTERY_STATUS_CHARGING, false for an observed negative on either half
(nothing plugged in, or discharging/not-charging/full), null otherwise. The
screen already omits the line for null, so an unknown state now says
nothing instead of asserting.

Fixes review findings F17, F18.
The pre-check figures were driven by a collector launched eagerly in the
ViewModel's init, keyed on the requested step. Leaving the screen by the
top arrow changes the nav destination, not the step, and the state was
collected at the composition root off an activity-scoped ViewModel, so a
battery read plus a full eligibility resolution — store reads, and a
Shizuku binder round trip on some adapters — kept running every three
seconds for the Activity's lifetime, including while the app was in the
background.

The same collector was the only subscriber that ever filled the pre-check
figures, and it unsubscribed when the step became RUNNING. The running
screen then read the last value it happened to leave behind, showing a
percent and charge counter frozen at the last pre-check reading for the
thirty to ninety minutes a run takes, while looking live.

Both come from the same shape, so both are fixed by the same change. The
figures are now a cold flow the UI state is built from (liveFigures), so
they exist only while the state has a subscriber; they are resolved for the
running step as well as the pre-check, and the run progress takes the
readout as a parameter instead of reading a cached one. The polling is
keyed on the *resolved* step, so a run picked up from another process polls
too. MainActivity collects the state inside the qualification destination
with collectAsStateWithLifecycle rather than at the root: the step is not a
proxy for the screen being visible, and only the collector's lifetime can
stop the poll.

A non-polling step emits one empty figure set, which is what clears the
previous step's block, and a polling step emits one before its first
reading so a step change is never held behind a battery read and an
eligibility resolution.

Fixes review findings F15, F16.
@d4rken d4rken added enhancement New feature or request device support Request to add charge-control support for a device/OEM ROM: LOS LineageOS labels Aug 19, 2026
@github-actions github-actions Bot added the Build/Deploy Build system / CI / release tooling label Aug 19, 2026
d4rken added 5 commits August 19, 2026 16:07
…ragraphs

Every step rendered through two primitives, a title and a paragraph, appended
as flat list items, so the running step read as two paragraphs, an unlabelled
progress bar, and a run-on reading line. Nothing grouped, nothing labelled.

The wizard now uses two shared blocks built on the app's existing card design
system: QualificationCard (AmplyCard plus the standard header, so it matches
the dashboard rather than inventing a second card style) and LabelledValue, a
label/value row that replaces the run-on reading lines.

The running step gets a progress card: a "Step N of M · <phase>" caption above
the bar, which on its own fills once per phase and restarts, reading as no
progress at all. The position comes from a pure runStep(), unit-tested for both
run shapes, because a fixed-cap run has a charge-up phase ahead of the baseline
and therefore one step more.

The raw charge counter is gone from the UI, and from RunProgressUi with it. It
is the protocol's internal flow signal in microamp-hours, actionable in no unit
for a user; it stays in the contribution report, where a maintainer reads it.
In its place the running card shows the charging state, derived by the same
tri-state chargingOrNull() the pre-check uses, and skipped when unobserved.

The pre-check block becomes the same card with LabelledValue rows, showing the
same lines under the same conditions. The result verdict and the report preview
move into cards, with their explanatory prose staying outside them.
…t a cut

Setting the charge limit below the current battery level makes some builds
report the phone as entirely unplugged: observed on a Pixel 6 running
LineageOS BP4A.251205.006, where the cable stayed connected (adb live over
it) while dumpsys reported "USB powered: false" with the limit active, and
raising the cap resumed charging instantly. A VARIABLE_CAP run caps below
the current level by design, so it aborted on its own first cut and told
the user their charger had come out.

A lost plug signal within PLUG_MASK_WINDOW_MILLIS of a cut's acknowledged
write now ends the run as Inconclusive(PLUG_SIGNAL_LOST_AT_CUT) instead of
Aborted(UNPLUGGED). Telling a cap-induced plug loss from a real unplug was
only ever needed to reach a pass safely; here both readings end the run
with no verdict, so an outcome that names both is true without having to
distinguish them. The window is anchored on the acknowledgement because
that is when our own cut could have masked the plug, and it decides wording
only - it authorizes no pass, no hold, and no measurement decision. Outside
a cut phase, outside the window, or with no acknowledged write, the
ordinary unplug abort is unchanged, as is abort precedence.

Inconclusive rather than a new AbortReason: a run that could not measure
stores nothing and stays repeatable, which is exactly this situation. The
user's configured policy is restored on this terminal like any other.
plugSignalLostAtCut() only checked the upper bound, so any negative
elapsed satisfied it. A backwards wall-clock correction during an
acknowledged cut - NTP, a time or timezone change, a user edit - stamps a
sample before commandAckedAt, and the run then published
Inconclusive(PLUG_SIGNAL_LOST_AT_CUT) about a moment that lies outside
the window entirely. Nothing durable was wrong (this branch reaches no
evidence and no verdict), but the statement was false, in the one change
whose purpose is to stop the run saying something untrue. The engine's
isStale() guard cannot catch it because abort handling runs first.

The elapsed delta is now required to be >= 0 as well. The phase check and
the commandAckedAt > 0 guard are unchanged, abortReasonFor and its
ordering are untouched, and the window still decides wording only.

Adds an engine case at commandAckedAt - 1 closing the lower boundary
(the existing cases cover the inclusive upper bound and past-window), and
pins the PLUG_SIGNAL_LOST_AT_CUT constant as stored text in
StoredRecordFormatTest, since it travels on the replayed finalization
intent.

Fixes review finding F19.
The plug-signal-lost body claimed two things the run never saw. It said
charging stopped, which is an inference: at that point the engine has only
`readout.onCharger == false`, with no charging-rate or charging-status
observation behind it. And it said the phone reported the charger as
disconnected, which `BatteryReadout.onCharger` cannot distinguish from a
phone that simply stopped reporting `plugged` at all.

The message now claims only the observation: the phone stopped reporting the
charger as connected while the lower limit was set. Both readings are still
named (some builds report it that way when the limit engages, and a slipped
cable looks the same), the cable-check retry stays, and so does the note that
sending the result helps.
`finalize()` already knew whether the user's own charge setting made it back:
a failed restore leaves the recovery target in place and hands the write to
boot recovery. The published result did not carry that, so every result body
that mentioned the setting claimed it was back regardless.

`QualificationResult` now carries `restored` (presentation only, like the
result itself; nothing persisted changes), the ViewModel threads it into the
UI state, and the result step renders the restore statement as its own line
with two variants: the existing "has been put back" wording when it landed,
and an honest "couldn't put it back yet, still trying, you can set it
yourself" when it did not. The eight abort bodies lose the sentence they used
to carry so it is stated in exactly one place.

The line is skipped for SERVICE_UNAVAILABLE alone: that abort happens before
the run's service ever starts, its own copy already says nothing was changed,
and a restore warning there would alarm about a setting that was never taken.

The UI state defaults `restored` to false, so a state without a result cannot
render the claim.
d4rken added 2 commits August 19, 2026 16:49
The phase sentences are written as finished facts ("The limit is set to
70%", "The limit is now above 70%"), but the phase is persisted before its
write is dispatched and `commandAckedAtWallMillis` is only stamped when the
adapter comes back. On a slow or failed write, that gap is the whole time the
user is looking at the screen, and the screen asserts a change that has not
happened.

`RunProgressUi` now carries the decoded commanded policy and whether its write
was acknowledged, both already on the run record, and a pure `runMessage()`
picks the sentence: unacknowledged renders one of two new pending strings
chosen by the commanded policy ("Setting the limit to X%…" for a cap,
"Removing the limit…" for Unrestricted), acknowledged falls through to the
phase sentence unchanged. Preflight commands nothing and keeps its sentence.

One generic pair rather than a pending variant per phase: every phase either
sets a limit or takes one off, so five more strings would say the same two
things.
…what it owes

The result screen decided its restore line from a behavioural flag that means
"no recovery obligation remains". That is equally true of a restore that was
written and landed, of one that was skipped because a newer user choice already
holds the slot, and of the drift abort that deliberately does not write so a
newer choice is not clobbered. Only the first put anything back, yet all three
rendered "Your charge setting has been put back".

The result now carries a QualificationRestorePresentation (APPLIED / PENDING /
OMIT) alongside the unchanged local flag: a successful write is APPLIED, a
failed or throwing one is PENDING, and a superseded restore, the drift
short-circuit and a run refused its service are OMIT, which renders no restore
line at all. The restore itself, the owner-scoped recovery-target clearing and
the failed-restore hand-off to boot recovery are untouched.

The pending copy also promised that Amply keeps trying. The result is a
one-time close-out snapshot and nothing on that screen observes the recovery
target or boot recovery afterwards, so the assurance can be false while it is
on screen - and a user who believes it may leave a setting in place that is
less protective than their own. It now states what happened and asks the user
to check the setting.

Fixes review findings F20, F21.
@d4rken
d4rken merged commit 227c03c into main Aug 19, 2026
12 checks passed
@d4rken
d4rken deleted the fix/qualification-run-hardening branch August 19, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build/Deploy Build system / CI / release tooling device support Request to add charge-control support for a device/OEM enhancement New feature or request ROM: LOS LineageOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant