Charging: Enable charge control on LineageOS devices - #79
Merged
Conversation
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.
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
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.
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
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
Deadlinewhether 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_STATUSwas measured on a Pixel 6 / LineageOS 23.2 still reading4while 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.FINGERPRINTalone 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.