Skip to content

Charging: Add GrapheneOS charge-limit support - #56

Merged
d4rken merged 7 commits into
mainfrom
feat/grapheneos-adapter
Aug 13, 2026
Merged

Charging: Add GrapheneOS charge-limit support#56
d4rken merged 7 commits into
mainfrom
feat/grapheneos-adapter

Conversation

@d4rken

@d4rken d4rken commented Aug 12, 2026

Copy link
Copy Markdown
Member

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

  • Root constraint: GrapheneOS samples global battery_charge_limit only 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 in device-qualification record 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).
  • Why a condition-based pending state: a 15s settling clock would silently claim success on a write with zero effect. The latched pending has no expiry and resolves only on evidence (written-unplugged with samples on both sides of the write agreeing, an observed unplug via a persisted watermark, hardware state 4, charging observably above the cap, or a competing native change). Deserves close review: computeRefreshPending's latched arm and its tests.
  • Why the session grace window: restore-on-disconnect fired on exactly the unplug the user performs to make the override latch, silently cancelling the full charge. The engine now opens a persisted 30s window (MARK_DISCONNECTED/MARK_REPLUGGED); expiry, backwards clocks, and late replugs all restore. Deserves close review: SessionDecisionEngine and the service's grace handlers.
  • Detection: no graphene property/feature/fingerprint marker exists (verified on-device), so identity resolves core app.grapheneos.* system packages (with FLAG_SYSTEM required, 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.
  • Known limitations, all failing closed (details in the device-qualification known-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.

d4rken added 7 commits August 12, 2026 15:08
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 d4rken added the device support Request to add charge-control support for a device/OEM label Aug 12, 2026
@github-actions github-actions Bot added the enhancement New feature or request label Aug 12, 2026
@d4rken
d4rken marked this pull request as ready for review August 13, 2026 08:56
@d4rken
d4rken merged commit 2b1db02 into main Aug 13, 2026
12 checks passed
@d4rken
d4rken deleted the feat/grapheneos-adapter branch August 13, 2026 08:57
@rannlangel

Copy link
Copy Markdown

dbaba29838271bbde2de2b5ad724b59a360d3e46

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Device Support] GrapheneOS (Pixel 9 Pro XL)

2 participants