Skip to content

Charging: Enable charge control on LineageOS devices - #79

Merged
d4rken merged 25 commits into
mainfrom
feat/enforcement-evidence-gate
Aug 17, 2026
Merged

Charging: Enable charge control on LineageOS devices#79
d4rken merged 25 commits into
mainfrom
feat/enforcement-evidence-gate

Conversation

@d4rken

@d4rken d4rken commented Aug 17, 2026

Copy link
Copy Markdown
Member

What changed

Amply can now control charging on LineageOS devices. Until now it couldn't on any of them: support was gated on a list of physically-qualified device codenames that shipped empty, so every LineageOS phone fell through to diagnostics-only and never got controls.

Any LineageOS build that ships the charging-control settings provider is now recognised. Controls start switched off, with a card explaining that nothing Amply can see proves the limit actually works on that build, and an option to enable them anyway. Once enabled, Amply watches real charging: if the battery is ever seen charging past the configured limit, it switches its controls back off for that build and says so, rather than showing a protection that isn't real.

Amply never claims a limit is "verified" from watching. No signal it can observe tells a charge limit holding apart from the phone pausing for heat or a weak charger, so observation can only ever disprove a limit, never confirm one. Devices that have been physically qualified skip the opt-in entirely.

Also fixed along the way: a temporary full-charge session's restore is no longer refused when a system update changes the build mid-session, which previously could leave the phone with its protection never restored.

Technical Context

  • Why the HAL capability probe doesn't gate this. Upstream picks the charging provider by configured mode before capability, so a device sitting in AUTO reports Deadline whether or not it supports LIMIT, and the mechanism enum has no negative case by design. The necessary condition is the existing write-readback instead — which is what established oriole's original NO-GO. The probe stays informational.

  • Why confirmation was removed rather than tightened. EXTRA_CHARGING_STATUS was measured on a Pixel 6 / LineageOS 23.2 still reading 4 while the phone actively charged ten points below its cap — it marks the long-life session, not an active hold. Combined with a thermal or weak-supply pause being indistinguishable from a cap hold in every other observable field, no passive rule can confirm. A guided two-cap cut/resume/cut challenge is the known way to earn real confirmation and is deliberately not implemented here.

  • Evidence is keyed on a composite build identity, not a codename or fingerprint. HAL capability is build-scoped: the same oriole exposed the LIMIT mode bit on one build and dropped it on another of the same Lineage version. Build.FINGERPRINT alone can't carry it either, because LineageOS spoofs it to stock.

  • The gate deliberately does not apply to repaying a persisted restore — an OTA changes the build identity mid-session, and withholding the restore would strand the phone unrestricted, which is the opposite of what the gate is for. Recovery targets therefore carry an origin so a pending user-initiated write still goes through the gate, defaulting to the gated origin for records written by older builds. Worth close review, as is the fail-closed handling in the evidence store: a corrupt or undecodable record must never read as "no evidence".

  • On-device testing covered what CI cannot: refutation was exercised end-to-end on a Pixel 6 running LineageOS 23.2 — an 80% limit applied and read back, the battery driven past it, and control observed being withdrawn with the explanatory card shown. The qualification ledger is updated in the same change: that device's earlier "HAL dropped LIMIT mode" NO-GO no longer holds on its current build, recorded as build-scoped evidence and explicitly not as a pass, so it is not added to the qualified allowlist.

d4rken added 23 commits August 16, 2026 23:54
A settings read-back proves the ROM accepted a charge cap, never that the
charging hardware acts on it (the `mIsLimitSet:false` class of bug). This
adds the units that answer the second question from the public battery
broadcast alone.

EnforcementVerdictEngine is pure and asymmetric by design. CONFIRM needs a
sustained plateau inside one epoch: an observed rise, then the level parked
in the band just below the cap with StatsLimitHitDetector.heldNow across
several samples spanning minutes — heldNow alone fires on thermal
suspension, a renegotiating charger, or a kernel charge pause. REFUTE keys
on a monotonic upward trend through the cap plus a small allowance;
requiring BATTERY_STATUS_CHARGING would be a false-negative source, since a
ROM can carry the level past the cap while reporting NOT_CHARGING or FULL.

Observation runs inside an epoch (adapter, build, cap, policy generation,
plug session) rather than off a watermark. A watermark set at cap 80 with
the user sitting at 78% would refute a good device the moment the cap moved
to 70.

Evidence is scoped to a composite build identity, not Build.FINGERPRINT:
LineageOS spoofs the fingerprint to stock, so two nightlies with different
charge-control behavior share it. The stored algorithm version means a
later tightening of the heuristic stops trusting today's confirmations.

The store keeps the raw JSON and decodes it here rather than falling back
to a default: a corrupt record surfaces as Corrupt and is treated like a
refutation, because an unreadable record may be one, and a corrupt payload
survives a write that declines to replace it. A refutation is terminal for
its scope; a confirmation never overwrites one.
The LineageOS live adapter matched only codenames in QUALIFIED_CODENAMES,
which ships empty, so it covered zero production devices while every
LineageOS build fell to diagnostics. It now matches any LineageOS build
that carries the `lineagesettings` provider, and control is justified by
enforcement observed on the user's own device instead.

AdapterRegistry.select() takes the evidence state explicitly, with no
default: "the caller forgot", "not read yet" and "genuinely nothing stored"
must not collapse into the control-enabled branch. Callers that only need
adapter capabilities (settings URIs, the native-settings intent, the
gesture flag, the hardware decode) pass the fail-closed Loading state,
which can never enable control; the device-support report now takes the
gated selection so its adapterControlEnabled field says what the user
actually has.

Resolution order, and the order matters: a refutation (or an undecodable
record, which may be one) disables control and asks for a report; a
maintainer-qualified codename or a local confirmation leaves the probe's
decision alone — this must precede the candidate branch or "no evidence"
would override the fast path; a started verification enables control but
labels it under test; anything else is a candidate with control OFF. A
candidate device is deliberately not handed controls that present as
protection, and every existing !canApply path (widget, tile, quick access,
gesture, session start) already refuses on that alone.

QUALIFIED_CODENAMES stays, repurposed as the maintainer fast path. The
adapter also stops offering the guided capture wizard, which the lab
adapter already withheld: its keys live outside the wizard's capture set,
so a guided run always diffs to empty.

Provider-less LineageOS builds no longer match the live adapter at all and
fall to LineageLabAdapter, which keeps the custom-ROM-before-OEM ordering
for them at the cost of the specific "not available on this build" note.
Runs the verdict engine over the monitor's battery ticks, so a device the
user opted into verification either earns control or loses it on its own
observed behavior.

The watcher only enqueues. onBatteryTick runs under the charge-session
service's serialized dispatch lock, where a blocking read can delay the
safety-critical policy restore, so the recorder does every DataStore,
provider and Binder read on its own IO coroutine — the same split
ChargeStatsWatcher/ChargeStatsRecorder use. The exact battery intent rides
along so charge current is read off the same observation the tick made.

The keep-alive gate is narrower than "the adapter wants evidence": without
a configured cap, on a secondary user, before the user starts verification,
or after a terminal refutation, a foreground service and its persistent
notification would be held up for an observation that cannot happen. A
confirmed device keeps observing on purpose — a build that held the cap
once can still be caught charging past it later.

A stored verdict refreshes the repository and re-pushes the widget and
tile, because the tier it changes decides whether those surfaces may offer
control at all. The engine keeps reaching the same verdict while the
condition holds, so only a change is written.
A device whose charge limit has not been shown to work must not look like a
protected one, on any surface.

The dashboard gains a card under the hero for the three states that would
otherwise be silent: unverified (controls withheld, with the "Verify on
this device" action that starts the check), under verification (controls
live, nothing proven yet), and refuted (control switched off because the
build charged past the limit it accepted). A confirmed device, and every
adapter the question doesn't apply to, show nothing.

The hero's green check is withheld while a verification runs. It reads as
"your battery is protected", but on these builds a Verified read-back only
proves the ROM stored the value, so the provenance line gets an explicit
note instead. The widget appends the same qualification rather than putting
a bare "Limited to 80%" on the home screen, and the tile says so instead of
its reassuring access label.

Refuted devices are left to the existing unsupported-device card for the
report affordance, which the flipped contributionWanted already brings back
— the new card explains, it doesn't duplicate the action.
The LineageOS sections described a codename allowlist that no longer
decides anything, and the always-loaded rules are meant to be accurate
about exactly this class of safety boundary.
…nothing

A CONFIRMED device emitted an empty list item, which still cost the
dashboard's 12dp inter-item spacing. Also fixes the recorder's
already-recorded check, which could not compile as written, and passes the
new callback from the screenshot fixture.
…ures

Two screen tests build the DashboardScreen call themselves instead of going
through DashboardScreenUnderTest.
A passive plateau cannot tell a cap hold from a thermal pause, a charger
renegotiation or a weak supply: all of them park a plugged battery below
full while StatsLimitHitDetector.heldNow reads true, and five minutes of
that is well within what a hot phone produces. The verdict engine could
therefore CONFIRM a build that goes on charging to 100%.

Confirmation now needs corroboration from the charging hardware itself.
ChargingAdapter gains hardwareHoldSignal(chargingStatus, plugged),
returning null where an adapter has no unambiguous signal — CONFIRMED is
then unreachable there and the device stays under test. LineageOS
implements it via the AOSP charge-policy state (EXTRA_CHARGING_STATUS
== 4, observed on oriole / LineageOS 23.2 while holding at its cap);
unplugged the sticky broadcast keeps a stale powered value, so it
reports null. decodeHardware is deliberately untouched: its result also
feeds the dashboard observation and the settling logic.

Two amplifiers went with it. The epoch-global `rose` flag is replaced by
phase-local climb tracking — any drop resets the climb base and clears
the rise — and the hold now pins to one exact level instead of accepting
"not increasing", so a battery losing charge on a weak charger no longer
counts as held.

REFUTE is deliberately unchanged and still needs no hardware signal at
all: a level climbing past the cap is self-evident, and a build with no
hold signal must stay refutable.

Fixes review finding F1
…the cap

An epoch that opened above the configured cap could never be refuted: the
climb base was only ever set from a sample at or below the cap, so it
stayed null and the refutation arm never armed while the battery charged
on to 100%. Two ordinary situations open such an epoch — an 80% cap
restored early from a full-charge session at 84%, and a process death at
82% — and both are precisely the runs where an unenforced cap shows
itself.

The base is now taken from the first valid percentage at any level, and
the refutation keys on the phase-local rise flag instead of the base
being non-null. A lone or flat above-cap sample still proves nothing: a
device resting at 95% under a 70% cap is a device that stopped charging.

Fixes review finding F2
The widget's steady label falls back to the last requested target, which
outlives a tier change: a build refuted after the user asked for 80%, or
a confirmed build that an OTA reset to candidate, still carried
FixedLimit(80) in the request journal while the repository published
Unsupported. Only UNDER_TEST was special-cased, so both of those put a
bare "Limited to 80%" on the home screen while charging control was off.

statusLine now resolves the enforcement tier before deriving the steady
label: candidate and refuted builds get their own text, under-test keeps
its suffix, and only a confirmed build (or an adapter the question does
not apply to) renders the requested target as a limit.

The Quick Settings tile has no equivalent fall-through — its subtitle is
the access label or the adapter name, never a policy claim, and it
renders STATE_UNAVAILABLE while control is gated off — so it is left
unchanged.

Fixes review finding F3
Routing every write through the gated selection made the gate refuse a
restore the user is already owed. A confirmed LineageOS device with an
open full-charge session that installs a nightly comes back with a
changed composite build identity, so the build is a candidate again —
Shizuku still available, provider still present, an 80% restore still
owed — and the protective write was refused, leaving charging configured
Unrestricted. The gate exists to withhold NEW control, never to withhold
protection the user already had.

restorePersistent() selects the matched adapter with its own probe
result, so every capability precondition still applies (system user,
provider/key presence, supported policy, a usable write backend) while
the evidence tier does not. The session restore, its failed-start
rollback, and boot recovery's rewrite hook take it; ordinary persistent
and temporary user writes stay on the gated path.

Fixes review finding F4
…ntrol

On a secondary user the Lineage probe correctly refuses control (the
provider keys are device-wide, sessions are per-user), but the registry
still labelled the device a CANDIDATE. The dashboard therefore offered
"Verify on this device", and tapping it moved the tier to UNDER_TEST —
a card stating controls are available — while canApply stayed false and
the recorder refused to observe. The verification could never complete
and nothing could reset it.

resolveEnforcement now returns the probe's own support untouched when it
already disabled control, so enforcement stays null: the specific probe
reason survives and no verification action is rendered. That also makes
the previous keep-the-probe-detail helper unreachable — every remaining
branch runs on a probe that was happy — so the detail assignments are
direct again.

Fixes review finding F5
The always-loaded safety-boundary notes still described confirmation as
a sustained plateau, which is exactly the reading that could not tell a
cap hold from a thermal pause. They now state the hardware-signal
requirement and its asymmetry with refutation, the restore path that
deliberately bypasses the tier, and the short-circuit for probes that
already refused control.

Follows review findings F1, F4, F5
EXTRA_CHARGING_STATUS == 4 was treated as a hardware hold signal and
gated the CONFIRMED verdict. It is not one: on a Pixel 6 running
LineageOS 23.2, holding at a 70% cap, dumpsys reported charging state 4;
raising the cap to 80 had the device actively charging at level 70 —
ten points below the cap — with the state still 4. The value means
"limit mode is enabled for this plug session", not "charging is stopped
right now", which is what StatsLimitHitDetector's KDoc already
documents for Pixel.

No other broadcast field discriminates a cap hold from a thermal or
weak-supply pause: the only difference between the two measured states
was EXTRA_STATUS, which a thermal pause produces too. So the signal
carries no information and the confirmation it gated cannot be trusted.

The engine keeps only the refutation arm (epoch, level-validity guard,
phase-local climb tracking, overshoot allowance); the hold band, sample
and duration thresholds, the adapter hold signal and the sample field
that carried it are gone. EnforcementVerdict keeps REFUTED as its only
constant so the stored wire format and its @SerialName stay stable, and
the store's record() collapses to "a refutation is terminal".

EnforcementStatus.CONFIRMED stays: the registry still reaches it through
the maintainer-qualified codename fast path, which rests on physical
qualification rather than observation. UNDER_TEST becomes UNVERIFIED —
with no confirmation there is no pending result — and the user-facing
copy now offers "enable anyway" on an unconfirmed build instead of a
check that produces a verdict.

A guided two-cap challenge (cut, resume, cut again) remains the known
way to earn a real confirmation and is deliberately not implemented.

Fixes review finding F6
The verdict heuristic materially changed in this branch: the
confirmation arm is gone, and version 1 also weighed a hardware signal
that turned out to be session-scoped. A version-1 record therefore is
not this version's verdict and must read as no evidence, which the
store's scoping already enforces once the constant moves.

Fixes review finding F7
setPersistentPolicy persists its target as pending recovery work BEFORE
the write it guards, so a process death or a failed write leaves a fresh
user choice — Unrestricted included — owed as recovery. Boot recovery
then rewrote every pending target through the ungated restore path,
which meant that choice could land on a build the enforcement gate
refuses: an OTA changes the composite build identity and turns a
previously usable build into a candidate, and a refutation can arrive in
between.

Recovery targets now carry a persisted RecoveryOrigin. Targets seeded
from the session being recovered are SESSION_RESTORE and keep the
ungated restorePersistent(forceNotify = true) — that write repays an
obligation the user already had, and refusing it would strand the device
in the session's Unrestricted state. Targets created by
setPersistentPolicy are USER_REQUEST and go through the gated
reapplyPersistent, exactly like the call that created them. The origin
travels through BootRecoveryFlow with the target it belongs to, read as
one record so target and origin cannot be paired across a concurrent
write.

The field defaults to USER_REQUEST, the gated direction, so a record
written before it existed cannot bypass the gate either.

Fixes review finding F8
Bumping ALGORITHM_VERSION to 2 invalidated every version-1 record, but
only the confirmation arm was unsound: the refutation never depended on
the session-scoped hardware signal and is semantically unchanged. A
device that recorded REFUTED under version 1 therefore read as "no
evidence" after an update, and since the opt-in preference is retained
the tier resolved to UNVERIFIED and control came back on hardware
already observed charging past its cap.

The store now inspects the raw JSON's algorithmVersion and verdict
before typed deserialization. A version-1 CONFIRMED reads Absent (it is
worth nothing; typed decoding would fail on the removed constant and
read Corrupt, which the gate treats as a refutation and would lock the
device out for good), a version-1 REFUTED is decoded and restamped to
version 2 so it keeps applying and stays terminal, and anything else
still reads Corrupt. Version-2 records take the unchanged path.

Fixes review finding F9.
EnforcementProgress.climbBase was assigned and threaded through every
tick but no longer read by any decision once the refutation moved to the
phase-local climbRose flag. Its rationale (track the climb from any
level, above the cap included) belongs to climbRose now and moved into
that KDoc.

EnforcementSample.batteryStatus, chargingStatus, currentNowMicroamps and
elapsedRealtimeMillis are likewise decision-dead: the refutation keys on
the level trend alone, so a status field could only suppress a real
refutation, and the hardware charging state is session-scoped. Dropping
them takes the matching population work with them — RawEnforcementTick
no longer carries the battery status or the observation timestamp, and
the recorder no longer reads a full BatteryReadout per tick. wallMillis
stays (the persisted verdict is stamped with it) and so does the battery
intent, which still backs the level fallback for a tick whose percent is
unknown.

Two engine tests only varied the removed inputs and are gone with them:
the plateau case they duplicated now carries the measured note about the
session-scoped hardware state, and the refutation-ignores-status case is
structural now that no status reaches the engine at all.
Both enforcement previews still described a verification that runs and
finishes. Nothing observable can confirm a cap, so the UNVERIFIED tier
is "the user enabled the controls anyway", not "a check is in progress",
and the candidate card's action is "Enable anyway" rather than a start
button. Renamed the preview and reworded both comments to match.
The 2026-07-22 LOS 23.2 row recorded a NO-GO because the HAL stopped
offering the LIMIT mode bit and LineageOS coerced charging_control_mode=3
to 1. On build BP4A.251205.006 that no longer holds: dumpsys lineagehealth
binds ccprovider.Limit, mode 3 persists, and the device held at exactly its
70% cap on AC with status 4 / Charging state 4 for hours. Raising the limit
to 80 resumed charging within 20s, so the setting demonstrably drives the
charging hardware rather than only the Settings UI.

Recorded as superseding evidence for that build only, not as a GO: just part
of step 2 was run (wired, no wireless, no threshold sweep, no hold at the
raised cap) and none of steps 3-5. oriole stays out of QUALIFIED_CODENAMES.

The same run established that EXTRA_CHARGING_STATUS is session-scoped - it
read 4 while charging ten points below the cap - which is why the new
enforcement gate can only refute a cap, never confirm one.
The 2026-08-17 row recorded a controlled hold at the 70% cap and a
cap-raise that resumed charging. Hours later the same device read 78%
with the limit still 70. That is logged here but explicitly marked
unattributable: the phone had been physically unplugged and moved, and
its clock force-set to a month in the past at 02:00 by another workflow,
so a scheduled-charging path could equally explain it.

Recorded so the row cannot be read as selectively reporting only the
observations that supported enforcement.
CANDIDATE and REFUTED both leave controlEnabled false, so the repository
publishes ChargeObservation.Unsupported and the hero rendered the generic
"Unsupported device" title - directly above a card explaining that the
adapter matched and the controls are one tap away. On a Pixel 6 running
LineageOS 23.2 the two surfaces contradicted each other on first launch.

The hero now reads the tier when it is the reason control is off: a
candidate build says the controls are not switched on yet and points at
the card below, a refuted build names the refutation. The card keeps the
full paragraph; the hero does not repeat it. No gate input changes -
resolveEnforcement, controlEnabled and canApply are untouched, and the
policy controls stay absent pre-opt-in rather than becoming a disabled
row of percentages next to the call to action.
Asserts against the hero card specifically (ancestor-scoped matcher), so
a string that also appears on a card further down the list cannot satisfy
the test. Covers both directions: candidate and refuted must not carry
the generic unsupported title, and a probe refusal with no tier at all
must still keep it.

Fixes review finding F10.
@d4rken d4rken added device support Request to add charge-control support for a device/OEM Shizuku Shizuku / ADB privileged access related ROM: LOS LineageOS labels Aug 17, 2026
@github-actions github-actions Bot added the enhancement New feature or request label Aug 17, 2026
d4rken added 2 commits August 17, 2026 19:12
Two conflicts, both places where this branch and the charge-conditions work independently addressed the same concern.

DashboardScreen: main added provesPolicyInEffect() so a readback of a conditional policy cannot earn the confirmed checkmark; this branch withheld the same checkmark on a build whose enforcement was never confirmed. Both survive - either condition alone now withholds it. The tier-aware hero title and the UNVERIFIED provenance line are unchanged.

ChargingRepository: main made nativeSettingsIntent() never null so an unmapped device reaches the generic battery-settings chain instead of Battery Saver; this branch routed adapter lookups through capabilityAdapter() because select() no longer has a default evidence state. Main's fallback now sits on top of that helper, since its registry.select() call no longer compiles.

Three further breaks were invisible to the conflict markers and only surfaced by compiling: main's new conditional-policy preview did not pass onStartVerification, and its UnmappedDeviceSettingsIntentTest constructed ChargingRepository without evidenceStore/buildIdentity.
Second merge round: main advanced again while this branch was validating (PR #75, the charge-conditions feature and its follow-ups).

Both conflicts were additive rather than competing. ChargeSessionService: main added a rules-layer suspension in the same persisted-intent step, this branch added the USER_REQUEST recovery origin to the same call - both kept. ChargeMonitorWatcherGraphTest: each side added a watcher-binding test and an import, both kept as separate cases.

As in the first round the conflict markers understated the work: main's new onOpenConditions parameter was not passed by this branch's three enforcement previews, which only the compiler surfaced.
@d4rken
d4rken merged commit e79dbae into main Aug 17, 2026
12 checks passed
@d4rken
d4rken deleted the feat/enforcement-evidence-gate branch August 17, 2026 17:46
d4rken added a commit that referenced this pull request Aug 17, 2026
Main landed the observed-enforcement gate (#79) and the quick-action
buttons (#77) while this branch was waiting on CI.

Two textual conflicts, both the same shape and both in test setup where
main added ChargingRepository's evidenceStore/buildIdentity arguments on
the exact line this branch added BatteryReader's unitCalibration
argument. Both sides are wanted, so both were kept.

A third break was semantic rather than textual, so git merged it cleanly
and the compiler caught it: ChargingRepositoryRestoreGateTest arrived
with main as a new file and constructs BatteryReader without the
calibration argument this branch added. Same fix as the other five call
sites.

Verified after resolution: 1345 tests across both flavors. Checked
specifically that main's adapter-gating rework did not invalidate this
branch's UnmappedDeviceSettingsIntentTest, which asserts a device matches
no adapter at all; LineageOS now matches on the provider rather than a
codename allowlist, but a bare Robolectric device still selects nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

device support Request to add charge-control support for a device/OEM enhancement New feature or request ROM: LOS LineageOS Shizuku Shizuku / ADB privileged access related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant