Skip to content

Commit 2b1db02

Browse files
authored
Charging: Add GrapheneOS charge-limit support (#56)
GrapheneOS backs "Limit to 80%" with the world-readable key `global battery_charge_limit` (0/1, WSS-writable, no Shizuku) but samples it only at plug-session start: external writes apply at the next unplug/replug. A new adapter capability (policyLatchesAtPlug) carries everything: a condition-based pending-until-replug verification state (no settling clock, resolves only on evidence), a persisted 30s disconnect grace window in the session engine so the user's deliberate replug latches the override instead of triggering restore-on-disconnect, and "unplug and replug" cues across dashboard/widget/tile/notifications. Identity resolves core app.grapheneos.* system packages (FLAG_SYSTEM required; no property/feature/fingerprint marker exists); the adapter precedes the Pixel adapter, whose Google+Pixel probe would otherwise swallow the device. Remote qualification via issue #49 (Pixel 9 Pro XL komodo, GrapheneOS 2026080501/A17): enforcement at 80% with hardware state 4 observed by the reporter; open items are recorded in the qualification ledger and all fail closed. Existing adapters are bit-for-bit unchanged. Includes a cross-model review hardening pass. Closes #49.
1 parent 5e9aaa0 commit 2b1db02

46 files changed

Lines changed: 1609 additions & 62 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Amply is an **experimental Android controller for OEM battery charge-protection
88
temporarily allows a full charge, then restores the user's protective policy at 100%, on unplug, or at a safety
99
timeout.
1010

11-
Several control adapters exist — four OEM adapters plus a custom-ROM (LineageOS) adapter. **Pixel charging optimization** is capability-gated to Pixel 6a and newer phones on
11+
Several control adapters exist — four OEM adapters plus two custom-ROM adapters (LineageOS, GrapheneOS). **Pixel charging optimization** is capability-gated to Pixel 6a and newer phones on
1212
Android 15+ when Google's charging-optimization controller is present. **Samsung battery protection** (global
1313
`protect_battery` keys) is gated to verified One UI generations — One UI 8 multi-mode, and the legacy One UI 4/5
1414
toggle — on the system user. **Xiaomi charging protection** (secure `security_pc_secure_protect_mode_key`,
@@ -19,10 +19,15 @@ binary Adaptive/Unrestricted) is gated to the HyperOS 2.x ROM (`ro.mi.os.version
1919
`lineagesettings` provider, keys `charging_control_enabled`/`_mode`/`_charging_limit`) is manufacturer-agnostic —
2020
gated to a **physically-qualified device-codename allowlist** (HAL enforcement is per-device) plus the provider and
2121
system user; **reads are unprivileged (ContentResolver), writes require Shizuku** (the shell UID holds
22-
`lineageos.permission.WRITE_SETTINGS`, which `WRITE_SECURE_SETTINGS` does not cover). Other Pixels, Samsung on
23-
unverified One UI versions (6/7, 9+), non-HyperOS-2 Xiaomi devices, non-ColorOS-15 Oplus devices, and unqualified
24-
LineageOS builds remain diagnostics-only. See the qualification ledger (`.claude/skills/device-qualification/`) for
25-
the verified devices and mappings.
22+
`lineageos.permission.WRITE_SETTINGS`, which `WRITE_SECURE_SETTINGS` does not cover). **GrapheneOS charge limit**
23+
(world-readable `global battery_charge_limit`, binary FixedLimit(80)/Unrestricted, WSS-writable — no Shizuku) is
24+
gated to GrapheneOS identity (its `app.grapheneos.*` core packages; no property/feature/fingerprint marker exists)
25+
plus key presence and the system user; the ROM **latches the key at plug-session start** (`policyLatchesAtPlug`),
26+
so external writes take effect at the next unplug→replug — handled by a pending-until-replug verification state
27+
and a 30s session grace window; the reconnect gesture is unsupported there. Other Pixels, Samsung on
28+
unverified One UI versions (6/7, 9+), non-HyperOS-2 Xiaomi devices, non-ColorOS-15 Oplus devices, unqualified
29+
LineageOS builds, and GrapheneOS builds without the key remain diagnostics-only. See the qualification ledger
30+
(`.claude/skills/device-qualification/`) for the verified devices and mappings.
2631

2732
Package: `eu.darken.amply`. License: GPL-3.0-or-later. Status: pre-launch (current version in `VERSION`).
2833

.claude/rules/architecture.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ single shared store, every write reaches every collector, so the deduplication h
3030
(Shizuku) readback, or a hardware reading for a *different* policy, does **not** clear it — the old policy
3131
legitimately still reads during the ~11–12s Pixel HAL transition. A WorkManager `SettleScheduler` fires one refresh
3232
at the window's end so the static widget/tile clear across process death.
33+
- **Plug-latched pending** (adapters with `policyLatchesAtPlug`, GrapheneOS): the ROM samples the configured policy
34+
only at plug-session start, so a write made while plugged carries `PendingRequest(awaitingReplug = true)` — a
35+
*condition*, not a countdown, with **no expiry**. It resolves only on evidence (`computeRefreshPending`'s latched
36+
arm): written-unplugged, an observed unplug (live, or the persisted `unpluggedSeenAt` watermark), hardware state 4
37+
for the exact target, or — for full-charge targets — the battery observably charging *above* the adapter's cap.
38+
Surfaces show a "replug to apply" hint instead of a spinner; the hint may linger while nothing observes a replug
39+
(accepted staleness), but the reverse error — claiming applied when not — cannot occur.
3340
- **Widget persistent-policy writes are atomic**: the ∞80% / ∞100% buttons route through a serialized
3441
`ACTION_SET_PERSISTENT_POLICY` command that cancels any running session **without restoring** and force-writes the
3542
chosen policy, so an explicit always-on choice never races the session's own writes.
@@ -47,15 +54,20 @@ indistinguishable).
4754

4855
## OEM Adapters
4956

50-
Per-adapter detail (Samsung, Xiaomi, OnePlus/ColorOS, LineageOS, Pixel — keys, value domains, write ordering,
51-
session overrides) lives in the **`oem-adapters` skill** — read it before changing anything under
57+
Per-adapter detail (Samsung, Xiaomi, OnePlus/ColorOS, LineageOS, GrapheneOS, Pixel — keys, value domains, write
58+
ordering, session overrides) lives in the **`oem-adapters` skill** — read it before changing anything under
5259
`charging/core/adapter`.
5360

5461
## Temporary Session & Recovery
5562

5663
- Before removing the limit, Amply persists the exact verified/requested protective policy (or the stored baseline).
5764
- A `specialUse` foreground service monitors the sticky battery broadcast (~30 s) and restores on: full charge,
5865
disconnect-after-connection, a 15-minute arming timeout, or a 24-hour safety timeout.
66+
- **Replug grace window** (plug-latched adapters only): a disconnect does not restore immediately — the engine emits
67+
`MARK_DISCONNECTED` and opens a persisted 30s window (`REPLUG_GRACE_MILLIS`, wall clock, survives process death);
68+
a replug inside it emits `MARK_REPLUGGED` and continues the session (the plug transition is what latches the
69+
override), expiry or a backwards clock restores as before. `full` and the 24h safety timeout keep priority. On
70+
every other adapter `replugGraceMillis` is 0 and the decision table is unchanged.
5971
- While active, the service watches the adapter's settings URIs; an unexpected native/system change **cancels without
6072
restoring**, so Amply never overwrites a newer external choice.
6173
- Boot recovery runs the restore *inside the service* with a bounded convergence check (re-write until the HAL

.claude/rules/privileged-access.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ for get / put / WSS grant / diagnostic snapshots. Hard rules:
3838
the allowlist without an explicit, reviewed reason.
3939
- Every writable key carries an explicit **per-key value domain** (`SettingWritePolicy`) — the boundary itself
4040
rejects out-of-domain values. The Samsung keys (`global protect_battery`, `global battery_protection_threshold`),
41-
the Xiaomi key (`secure security_pc_secure_protect_mode_key`), and the Oplus keys (`system
42-
regular_charge_protection_switch_state`, `system smart_charge_protection_switch_state`) are all **live** on gated
43-
devices (see Capability Gates).
41+
the Xiaomi key (`secure security_pc_secure_protect_mode_key`), the Oplus keys (`system
42+
regular_charge_protection_switch_state`, `system smart_charge_protection_switch_state`), and the GrapheneOS key
43+
(`global battery_charge_limit`) are all **live** on gated devices (see Capability Gates).
4444

4545
## Capability Gates
4646

@@ -87,6 +87,32 @@ assumptions: the feature is treated as present on any HyperOS 2 device (a device
8787
absent → a harmless false claim of control), and daemon-level enforcement of external writes is pending
8888
long-term observation (see Known gaps below).
8989

90+
### GrapheneOS
91+
92+
GrapheneOS control requires **all** of: GrapheneOS identity (`DeviceInfo.isGrapheneOs` — resolved from the OS's
93+
core `app.grapheneos.*` packages via PackageManager `<queries>` entries; **no** graphene property, system feature,
94+
or fingerprint marker exists, verified on a real device), a present world-readable `global battery_charge_limit`
95+
key (the capability signal — GrapheneOS ships the toggle exactly where its implementation works), and the system
96+
user. Identity is deliberately **not** OR-ed with key presence: the adapter is registered ahead of the Pixel
97+
adapter, and a future stock Pixel shipping a same-named key must not be swallowed as GrapheneOS.
98+
99+
The key is binary (`1` = fixed 80% cap with bypass charging, `0` = off) and WSS-writable — **no Shizuku needed**.
100+
The defining quirk is **`policyLatchesAtPlug`**: the ROM samples the key only at plug-session start, so an external
101+
write reads back correctly but has no hardware effect until the next unplug→replug (the native Settings toggle
102+
applies live because Settings pokes the charging service directly). Three mechanisms handle this — the
103+
pending-until-replug verification state (condition-based, no settling clock), the session engine's 30s replug grace
104+
window (a disconnect during a session opens a window instead of restoring, so the user's replug latches the
105+
override rather than a premature restore), and `reapply == apply` (no observer to re-trigger). While enforcing, the
106+
device reports the stock-Pixel hardware signal (`EXTRA_CHARGING_STATUS` = 4), which the adapter decodes for real
107+
enforcement evidence. The reconnect gesture is unsupported — its override write lands strictly after the replug
108+
broadcast, which the ROM has already sampled past.
109+
110+
Qualification is **remote** (issue #49, Pixel 9 Pro XL `komodo`, GrapheneOS 2026080501 / Android 17): the tester
111+
physically observed enforcement (held at 80% with the shield and state 4) and the latch behavior. Package
112+
visibility of `app.grapheneos.*` from app context and the factory-absent key state are still unverified on-device;
113+
both fail closed (Pixel-adapter diagnostics, or diagnostics + contribution wizard). Record any new evidence in the
114+
qualification ledger (`device-qualification` skill).
115+
90116
### LineageOS
91117

92118
LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), a **physically-qualified

.claude/skills/device-qualification/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
5050
| Samsung | Galaxy Tab A9+ SM-X210 One UI 8.0; Galaxy S20 FE SM-G781B One UI 4.1 | Full — sync readback + HAL enforcement | Modern multi-mode + legacy toggle, session E2E, native-change cancel, reboot recovery, R8 beta | 2026-07-21 |
5151
| Xiaomi | Xiaomi 13T `2306EPN60G` HyperOS 2.0 (`ro.mi.os.version.code=2`) | **Partial** — mapping/readback/session verified; the adaptive 80% hold could not be triggered, so daemon-level hardware enforcement is **not yet demonstrated** | Read matrix, both-direction writes, session at 100%, unknown-value refusal, R8 beta | 2026-07-21 |
5252
| OnePlus (Oplus) | OnePlus Nord CE4 Lite `CPH2621` ColorOS 15 (`ro.build.version.oplusrom=V15.0.0`) | Full — enforcement directly observable (device holds at 80%); external writes stick | Two mutually-exclusive `system` keys (Charging limit / Smart charging), WSS-only write rejected + Shizuku write succeeds for all three policies, WSS-only UX (controls disabled + Shizuku-required banner) | 2026-07-21 |
53+
| GrapheneOS | Pixel 9 Pro XL `komodo`, GrapheneOS 2026080501 / Android 17 — **REMOTE qualification via issue #49** (tester-run protocol, not maintainer hardware) | **Enforcement observed**: held at 80% with shield, `dumpsys battery` status=4/Charging state=4/policy=2 (limit on) vs 2/1/1 (off); shell-UID writes move the Settings UI live, **latch at plug-session start** — mid-session writes have no hardware effect until unplug→replug, replug reliably applies the current value | Key isolation (`settings list` diff → single `global battery_charge_limit` 0/1), write→UI both directions, mid-session no-op both directions, replug latch both directions, hardware signal both states. **NOT run**: app-context access tiers (WSS write from Amply, `app.grapheneos.*` package visibility), sessions/boot recovery, wireless, factory-absent key state, secondary user | 2026-08-12 |
5354

5455
## Known gaps
5556

@@ -114,6 +115,29 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
114115
(`google/oriole/oriole:16/…/release-keys`), so fingerprint sniffing is not a fallback. Otherwise clean on this
115116
ROM: install/launch/onboarding/dashboard/settings with no crashes, honest "Unsupported device" reporting, live
116117
battery monitoring across simulated plug/level transitions, and the charge alarm firing at threshold.
118+
- **GrapheneOS** — landed **live** on remote qualification (issue #49; the only OEM row not tested on maintainer
119+
hardware). Open items, all failing closed:
120+
- **`app.grapheneos.*` package visibility from app context is unverified**`<queries>` package entries are
121+
specified platform behavior (not SELinux-fragile like `ro.lineage.*`), but GrapheneOS hardens aggressively. If
122+
the packages are hidden, `isGrapheneOs` is false and the device falls to the Pixel adapter as
123+
matched/diagnostics-only — no unsafe write path, but support silently vanishes; the first tester report of
124+
"still unsupported" on the test build should check `is_grapheneos=` in the device report.
125+
- **App-context WSS write unverified** — the tester's writes ran as shell UID; an Amply-originated
126+
`Settings.Global.putString` under granted WSS is expected to behave identically (same namespace rules) but has
127+
not been observed. The read-back-equality check catches a silently-failing write.
128+
- **Factory-absent key state unknown** — the tester's device had the key present while off; whether a
129+
never-toggled install exposes it is unverified. Absent → gate fails closed → diagnostics + contribution wizard
130+
(`adapter_detail_grapheneos_no_key`), and `read()` refuses (`unrecognizedValue`) so a session never clobbers it.
131+
- **State 4 below the limit unverified** — evidence was sampled at the 80% hold; if the ROM reports 4 only while
132+
holding, a FixedLimit pending clears late (at the hold) instead of instantly. Cosmetic.
133+
- **A plugged restore configures but cannot enforce** — restore-at-100%, the 24h safety timeout,
134+
manual restore, and a plugged boot recovery all write the protective value while a plug session
135+
is running; the ROM won't enforce it until the next replug, and no code path can change that
136+
(mid-session writes are ignored by design). Amply's state is correct — config protective,
137+
session/recovery closed, pending-until-replug hint shown — and the exposure is one charge cycle,
138+
bounded by the plug session the user is already in. Deliberately NOT treated as a defect.
139+
- **Wireless charging and secondary users**: NOT RUN (gated to system user).
140+
- Sessions/boot-recovery/R8 smoke on real GrapheneOS hardware: pending the test build posted to issue #49.
117141
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
118142
the 80% hold is physically observed.
119143
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified, stays diagnostics-only).** A

.claude/skills/oem-adapters/SKILL.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ states v1 can restore exactly (a supported fixed limit, or Unrestricted); AUTO/C
9090
an absent/malformed `enabled` decode to `Unknown(unrecognizedValue=true)` so a temporary session refuses rather than
9191
clobbering the user's native choice. Verified devices + coverage: see the qualification ledger (`device-qualification` skill).
9292

93+
## GrapheneOS Adapter
94+
95+
One live adapter (`grapheneos-chargelimit-v1`) for GrapheneOS's own "Limit to 80%" (Settings → Battery → Charging
96+
optimization). **ROM-identity adapter, ordered after the Lineage pair and BEFORE `pixel`** in `AdapterRegistry`
97+
GrapheneOS ships only on Pixels, and the Pixel probe (any Google/Pixel*) would otherwise swallow the device as a
98+
matched-but-diagnostics-only stock Pixel. Gate: `DeviceInfo.isGrapheneOs` (core `app.grapheneos.*` packages via
99+
PackageManager `<queries>` — NO property/feature/fingerprint marker exists; verified empty on a real device) +
100+
`hasBatteryChargeLimit` (world-readable key presence — the capability signal, deliberately NOT part of identity) +
101+
system user. No lab adapter: a GrapheneOS build without the key stays on this adapter as diagnostics-only with
102+
`contributionWanted`.
103+
104+
Single key `global battery_charge_limit`: `1` = fixed 80% cap (bypass charging; hard-wired, no threshold key) →
105+
`FixedLimit(80)`, `0` = off → `Unrestricted`; absent/other → `Unknown(unrecognizedValue=true)` (factory-absent
106+
semantics unverified — refuse, don't guess). WSS-writable, **no Shizuku**. SYNC_READBACK with read-back equality;
107+
session override = Unrestricted; protective default = FixedLimit(80); `reapply == apply` (no observer-poke — see
108+
below); reconnect gesture **unsupported** (structurally: the gesture's override write lands strictly after the
109+
replug broadcast, which the ROM has already sampled past).
110+
111+
**The defining quirk: `policyLatchesAtPlug = true`.** GrapheneOS samples the key only at plug-session start; an
112+
external write updates the Settings UI live but has no hardware effect until the next unplug→replug (the native
113+
toggle applies live because Settings pokes the charging service directly — so the session watcher's
114+
cancel-without-restore on an observed external change stays correct). This drives the pending-until-replug
115+
verification state and the session engine's 30s replug grace window (see `rules/architecture.md`). While enforcing,
116+
the device reports stock-Pixel hardware state 4 (`EXTRA_CHARGING_STATUS`), which the adapter's own `decodeHardware`
117+
maps to `Verified(FixedLimit(80), BATTERY_HARDWARE)` — deliberately not shared with the Pixel decode, which also
118+
maps state 5 to an Adaptive profile this adapter cannot restore. Remote qualification (issue #49): see the
119+
`device-qualification` skill.
120+
93121
## Pixel Adapter
94122

95123
Writes **only** two secure settings:

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
<queries>
1313
<package android:name="moe.shizuku.privileged.api" />
1414
<package android:name="com.google.android.settings.intelligence" />
15+
<!-- GrapheneOS identity: no property/feature/fingerprint marker exists, so detection resolves
16+
these core OS packages (DeviceInfo.GRAPHENEOS_PACKAGES). -->
17+
<package android:name="app.grapheneos.setupwizard" />
18+
<package android:name="app.grapheneos.info" />
1519
<!-- LineageOS private settings provider — needed so resolveContentProvider/query aren't
1620
hidden by package visibility on API 30+ (the Lineage charge-control adapter reads it). -->
1721
<provider android:authorities="lineagesettings" />

app/src/main/java/eu/darken/amply/charging/core/ChargeStatus.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const val SETTLING_WINDOW_MILLIS = 15_000L
1414
*/
1515
fun ChargingState.isSettling(now: Long): Boolean {
1616
val p = pending ?: return false
17+
// A latched request has no clock to run down — surfaces show the replug hint, not a spinner.
18+
if (p.awaitingReplug) return false
1719
val age = now - p.requestedAt
1820
if (age !in 0 until SETTLING_WINDOW_MILLIS) return false // expired, or clock moved backwards
1921
val obs = observation
@@ -24,3 +26,9 @@ fun ChargingState.isSettling(now: Long): Boolean {
2426

2527
/** The policy a settling request is converging on, or null when nothing is pending. Surfaces choose their own copy. */
2628
fun ChargingState.settlingTarget(): ChargePolicy? = pending?.target
29+
30+
/**
31+
* True while a written policy is waiting for the user to unplug and replug before the charging
32+
* hardware can pick it up (plug-latched adapters). Mutually exclusive with [isSettling].
33+
*/
34+
fun ChargingState.isAwaitingReplug(): Boolean = pending?.awaitingReplug == true

0 commit comments

Comments
 (0)