| 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. |
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.
Adding or widening a live adapter requires physical qualification, not just a settings mapping:
- 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). - 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. - 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.
- Validate sessions — full charge, early disconnect, manual restore, arm + safety timeouts, process death,
force-stop, notification denial, real reboot + deferred
BOOT_COMPLETEDredelivery. 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 reportsNOT_CHARGING, which ends a session immediately — drive the lifecycle withadb shell dumpsys battery set status 2 / set level N(thendumpsys battery reset) so the session behaves as it would mid-charge. - Minified build — repeat the smoke path on an R8
fossbeta (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 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 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 |
- LineageOS — landed diagnostics-only:
QUALIFIED_CODENAMESships empty, so the live adapter never matches and every LineageOS build falls toLineageLabAdapter. A codename is added only after that device passes qualification (real charging cessation at the limit, wired + wireless, below/at/above threshold) and gets a "Verified devices" row.- 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 viawriteLineageSettingand read backVerified(FixedLimit(80), SHIZUKU). LineageOS'sChargingControlControllerobserves the external write and updates its live config (dumpsys lineagehealth→mConfigEnabled:true, mConfigMode:3, mConfigLimit:80); thevendor.lineage.health.IChargingControl/defaultHAL is registered and its service runs. The refuse-don't-clobber decode was also confirmed live: a nativemode=2(CUSTOM) state read asUnknown(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 — withlimit=80set, the battery charged 92→95 %+ at ~1.2 A (charge_stage=Inactive,charge_limitsysfs empty,mChargingStopReason:0). On Pixel the charge hardware is driven by Google's adaptive/charge_deadlinemechanism, and this Lineage build's HAL does not map the %-cap to a real cutoff — themIsLimitSet:falsedevice-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 registeredobservedSettingUris, 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.IChargingControlHAL reportsgetSupportedMode()without the LIMIT bit, soChargingControlController.isChargingModeSupported(LIMIT)is false and LineageOS coercescharging_control_mode=3→1(AUTO) — verified live: every raw shell-UIDcontent insertofmode=3(even whileenabled=0) read back as1. Enabling then logsLineageHealth: No alarm found, auto charging control has no effectandSetting charge deadline: … 73353; the active provider isccprovider.Deadline(Google Adaptive Charging — time/alarm-based, no fixed-percent cap).charging_policy/charge_stagesysfs 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_READBACKapply(FixedLimit)writesmode=3, reads backmode=1 ≠ 3→ decodesUnknown(unrecognizedValue=true)→applyreturns 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 labelledu:object_r:custom_version_prop:s0, which SELinux denies tountrusted_app(avc: denied { read } … tcontext=custom_version_prop … app=eu.darken.amply).SystemProperties.getreturns""on denial instead of throwing, soSystemPropertyReader'srunCatchingnever fired, nothing was logged, andLineageOsDetector.detect()silently returned null —getpropover adb had always worked because it runs asshell. Both Lineage adapters skipped and selection fell through togoogle-pixel-lab-v1, which (a) madeQUALIFIED_CODENAMESdead — a qualified codename could never activate, (b) hid the "Help add support" wizard (contributionWanteddefaults 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 triesPOWER_USAGE_SUMMARY(which does resolve on LOS). Fixed by gating on the app-readableorg.lineageos.androidsystem 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 adumpsys lineagehealthprobe 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 forMODE_AUTO/MODE_MANUAL, so this device'sProvider: DeadlineatMode: 1merely means nothing was learned), and there is no negative case at all —Togglealso acceptsMODE_LIMITand enforces the cap itself, so binding it is a capable mechanism rather than a rejection. EvenNATIVE_LIMITproves nothing: oriole boundLimiton LOS 20 and still charged past the cap. Only physical observation of the charge current qualifies a device. Oriole's NO-GO rests on themode=3write reading back as1plus the LOS 20 charge-past observation — both separate evidence from this probe. Note LineageOS spoofsBuild.FINGERPRINTto stock (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.
- 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
- 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 likero.lineage.*), but GrapheneOS hardens aggressively. If the packages are hidden,isGrapheneOsis 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 checkis_grapheneos=in the device report.- App-context WSS write unverified — the tester's writes ran as shell UID; an Amply-originated
Settings.Global.putStringunder 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), andread()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
Redmi Note 14
24117RN76G(tanzanite, Android 16 / SDK 36,ro.mi.os.version.code=3, ROM3.0.302.0.WOGMIXM.C08) reported via the contribution wizard that the same keysecure/security_pc_secure_protect_mode_keynow carries three modes:0= Charge fully and1= Intelligent charging (labels matching HyperOS 2), plus a new2= Battery protection — apparently a hard-cap mode, which HyperOS 2 lacks entirely. The device correctly fell through toXiaomiLabAdapter, and the existing decode treats2asUnknown(unrecognizedValue=true)(refuse-don't-clobber — the test that pins this was written as a garbage-value guard;2is now known to be a real, named OEM mode). This is a settings mapping only: no behavioral evidence (the optional effect prompts were skipped), the cap percentage is unknown (modeling mode2needs a concreteFixedLimitpercent), factory/absent-key semantics on HyperOS 3 are unknown, and whether value2is HyperOS-3-wide or model-specific is unconfirmed. A full qualification would also need the boundary write domain widened from{"0","1"}(ChargingControlUserService). The contributor has a working Shizuku setup (clean three-namespace, three-mode capture) — a strong candidate for a follow-up qualification run; the report thread is in support mail ("Amply device-support discovery", 2026-08-07).
- HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified, stays diagnostics-only). A
Redmi Note 14
- Pixel — wireless at-threshold hold/charge-past and the widget under Shizuku-only remain unexercised (both share the verified wired mechanism).