Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Amply is an **experimental Android controller for OEM battery charge-protection
temporarily allows a full charge, then restores the user's protective policy at 100%, on unplug, or at a safety
timeout.

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

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

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

## OEM Adapters

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

## Temporary Session & Recovery

- Before removing the limit, Amply persists the exact verified/requested protective policy (or the stored baseline).
- A `specialUse` foreground service monitors the sticky battery broadcast (~30 s) and restores on: full charge,
disconnect-after-connection, a 15-minute arming timeout, or a 24-hour safety timeout.
- **Replug grace window** (plug-latched adapters only): a disconnect does not restore immediately — the engine emits
`MARK_DISCONNECTED` and opens a persisted 30s window (`REPLUG_GRACE_MILLIS`, wall clock, survives process death);
a replug inside it emits `MARK_REPLUGGED` and continues the session (the plug transition is what latches the
override), expiry or a backwards clock restores as before. `full` and the 24h safety timeout keep priority. On
every other adapter `replugGraceMillis` is 0 and the decision table is unchanged.
- While active, the service watches the adapter's settings URIs; an unexpected native/system change **cancels without
restoring**, so Amply never overwrites a newer external choice.
- Boot recovery runs the restore *inside the service* with a bounded convergence check (re-write until the HAL
Expand Down
32 changes: 29 additions & 3 deletions .claude/rules/privileged-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ for get / put / WSS grant / diagnostic snapshots. Hard rules:
the allowlist without an explicit, reviewed reason.
- Every writable key carries an explicit **per-key value domain** (`SettingWritePolicy`) — the boundary itself
rejects out-of-domain values. The Samsung keys (`global protect_battery`, `global battery_protection_threshold`),
the Xiaomi key (`secure security_pc_secure_protect_mode_key`), and the Oplus keys (`system
regular_charge_protection_switch_state`, `system smart_charge_protection_switch_state`) are all **live** on gated
devices (see Capability Gates).
the Xiaomi key (`secure security_pc_secure_protect_mode_key`), the Oplus keys (`system
regular_charge_protection_switch_state`, `system smart_charge_protection_switch_state`), and the GrapheneOS key
(`global battery_charge_limit`) are all **live** on gated devices (see Capability Gates).

## Capability Gates

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

### GrapheneOS

GrapheneOS control requires **all** of: GrapheneOS identity (`DeviceInfo.isGrapheneOs` — resolved from the OS's
core `app.grapheneos.*` packages via PackageManager `<queries>` entries; **no** graphene property, system feature,
or fingerprint marker exists, verified on a real device), a present world-readable `global battery_charge_limit`
key (the capability signal — GrapheneOS ships the toggle exactly where its implementation works), and the system
user. Identity is deliberately **not** OR-ed with key presence: the adapter is registered ahead of the Pixel
adapter, and a future stock Pixel shipping a same-named key must not be swallowed as GrapheneOS.

The key is binary (`1` = fixed 80% cap with bypass charging, `0` = off) and WSS-writable — **no Shizuku needed**.
The defining quirk is **`policyLatchesAtPlug`**: the ROM samples the key only at plug-session start, so an external
write reads back correctly but has no hardware effect until the next unplug→replug (the native Settings toggle
applies live because Settings pokes the charging service directly). Three mechanisms handle this — the
pending-until-replug verification state (condition-based, no settling clock), the session engine's 30s replug grace
window (a disconnect during a session opens a window instead of restoring, so the user's replug latches the
override rather than a premature restore), and `reapply == apply` (no observer to re-trigger). While enforcing, the
device reports the stock-Pixel hardware signal (`EXTRA_CHARGING_STATUS` = 4), which the adapter decodes for real
enforcement evidence. The reconnect gesture is unsupported — its override write lands strictly after the replug
broadcast, which the ROM has already sampled past.

Qualification is **remote** (issue #49, Pixel 9 Pro XL `komodo`, GrapheneOS 2026080501 / Android 17): the tester
physically observed enforcement (held at 80% with the shield and state 4) and the latch behavior. Package
visibility of `app.grapheneos.*` from app context and the factory-absent key state are still unverified on-device;
both fail closed (Pixel-adapter diagnostics, or diagnostics + contribution wizard). Record any new evidence in the
qualification ledger (`device-qualification` skill).

### LineageOS

LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), a **physically-qualified
Expand Down
24 changes: 24 additions & 0 deletions .claude/skills/device-qualification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
| 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 |
| 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 |
| 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 |
| 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 |

## Known gaps

Expand Down Expand Up @@ -114,6 +115,29 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
(`google/oriole/oriole:16/…/release-keys`), so fingerprint sniffing is not a fallback. Otherwise clean on this
ROM: install/launch/onboarding/dashboard/settings with no crashes, honest "Unsupported device" reporting, live
battery monitoring across simulated plug/level transitions, and the charge alarm firing at threshold.
- **GrapheneOS** — landed **live** on remote qualification (issue #49; the only OEM row not tested on maintainer
hardware). Open items, all failing closed:
- **`app.grapheneos.*` package visibility from app context is unverified** — `<queries>` package entries are
specified platform behavior (not SELinux-fragile like `ro.lineage.*`), but GrapheneOS hardens aggressively. If
the packages are hidden, `isGrapheneOs` is false and the device falls to the Pixel adapter as
matched/diagnostics-only — no unsafe write path, but support silently vanishes; the first tester report of
"still unsupported" on the test build should check `is_grapheneos=` in the device report.
- **App-context WSS write unverified** — the tester's writes ran as shell UID; an Amply-originated
`Settings.Global.putString` under granted WSS is expected to behave identically (same namespace rules) but has
not been observed. The read-back-equality check catches a silently-failing write.
- **Factory-absent key state unknown** — the tester's device had the key present while off; whether a
never-toggled install exposes it is unverified. Absent → gate fails closed → diagnostics + contribution wizard
(`adapter_detail_grapheneos_no_key`), and `read()` refuses (`unrecognizedValue`) so a session never clobbers it.
- **State 4 below the limit unverified** — evidence was sampled at the 80% hold; if the ROM reports 4 only while
holding, a FixedLimit pending clears late (at the hold) instead of instantly. Cosmetic.
- **A plugged restore configures but cannot enforce** — restore-at-100%, the 24h safety timeout,
manual restore, and a plugged boot recovery all write the protective value while a plug session
is running; the ROM won't enforce it until the next replug, and no code path can change that
(mid-session writes are ignored by design). Amply's state is correct — config protective,
session/recovery closed, pending-until-replug hint shown — and the exposure is one charge cycle,
bounded by the plug session the user is already in. Deliberately NOT treated as a defect.
- **Wireless charging and secondary users**: NOT RUN (gated to system user).
- Sessions/boot-recovery/R8 smoke on real GrapheneOS hardware: pending the test build posted to issue #49.
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
the 80% hold is physically observed.
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified, stays diagnostics-only).** A
Expand Down
28 changes: 28 additions & 0 deletions .claude/skills/oem-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,34 @@ states v1 can restore exactly (a supported fixed limit, or Unrestricted); AUTO/C
an absent/malformed `enabled` decode to `Unknown(unrecognizedValue=true)` so a temporary session refuses rather than
clobbering the user's native choice. Verified devices + coverage: see the qualification ledger (`device-qualification` skill).

## GrapheneOS Adapter

One live adapter (`grapheneos-chargelimit-v1`) for GrapheneOS's own "Limit to 80%" (Settings → Battery → Charging
optimization). **ROM-identity adapter, ordered after the Lineage pair and BEFORE `pixel`** in `AdapterRegistry` —
GrapheneOS ships only on Pixels, and the Pixel probe (any Google/Pixel*) would otherwise swallow the device as a
matched-but-diagnostics-only stock Pixel. Gate: `DeviceInfo.isGrapheneOs` (core `app.grapheneos.*` packages via
PackageManager `<queries>` — NO property/feature/fingerprint marker exists; verified empty on a real device) +
`hasBatteryChargeLimit` (world-readable key presence — the capability signal, deliberately NOT part of identity) +
system user. No lab adapter: a GrapheneOS build without the key stays on this adapter as diagnostics-only with
`contributionWanted`.

Single key `global battery_charge_limit`: `1` = fixed 80% cap (bypass charging; hard-wired, no threshold key) →
`FixedLimit(80)`, `0` = off → `Unrestricted`; absent/other → `Unknown(unrecognizedValue=true)` (factory-absent
semantics unverified — refuse, don't guess). WSS-writable, **no Shizuku**. SYNC_READBACK with read-back equality;
session override = Unrestricted; protective default = FixedLimit(80); `reapply == apply` (no observer-poke — see
below); reconnect gesture **unsupported** (structurally: the gesture's override write lands strictly after the
replug broadcast, which the ROM has already sampled past).

**The defining quirk: `policyLatchesAtPlug = true`.** GrapheneOS samples the key only at plug-session start; an
external write updates the Settings UI live but has no hardware effect until the next unplug→replug (the native
toggle applies live because Settings pokes the charging service directly — so the session watcher's
cancel-without-restore on an observed external change stays correct). This drives the pending-until-replug
verification state and the session engine's 30s replug grace window (see `rules/architecture.md`). While enforcing,
the device reports stock-Pixel hardware state 4 (`EXTRA_CHARGING_STATUS`), which the adapter's own `decodeHardware`
maps to `Verified(FixedLimit(80), BATTERY_HARDWARE)` — deliberately not shared with the Pixel decode, which also
maps state 5 to an Adaptive profile this adapter cannot restore. Remote qualification (issue #49): see the
`device-qualification` skill.

## Pixel Adapter

Writes **only** two secure settings:
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<queries>
<package android:name="moe.shizuku.privileged.api" />
<package android:name="com.google.android.settings.intelligence" />
<!-- GrapheneOS identity: no property/feature/fingerprint marker exists, so detection resolves
these core OS packages (DeviceInfo.GRAPHENEOS_PACKAGES). -->
<package android:name="app.grapheneos.setupwizard" />
<package android:name="app.grapheneos.info" />
<!-- LineageOS private settings provider — needed so resolveContentProvider/query aren't
hidden by package visibility on API 30+ (the Lineage charge-control adapter reads it). -->
<provider android:authorities="lineagesettings" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const val SETTLING_WINDOW_MILLIS = 15_000L
*/
fun ChargingState.isSettling(now: Long): Boolean {
val p = pending ?: return false
// A latched request has no clock to run down — surfaces show the replug hint, not a spinner.
if (p.awaitingReplug) return false
val age = now - p.requestedAt
if (age !in 0 until SETTLING_WINDOW_MILLIS) return false // expired, or clock moved backwards
val obs = observation
Expand All @@ -24,3 +26,9 @@ fun ChargingState.isSettling(now: Long): Boolean {

/** The policy a settling request is converging on, or null when nothing is pending. Surfaces choose their own copy. */
fun ChargingState.settlingTarget(): ChargePolicy? = pending?.target

/**
* True while a written policy is waiting for the user to unplug and replug before the charging
* hardware can pick it up (plug-latched adapters). Mutually exclusive with [isSettling].
*/
fun ChargingState.isAwaitingReplug(): Boolean = pending?.awaitingReplug == true
Loading