- name
- device-qualification
- description
- Amply's physical qualification protocol for OEM charge-control adapters, the ledger of verified devices (Pixel/Samsung/Xiaomi/OnePlus), and known gaps per OEM. Use when qualifying a new device or ROM, widening a capability gate, adding a codename to an allowlist, or recording device test results.
# Device Qualification & Ledger
Companion to `.claude/rules/privileged-access.md`, which holds the always-loaded safety boundary and the capability
gates themselves. **Never widen a gate without adding a row to "Verified devices" below.**
## Qualifying a new device / OEM
Adding or widening a live adapter requires physical qualification, not just a settings mapping:
1. **Characterize** — record model/codename, build fingerprint, OS + OEM-component versions, and the native
charge-protection options. Diff `settings list {secure,system,global}` before/after each native UI state to isolate
the exact key(s) and value domain. Note whether a key is **absent** in factory state (absent ≠ off).
2. **Validate hardware** — drive the raw key transitions unplugged, and charging below/near/above the limit, wired
**and** wireless. The gate passes only if writes move the **real charging hardware** (battery status / sysfs
`charging_policy` / current), not just the Settings UI.
3. **Validate access tiers** — WSS-only (writes apply, hidden reads stay truthfully "requested", grant survives
reboot), Shizuku-only (authoritative readback, denial + binder-death + restart), and both (WSS write → Shizuku
readback). An external native change must never be claimed as verified.
4. **Validate sessions** — full charge, early disconnect, manual restore, arm + safety timeouts, process death,
force-stop, notification denial, real reboot + deferred `BOOT_COMPLETED` redelivery. Confirm exact restore of the
prior value **including a previously-absent key**, and that an unknown/malformed value **refuses** the session
without mutating the setting. Also confirm the interrupted-session surface both ways: a **force-stop that leaves a
restore owed** must, on next open, restore the limit *and* show the dashboard interruption card, while a **reboot**
must restore via boot recovery and show **no** card (the boot-count guard). A device that holds at its limit often
reports `NOT_CHARGING`, which ends a session immediately — drive the lifecycle with `adb shell dumpsys battery set
status 2 / set level N` (then `dumpsys battery reset`) so the session behaves as it would mid-charge.
5. **Minified build** — repeat the smoke path on an R8 `foss` beta (past runs caught R8-only startup/reflection
breakage that debug builds hid).
Record every scenario as **PASS / FAIL / NOT RUN / BLOCKED** — an untestable hardware condition is not a pass. Never
commit device serials or user data. Requalify after a relevant OS / OEM-component update.
**Go / no-go**: *Go* only if writes reliably control the hardware and restore safely (enable just the tested model/OS
row). *Shizuku-only* if control needs an extra safe op invokable through the typed service. *No-go* → keep the device
diagnostics-only.
## The in-app guided run is not a ledger row
`charging/core/qualification/` lets a user run the cut → resume → cut challenge on their own device. A pass
records `EnforcementStatus.SELF_QUALIFIED`, which enables control **for that user, on that exact build**, and
produces a report (`qualification_schema=1`) carrying `Build.DEVICE` — the codename an allowlist entry needs and
the field the contribution wizard omits.
**A self-qualified pass never becomes a "Verified devices" row on its own, and never writes into a
`QUALIFIED_CODENAMES` allowlist.** It is one run, on one unit, usually unattended, with no wireless leg, no
access-tier matrix, no session/boot-recovery coverage and no R8 build — i.e. step 2 of the protocol partially, and
none of steps 3-5. What it *is* good for is the thing that used to take days of email: it settles the
does-the-hardware-obey question with a machine-collected, timestamped result instead of contributor prose, so a
maintainer row becomes a matter of covering the remaining steps rather than starting from nothing.
Treat an incoming qualification report the way the `tanzanite` enforcement evidence was treated: strong evidence
for one question, explicitly scoped, recorded with its gaps named.
## Verified devices (physically tested)
The gate's supported *scope* (see Capability Gates) is broader than what has been physically tested below. Widen a gate
only after adding a row here. Detailed run narratives live in each adapter's landing commit.
| OEM | Tested device / build | Hardware evidence | Coverage | Landed |
|---|---|---|---|---|
| Pixel | Pixel 8 `shiba` A17/API37; Pixel 9 Pro `caiman` A16/API36; Pixel 7a `lynx` A16/API36 | Full — sysfs `charging_policy` follows writes (~11–12s) | Access tiers, sessions, boot recovery, wireless hold, at-threshold, reconnect gesture, natural 100%, interrupted-session detection (7a) | 2026-07-15/-19/-20/-25 |
| 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` (`aristotle`) HyperOS 2.0 (`ro.mi.os.version.code=2`); 2026-08-16 re-run on `OS2.0.216.0.VMFEUXM` / Android 15 | **Partial, now characterized** — external shell-UID writes provably drive the daemon identically to native UI taps (`getProtectMode` → `checkUiModeProtect` → `setEnable`, ~80 ms, both directions, no hidden UI-only flag). The adaptive 80% hold is still **unobserved**: charged 59→100% with no plateau, `getNightChargingState` = 0 on all 140 evaluations. Cause identified — the gate is a *learned* charging-routine model (`key_ave_night_charge_start_minutes`, `…_sd`, `key_enter_night_charge_times`), not a clock window (forced clock to 02:30 did not open it). **BLOCKED on an unused test device, not FAILED.** No hardware hold signal (`Charging state`/`policy` = 0/0) | Read matrix, both-direction writes, session at 100%, unknown-value refusal, R8 beta (2026-07-21). Added 2026-08-16: external-vs-UI write-path equivalence, native UI reflects external key, learned-schedule root cause, clock-forcing negative | 2026-07-21 / 2026-08-16 |
| 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, and on this device/build the replug applied the written value in both directions. **The replug half does not generalize**: refuted on `frankel` (Pixel 10, 2026081301) — see the known-gaps entry | 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 |
| Xiaomi (HyperOS 3) | Redmi Note 14 `24117RN76G` (`tanzanite`), HyperOS 3.0.302 / Android 16 (`ro.mi.os.version.code=3`) — **REMOTE qualification via issue #48** (contributor-run protocol, not maintainer hardware) | **Both-direction enforcement of EXTERNAL shell-UID writes observed** (the same write path as Amply's Shizuku service): `settings put … 2` below the cap → Battery protection active, Settings UI follows immediately, held at 80% for ~20 min under active use (voltage 4228 mV holding vs 4391 mV charging, charge counter 4283 vs 4341 corroborate; sysfs `current_now` permission-denied, so no current reading); `settings put … 0` mid-hold → charging resumes past 80 immediately. **No hardware hold signal**: `dumpsys battery` reports `status: 2` / `Charging state: 0` / `Charging policy: 0` in both states → read-back-only verification | Key mapping (three modes incl. `2` = Battery protection @80, cap fixed — no percent picker), external write → UI both directions, sustained hold, mid-hold release. Beta run 2026-08-14 added: app-context three-mode control (direct WSS), factory-absent key = Intelligent (confirmed). Session restore FAILED in that run (observer noise cancel — app bug, fixed in #65). Re-verified 2026-08-16 on v0.3.4-beta0: **session restore PASSES** — full-charge session ran to 100% and Battery protection was re-written automatically while still plugged. **NOT run**: Shizuku tier, boot recovery, wireless, R8, unplug-early restore | 2026-08-16 |
| 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 |
| GrapheneOS (2nd device) | Pixel 10 `frankel`, GrapheneOS 2026081301 / Android 17, **Amply 0.5.0-beta0 (foss/beta, R8-minified) on-device report via issue #49** | **No new hardware observation** — the reporter states the app works with Shizuku granted, but reported neither an observed 80% hold nor a session run, so this row adds no enforcement evidence. The `komodo` enforcement observation above remains the only one | **Amply's own Shizuku path verified on hardware for the first time**: with Shizuku set up the app reads the key and offers the control, where the 0.3.2-beta0 run without Shizuku showed "setting not present". Second device, and the first on a Pixel generation other than the 9 Pro XL all prior work used. R8 smoke clean (the `beta` build type minifies and shrinks resources), and `ReconnectSupport.NONE` renders correctly as an unavailable gesture rather than a dead control. **NOT run**: observed 80% hold through the app, full-charge session + restore, boot recovery, wireless, secondary user | 2026-08-23 |
## Known gaps
- **LineageOS** — the live adapter now **matches every LineageOS build that ships the `lineagesettings` provider**;
`QUALIFIED_CODENAMES` still ships **empty** but is only a maintainer fast path, no longer what makes the adapter
reachable. Control is gated on **per-build enforcement evidence** instead: a device is a candidate with controls
off until the user explicitly enables control on an unconfirmed build, and loses control permanently for that
build if the battery is observed charging past the cap. A codename still goes into `QUALIFIED_CODENAMES` only
after full qualification (real charging cessation at the limit, wired + wireless, below/at/above threshold) plus
a "Verified devices" row — that list now buys skipping the opt-in, not access itself.
- **Observation can only refute, never confirm** (established 2026-08-17, see the oriole re-observation below):
no passively observable signal distinguishes a cap hold from a thermal or weak-supply pause. Both present as
plugged + `BATTERY_STATUS_NOT_CHARGING` + a static level. `EXTRA_CHARGING_STATUS` does **not** break the tie —
it is session-scoped, measured still reading `4` while the device charged ten points *below* its cap. A guided
two-cap cut/resume/cut challenge (raise the cap, verify charging resumes and re-stops at the new threshold) is
the known way to earn real confirmation and is deliberately not implemented.
- **Pixel 6 (oriole) on LineageOS 23.2 / Android build `BP4A.251205.006` — re-observed 2026-08-17. The
2026-07-22 LOS 23.2 NO-GO below does NOT hold on this build.** `dumpsys lineagehealth` binds
`ccprovider.Limit` (so the HAL *does* advertise the LIMIT mode bit again), `charging_control_mode` reads back
as `3` and is **not** coerced to `1`, and with `enabled=1 / mode=3 / limit=70` the device sat at exactly 70 %
on AC for ~4 h: `status: 4` (NOT_CHARGING), `Charging state: 4`, `Charging policy: 1`, voltage 4072 mV.
**Causal check**: raising `charging_control_charging_limit` to `80` resumed charging within 20 s —
`status: 2`, voltage 4072 → 4183 mV, temperature 294 → 311, charge counter 2908000 → 2910000 — i.e. the
setting demonstrably drives the charging hardware, which is stronger evidence than an observed plateau.
The limit was restored to `70` and re-verified by read-back; `enabled`/`mode` were never written.
**This is NOT a GO and oriole is NOT in `QUALIFIED_CODENAMES`**: only part of step 2 was run — wired only, no
wireless, no below/at/above sweep, no hold observed at the raised cap, and none of steps 3-5 (access tiers,
sessions, boot recovery, R8). It supersedes the "HAL dropped LIMIT" claim for *this build only*.
**Unexplained later observation, recorded so this row does not overclaim**: a few hours after the run above,
the device was found at **78 %** with `charging_control_charging_limit` still reading `70` — i.e. a level
above the cap. It is **not attributable** and must not be read either as enforcement failing or as anything
else: by then the phone had left this run's control entirely — physically unplugged and moved, and its
system clock force-set to `Tue Jul 21 02:01 CEST` (a month in the past, at 02:00) by another workflow, which
is a scheduled/night-charge experiment signature. Any of that could produce the rise. The controlled
observations above stand as recorded; this one is logged only so a later reader does not find it and
conclude the row was written selectively. Re-check under controlled conditions before treating either as
settled.
**Why it matters beyond oriole**: same device, same Lineage major version, different build, opposite HAL
capability. That is the concrete case for HAL capability being **build-scoped, not codename-scoped**, and it
is why enforcement evidence is keyed on a composite build identity (fingerprint + incremental + build time +
provider package version) rather than a codename — LineageOS spoofs `Build.FINGERPRINT` to stock, so the
fingerprint alone cannot carry it.
- **Pixel 6 (oriole) on LineageOS 20.0 / Android 13 — tested 2026-07-22, result NO-GO (no hardware enforcement).**
The *software chain is fully validated* — both raw (shell-UID `content query`/`content insert`) **and through the
app's real Shizuku backend end-to-end** (R8 debug build + Shizuku granted): tapping 80 % wrote the trio via
`writeLineageSetting` and read back `Verified(FixedLimit(80), SHIZUKU)`. LineageOS's `ChargingControlController`
**observes the external write** and updates its live config (`dumpsys lineagehealth` → `mConfigEnabled:true,
mConfigMode:3, mConfigLimit:80`); the `vendor.lineage.health.IChargingControl/default` HAL is registered and its
service runs. The refuse-don't-clobber decode was also confirmed live: a native `mode=2` (CUSTOM) state read as
`Unknown(unrecognized)` ("Policy not verified"), never overwritten. And the diagnostics-only path was smoke-tested
(empty allowlist → `lineageos-lab` → "Detected for diagnostics only", no crash). **But the hard percent
limit did not cut charging** — with `limit=80` set, the battery charged 92→95 %+ at ~1.2 A (`charge_stage=Inactive`,
`charge_limit` sysfs empty, `mChargingStopReason:0`). On Pixel the charge hardware is driven by Google's
adaptive/`charge_deadline` mechanism, and this Lineage build's HAL does not map the %-cap to a real cutoff — the
`mIsLimitSet:false` device-dependent class of gap. So oriole stays **out** of the allowlist. This is a clean
validation of the conservative gate: an "any LineageOS device" gate would have claimed 80 % protection while the
phone charged to 100 %. (Qualifying a device with a *working* charge-control HAL remains open; the adapter's
provider/observer mechanism is proven, only per-device HAL enforcement varies.)
- Also confirmed on oriole: `charging_control_*` keys are **absent in factory state** (validates the conservative
"absent → unrecognized" decode). Still open: the provider's change-notification URI form (per-key vs table)
against the registered `observedSettingUris`, to be checked on a HAL-enforcing device.
- **Pixel 6 (oriole) on LineageOS 23.2 / Android 16 — retested 2026-07-22 after an anti-rollback firmware bump,
result NO-GO (HAL no longer offers LIMIT mode at all).** A *sharper* root cause than the LOS 20 run: the
`vendor.lineage.health.IChargingControl` HAL reports `getSupportedMode()` **without the LIMIT bit**, so
`ChargingControlController.isChargingModeSupported(LIMIT)` is false and LineageOS **coerces `charging_control_mode=3`
→ `1` (AUTO)** — verified live: every raw shell-UID `content insert` of `mode=3` (even while `enabled=0`) read back
as `1`. Enabling then logs `LineageHealth: No alarm found, auto charging control has no effect` and `Setting charge
deadline: … 73353`; the active provider is `ccprovider.Deadline` (Google Adaptive Charging — time/alarm-based, **no
fixed-percent cap**). `charging_policy`/`charge_stage` sysfs never changed. So oriole is NO-GO on **both** builds for
different reasons: LOS 20 *accepted* LIMIT but the HAL silently didn't cut; LOS 23.2's HAL dropped LIMIT and falls
back to the Deadline mechanism. Consequence for the adapter (unchanged — correct by design): SYNC_READBACK
`apply(FixedLimit)` writes `mode=3`, reads back `mode=1 ≠ 3` → decodes `Unknown(unrecognizedValue=true)` → `apply`
returns false, so it **refuses without a false claim of control**. Reinforces the allowlist bar: a device must both
expose the LIMIT mode bit **and** actually cut charging. `charging_control_*` again **absent in factory state**.
- **Pixel 6 (oriole) on LineageOS 23.2-20260720-NIGHTLY / Android 16 — app-level compatibility pass 2026-08-03.**
HAL qualification **not re-run** (same build as the run above, verdict stands). This pass found a defect that made
the whole Lineage path unreachable in production: **Amply never detected LineageOS at all.** All five
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
`untrusted_app` (`avc: denied { read } … tcontext=custom_version_prop … app=eu.darken.amply`).
`SystemProperties.get` returns `""` on denial instead of throwing, so `SystemPropertyReader`'s `runCatching`
never fired, nothing was logged, and `LineageOsDetector.detect()` silently returned null — `getprop` over adb had
always worked because it runs as `shell`. Both Lineage adapters skipped and selection fell through to
`google-pixel-lab-v1`, which (a) made `QUALIFIED_CODENAMES` dead — a qualified codename could never activate,
(b) hid the "Help add support" wizard (`contributionWanted` defaults false on the Pixel adapter, true on the lab
adapter), and (c) pointed "open battery settings" at Battery Saver, since the Pixel intent targets the absent
Google Settings-Intelligence component and never tries `POWER_USAGE_SUMMARY` (which *does* resolve on LOS).
Fixed by gating on the app-readable `org.lineageos.android` system feature (`DeviceInfo.isLineageOs`); the
version property is kept as a **secondary identity signal** (OR-ed in, normally null on real hardware). The
same run added a `dumpsys lineagehealth` probe to the device-support report. **It is an observation, never a
verdict, and never qualifies a device.** Two reasons: selection is mode-dependent (upstream picks Deadline
before Limit for `MODE_AUTO`/`MODE_MANUAL`, so this device's `Provider: Deadline` at `Mode: 1` merely means
nothing was learned), and there is no negative case at all — `Toggle` also accepts `MODE_LIMIT` and enforces
the cap itself, so binding it is a capable mechanism rather than a rejection. Even `NATIVE_LIMIT` proves
nothing: oriole bound `Limit` on LOS 20 and still charged past the cap. Only physical observation of the
charge current qualifies a device. Oriole's NO-GO rests on the `mode=3` write reading back as `1` plus the
Ver en GitHub