Skip to content

Commit 363bee0

Browse files
committed
Charging: Gate GrapheneOS charge control on Shizuku
The 0.3.2-beta0 on-device report (issue #49) disproved the landing assumption that global battery_charge_limit is world-readable and WSS-writable: the probe read absent while the same report showed the limit enforcing (charging state 4). GrapheneOS declares the key @Protected(read = SYSTEM_UI, readWrite = SETTINGS) (frameworks_base c30c6393) and its SettingsProvider throws SecurityException on reads and writes from every other package - including WRITE_SECURE_SETTINGS holders; the check is package-based and runs after the permission check (e87c93a2). The one exemption Amply can use is the shell UID ('ADB is used for testing'), which is exactly how the Shizuku user service executes settings get/put - the tester's successful adb runs were this path's evidence all along. The adapter now sets preferShizukuForWrites (existing Oplus plumbing: Shizuku-required banner, disabled controls, skipped WSS auto-grant) and drops the unprobeable key-presence gate - @Protected denies the probe whether the key exists or not, so presence is assumed on any GrapheneOS build, the Xiaomi-precedent assumption with the same accepted harmless-false-claim failure mode. An absent key now decodes as the factory off state per upstream source (BoolSetting default false) instead of refusing. Reads need no code change: the direct read comes back unreadable and readSyncDirectFirst already falls through to Shizuku. The shared Shizuku-required banner no longer claims state stays readable without Shizuku, which was only true for Oplus. On-device beta evidence recorded in the ledger: package detection verified from app context; unprivileged key access denied as designed (fail closed).
1 parent 3beb2f1 commit 363bee0

14 files changed

Lines changed: 155 additions & 102 deletions

File tree

.claude/CLAUDE.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ binary Adaptive/Unrestricted) is gated to the HyperOS 2.x ROM (`ro.mi.os.version
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
2222
`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.
23+
(`global battery_charge_limit`, binary FixedLimit(80)/Unrestricted) is gated to GrapheneOS identity (its
24+
`app.grapheneos.*` core packages; no property/feature/fingerprint marker exists) plus the system user —
25+
**reads AND writes require Shizuku**: GrapheneOS marks the key `@Protected`, denying it to all third-party
26+
packages including WSS holders, with only the shell UID exempt. The ROM **latches the key at plug-session start**
27+
(`policyLatchesAtPlug`), so external writes take effect at the next unplug→replug — handled by a
28+
pending-until-replug verification state and a 30s session grace window; the reconnect gesture is unsupported
29+
there. Other Pixels, Samsung on unverified One UI versions (6/7, 9+), non-HyperOS-2 Xiaomi devices,
30+
non-ColorOS-15 Oplus devices, and unqualified LineageOS builds remain diagnostics-only. See the qualification
31+
ledger (`.claude/skills/device-qualification/`) for the verified devices and mappings.
3132

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

.claude/rules/privileged-access.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,27 @@ long-term observation (see Known gaps below).
9090
### GrapheneOS
9191

9292
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.
93+
core `app.grapheneos.*` system packages (FLAG_SYSTEM required) via PackageManager `<queries>` entries; **no**
94+
graphene property, system feature, or fingerprint marker exists, verified on a real device) and the system user.
95+
**Key presence is deliberately NOT a gate condition and cannot be**: GrapheneOS declares the key
96+
`@Protected(read = SYSTEM_UI, readWrite = SETTINGS)` (frameworks_base `c30c6393`) and its SettingsProvider throws
97+
`SecurityException` on reads and writes from every other package — **including `WRITE_SECURE_SETTINGS` holders**;
98+
the check is package-based and runs after the permission check (`e87c93a2`). The unprivileged probe therefore
99+
reads absent whether the key exists or not (verified via the issue-#49 beta report: probe false while the same
100+
report showed the limit enforcing). The feature is treated as present on any GrapheneOS build (Xiaomi-precedent
101+
assumption; their platform ships it for every Google device, which is every device GrapheneOS supports). Accepted
102+
failure mode, same class as Xiaomi's: on a build without the feature a shell-UID write could still create the row
103+
and read back, yielding a harmless false claim of configured control — no battery hazard, and the hardware decode
104+
(state 4) stays honest.
105+
106+
**Access is Shizuku-only.** The one usable exemption in their enforcement is the shell UID ("ADB is used for
107+
testing"), which is how the Shizuku user service executes `settings get/put`. The adapter sets
108+
`preferShizukuForWrites`; direct reads come back unreadable (SecurityException → `access_read_blocked`) and
109+
`readSyncDirectFirst` falls through to the Shizuku backend. The key is binary (`1` = fixed 80% cap with bypass
110+
charging, `0` = off); an **absent key decodes as the factory off state** — upstream reads it through
111+
`BoolSetting(GLOBAL, BATTERY_CHARGE_LIMIT, /* default */ false)`, so a never-toggled device has no row and
112+
charges unrestricted.
98113

99-
The key is binary (`1` = fixed 80% cap with bypass charging, `0` = off) and WSS-writable — **no Shizuku needed**.
100114
The defining quirk is **`policyLatchesAtPlug`**: the ROM samples the key only at plug-session start, so an external
101115
write reads back correctly but has no hardware effect until the next unplug→replug (the native Settings toggle
102116
applies live because Settings pokes the charging service directly). Three mechanisms handle this — the
@@ -108,10 +122,10 @@ enforcement evidence. The reconnect gesture is unsupported — its override writ
108122
broadcast, which the ROM has already sampled past.
109123

110124
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).
125+
physically observed enforcement (held at 80% with the shield and state 4), the latch behavior, and — via the
126+
0.3.2-beta0 report — that package detection works from app context while unprivileged key access is denied.
127+
Shizuku-path writes are the same shell-UID mechanism the tester's adb commands proved. Record any new evidence in
128+
the qualification ledger (`device-qualification` skill).
115129

116130
### LineageOS
117131

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
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 |
5353
| 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 |
54+
| GrapheneOS (follow-up) | Same device, **0.3.2-beta0 on-device report via issue #49** | **Package detection VERIFIED from app context** (`is_grapheneos=true` with the FLAG_SYSTEM check); **unprivileged key read DENIED**`has_battery_charge_limit=false` while the very same report showed `battery_charging_status=4` (limit enforcing). Root cause in GrapheneOS source: the key is `@Protected(read = SYSTEM_UI, readWrite = SETTINGS)` (frameworks_base `c30c6393`); SettingsProvider throws SecurityException for all other packages **including WSS holders**, with the shell UID explicitly exempt ("ADB is used for testing", `e87c93a2`) — so the tester's earlier adb runs ARE the Shizuku-path evidence. Factory-absent semantics resolved from source: `BoolSetting(..., default false)` → absent = off | Detection + fail-closed probe verified live; adapter re-gated to Shizuku-only in response | 2026-08-13 |
5455

5556
## Known gaps
5657

@@ -116,18 +117,14 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
116117
ROM: install/launch/onboarding/dashboard/settings with no crashes, honest "Unsupported device" reporting, live
117118
battery monitoring across simulated plug/level transitions, and the charge alarm firing at threshold.
118119
- **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.
120+
hardware), then **re-gated to Shizuku-only** after the 0.3.2-beta0 on-device report (see the follow-up ledger
121+
row). Resolved since the landing PR: package visibility ✔ verified from app context; app-context WSS access ✘
122+
resolved as DENIED (`@Protected` — the reason for the re-gate, not a bug in Amply); factory-absent key state ✔
123+
resolved from upstream source (`BoolSetting` default false → absent decodes as Unrestricted). Still open, all
124+
failing closed or cosmetic:
125+
- **Shizuku end-to-end on real hardware unverified** — the shell-UID `settings get/put` mechanism is proven (the
126+
tester's adb runs), but Amply's own Shizuku service driving it, plus sessions/boot recovery/R8 smoke, await the
127+
next beta report on issue #49.
131128
- **State 4 below the limit unverified** — evidence was sampled at the 80% hold; if the ROM reports 4 only while
132129
holding, a FixedLimit pending clears late (at the hold) instead of instantly. Cosmetic.
133130
- **A plugged restore configures but cannot enforce** — restore-at-100%, the 24h safety timeout,
@@ -137,7 +134,6 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
137134
session/recovery closed, pending-until-replug hint shown — and the exposure is one charge cycle,
138135
bounded by the plug session the user is already in. Deliberately NOT treated as a defect.
139136
- **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.
141137
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
142138
the 80% hold is physically observed.
143139
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified, stays diagnostics-only).** A

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,22 @@ clobbering the user's native choice. Verified devices + coverage: see the qualif
9595
One live adapter (`grapheneos-chargelimit-v1`) for GrapheneOS's own "Limit to 80%" (Settings → Battery → Charging
9696
optimization). **ROM-identity adapter, ordered after the Lineage pair and BEFORE `pixel`** in `AdapterRegistry`
9797
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`.
98+
matched-but-diagnostics-only stock Pixel. Gate: `DeviceInfo.isGrapheneOs` (core `app.grapheneos.*` FLAG_SYSTEM
99+
packages via PackageManager `<queries>` — NO property/feature/fingerprint marker exists; verified on a real
100+
device) + system user. **Key presence is not — and cannot be — a gate condition**: the key is `@Protected`
101+
(below), so the unprivileged probe reads absent regardless; the feature is assumed present on any GrapheneOS
102+
build (their platform ships it for every Google device). No lab adapter; `contributionWanted = false`.
103103

104104
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).
105+
`FixedLimit(80)`, `0` **or absent**`Unrestricted` (upstream reads it via `BoolSetting(..., default false)`
106+
absent IS the factory off state); other → `Unknown(unrecognizedValue=true)`. **Reads and writes are Shizuku-only**:
107+
GrapheneOS declares the key `@Protected(read = SYSTEM_UI, readWrite = SETTINGS)` (frameworks_base `c30c6393`) and
108+
throws SecurityException for every other package *including WSS holders* (`e87c93a2`); the shell UID is the one
109+
usable exemption, which is exactly the Shizuku user service's `settings get/put` path. `preferShizukuForWrites`;
110+
direct reads come back unreadable and `readSyncDirectFirst` falls through to Shizuku. SYNC_READBACK with read-back
111+
equality; session override = Unrestricted; protective default = FixedLimit(80); `reapply == apply` (no
112+
observer-poke — see below); reconnect gesture **unsupported** (structurally: the gesture's override write lands
113+
strictly after the replug broadcast, which the ROM has already sampled past).
110114

111115
**The defining quirk: `policyLatchesAtPlug = true`.** GrapheneOS samples the key only at plug-session start; an
112116
external write updates the Settings UI live but has no hardware effect until the next unplug→replug (the native

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,12 @@ class ChargingRepository @Inject constructor(
270270
}
271271

272272
/**
273-
* Sync-readback adapters use world-readable / unprivileged keys, so the DIRECT provider read is
273+
* Most sync-readback adapters use world-readable keys, so the DIRECT provider read is
274274
* authoritative and — unlike a Shizuku user-service bind, which can block up to ~15s on a cold process
275275
* ([ShizukuController.service]) — never stalls. Read direct first so that bind is never on the critical
276-
* path (the widget/tile refresh after a tap); consult Shizuku only as a fallback when the direct read is
277-
* not authoritative on some ROM. A nominally "ready" but misbehaving Shizuku service therefore can no
276+
* path (the widget/tile refresh after a tap); consult Shizuku as the fallback when the direct read is
277+
* not authoritative — including GrapheneOS's @Protected key, where the direct read is always denied
278+
* and Shizuku (shell UID) is the only readable path. A nominally "ready" but misbehaving Shizuku service therefore can no
278279
* longer delay verification.
279280
*/
280281
private suspend fun readSyncWithFallback(adapter: ChargingAdapter): ChargeObservation? {
@@ -603,11 +604,12 @@ class ChargingRepository @Inject constructor(
603604

604605
/**
605606
* Read a sync-readback adapter's configured state, preferring the [direct] provider and consulting
606-
* [shizuku] (may be null when not ready) only as a fallback. Direct reads of these adapters' world-readable
607-
* keys are authoritative and cannot stall, so an *authoritative* direct read — [ChargeObservation.Verified]
607+
* [shizuku] (may be null when not ready) only as a fallback. A direct read of a world-readable key is
608+
* authoritative and cannot stall, so an *authoritative* direct read — [ChargeObservation.Verified]
608609
* or a readable-but-unrecognized OEM value — short-circuits without ever binding the Shizuku user service
609-
* (both backends read the same settings provider, so Shizuku could not report anything stronger). Only a
610-
* genuinely unreadable direct result falls back to Shizuku.
610+
* (both backends read the same settings provider, so Shizuku could not report anything stronger). A
611+
* genuinely unreadable direct result falls back to Shizuku — the routine case for GrapheneOS's @Protected
612+
* key, which the provider denies to Amply but not to the shell UID.
611613
*/
612614
internal suspend fun readSyncDirectFirst(
613615
adapter: ChargingAdapter,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,11 @@ data class DeviceInfo(
8888
Settings.Global.getString(it.contentResolver, KEY_PROTECT_BATTERY) != null
8989
}.getOrDefault(false)
9090
} ?: false,
91-
// GrapheneOS's charge-limit key, world-readable in `global`. Presence is the capability
92-
// gate (the OS only ships the toggle where its implementation works); fail closed.
91+
// GrapheneOS's charge-limit key. NOT a gate: GrapheneOS marks the key @Protected, so
92+
// this unprivileged read throws SecurityException (→ false) on real GrapheneOS whether
93+
// the key exists or not — verified via the issue-#49 beta report. Kept as a diagnostic
94+
// signal only: true would indicate a ROM exposing a same-named key WITHOUT the
95+
// GrapheneOS restriction, which is worth seeing in a device-support report.
9396
hasBatteryChargeLimit = context?.let {
9497
runCatching {
9598
Settings.Global.getString(it.contentResolver, KEY_BATTERY_CHARGE_LIMIT) != null

0 commit comments

Comments
 (0)