Charging: Add GrapheneOS charge-limit support - #56
Merged
Conversation
Some ROMs (GrapheneOS) sample the configured charge policy only at the start of a plug session: an external write reads back correctly but has no hardware effect until the next unplug/replug. A new adapter capability (policyLatchesAtPlug) makes such a write's pending state a condition instead of the 15s settling clock: it resolves only on an observed unplug (live, or via a persisted watermark), on hardware evidence for the exact target, or - for full-charge targets - on the battery observably charging above the adapter's cap. The plug state at write time and the unplugged-seen watermark are persisted field-by-field in the policy record so old records decode unchanged. Adapters without the capability keep today's behavior bit-for-bit.
On a plug-latched adapter the session override written while plugged only takes effect after an unplug/replug - but the session's immediate restore-on-disconnect wrote the protective policy back at exactly that unplug, so the user's replug latched protection and silently cancelled the full charge they had just requested. The decision engine now opens a persisted 30s grace window on the first disconnect (MARK_DISCONNECTED) instead of restoring: a replug inside it continues the session (the plug transition is what latches the override, MARK_REPLUGGED), expiry or a backwards wall clock restores as before. The window only exists when the adapter declares policyLatchesAtPlug; every other adapter's decision table is unchanged. The session notification walks the user through it (unplug-and-replug instruction while plugged, a countdown-style grace prompt while unplugged), and the grace unplug records the unplugged-seen watermark so the override's pending-until-replug state resolves live.
GrapheneOS exposes no identity an app can gate on the usual ways: no graphene-related system property exists at all and the build fingerprint is stock-Google-shaped (verified on a Pixel 9 Pro XL, build 2026080501), so property sniffing and fingerprint matching are both dead ends. Identity instead resolves the OS's own core app.grapheneos.* packages (setup wizard, Info app) through PackageManager with matching <queries> entries - specified platform behavior, unlike the SELinux-denied ro.lineage.* lesson. A separate world-readable global key probe (battery_charge_limit, the OS's 80% limit toggle) gates capability; it is deliberately not identity, so a future stock Pixel shipping a same-named key cannot be swallowed as GrapheneOS. Both signals join the device-support report (schema 9) and the support-lead predicate.
GrapheneOS backs its Battery > Charging optimization "Limit to 80%" with a single world-readable key, global battery_charge_limit (1 = hard 80% cap with bypass charging, 0 = off). Writes need only WRITE_SECURE_SETTINGS - no Shizuku. Remote qualification on a Pixel 9 Pro XL (komodo, GrapheneOS 2026080501 / Android 17, issue #49) proved enforcement: held at 80% with the shield and the same hardware signal stock Pixels report (EXTRA_CHARGING_STATUS = 4), which the adapter decodes for real enforcement evidence. The adapter declares policyLatchesAtPlug: the ROM samples the key only at plug-session start, so external writes apply at the next unplug/replug (the pending-until-replug state and the session grace window handle this). Identity is package-based (isGrapheneOs), capability is key presence plus the system user; the adapter is registered ahead of the Pixel adapter, whose Google+Pixel probe would otherwise swallow every GrapheneOS device as a diagnostics-only stock Pixel. Reconnect gesture stays unsupported - its override write lands strictly after the replug broadcast, which the ROM has already sampled past. The key joins the privileged write allowlist and the public contribution allowlist.
… and tile A plug-latched write shows no spinner and no 15s countdown - the configured value is real and verified, only the hardware pickup waits for a plug transition. The dashboard's settling-line slot gains an explicit 'takes effect the next time you unplug and replug' line, the widget status shows 'replug to apply' next to the configured target, and the QS tile subtitle carries the same hint (QS-open is the tile's natural refresh cadence). The dashboard's settling countdown observer skips these requests - resolution comes from evidence at the next refresh (MainActivity's battery receiver fires one on every plug transition while the UI is visible; the settle worker re-pushes the static surfaces). Adds a dashboard preview for the state.
… qualification Adds the GrapheneOS capability-gate section and live-key entry to privileged-access.md, the plug-latched pending state and session replug grace window to architecture.md, a full adapter section to the oem-adapters skill, and the qualification-ledger row - explicitly marked as REMOTE qualification via issue #49 (Pixel 9 Pro XL komodo, GrapheneOS 2026080501 / Android 17) - plus a known-gaps entry listing everything the remote run could not cover (app-context package visibility, app-context WSS writes, factory-absent key state, wireless, secondary users), each with its fail-closed behavior.
Five fixes from the Codex implementation review: - A replug after the grace window expired (or across a backwards clock) now restores instead of resuming the stale session; a process restart mid-grace re-arms the expiry nudge on its first CONTINUE tick. The physical latch is unavoidable either way; honoring the persisted 30s bound closes the session with a protective config. - A latched pending request is cancelled by a competing configured change (different verified value or unrecognized value) - the native toggle applies live on these ROMs, so the request is obsolete. - The unplugged-seen watermark persists BEFORE the session's grace mark so a process death between the two writes cannot strand a resolved request as pending. - 'Written unplugged' (which settles immediately) is only claimed when plug samples on BOTH sides of the write agree; the session's awaiting-replug flag is reconciled post-write from the repository's authoritative computation. - GrapheneOS package identity requires FLAG_SYSTEM/ FLAG_UPDATED_SYSTEM_APP, so a user-installed APK squatting on an app.grapheneos.* name cannot spoof ROM identity. Also makes the widget honest during a not-yet-latched session (1x100% - replug to start) and records in the qualification ledger why a plugged restore configuring-but-not-enforcing is the ROM's inherent limitation, not a defect.
d4rken
marked this pull request as ready for review
August 13, 2026 08:56
|
dbaba29838271bbde2de2b5ad724b59a360d3e46 |
rannlangel
approved these changes
Aug 13, 2026
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
Adds live charging control on GrapheneOS: the dashboard, widget, and tile can switch between the ROM's "Limit to 80%" and unrestricted charging, and the one-time full charge works — no Shizuku needed, a one-time WSS grant is enough. Because GrapheneOS only picks up an external change when the charger is reconnected, the app walks the user through that: an explicit "unplug and replug" cue on every surface instead of a spinner, and a session that survives the deliberate unplug→replug instead of cancelling itself. Devices where the setting is absent stay diagnostics-only with the contribution wizard offered.
Closes #49.
Technical Context
global battery_charge_limitonly at plug-session start (remote qualification in [Device Support] GrapheneOS (Pixel 9 Pro XL) #49 — enforcement at 80% with hardware state 4 was physically observed by the reporter; the ledger row and known-gaps list indevice-qualificationrecord exactly what that run could and could not cover). Everything hangs off one new adapter capability,policyLatchesAtPlug; every behavioral branch keys on it, so existing adapters are bit-for-bit unchanged (pinned by the pre-existing test suites).computeRefreshPending's latched arm and its tests.MARK_DISCONNECTED/MARK_REPLUGGED); expiry, backwards clocks, and late replugs all restore. Deserves close review:SessionDecisionEngineand the service's grace handlers.app.grapheneos.*system packages (withFLAG_SYSTEMrequired, so a name-squatting sideload can't spoof it) and is deliberately not OR-ed with key presence — the adapter precedes the Pixel adapter in the registry, and a future stock Pixel shipping a same-named key must not be swallowed.device-qualificationknown-gaps entry):app.grapheneos.*package visibility from app context and app-context WSS writes are unverified until the reporter runs a test build; a plugged restore configures but cannot enforce until the next replug (inherent to the ROM — the pending hint says so); the replug hint can linger while nothing observes a replug (never the reverse error); factory-absent key state, wireless, and secondary users are unverified. On-device verification happens via a test build posted to [Device Support] GrapheneOS (Pixel 9 Pro XL) #49, which CI cannot cover.