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:
- 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 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
lineagesettingsprovider;QUALIFIED_CODENAMESstill 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 intoQUALIFIED_CODENAMESonly 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_STATUSdoes not break the tie — it is session-scoped, measured still reading4while 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 lineagehealthbindsccprovider.Limit(so the HAL does advertise the LIMIT mode bit again),charging_control_modereads back as3and is not coerced to1, and withenabled=1 / mode=3 / limit=70the 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: raisingcharging_control_charging_limitto80resumed 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 to70and re-verified by read-back;enabled/modewere never written. This is NOT a GO and oriole is NOT inQUALIFIED_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 % withcharging_control_charging_limitstill reading70— 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 toTue 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 spoofsBuild.FINGERPRINTto 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 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.
- 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 +
- GrapheneOS — landed live on remote qualification (issue #49; the only OEM row not tested on maintainer
hardware), then re-gated to Shizuku-only after the 0.3.2-beta0 on-device report (see the follow-up ledger
row). Resolved since the landing PR: package visibility ✔ verified from app context; app-context WSS access ✘
resolved as DENIED (
@Protected— the reason for the re-gate, not a bug in Amply); factory-absent key state ✔ resolved from upstream source (BoolSettingdefault false → absent decodes as Unrestricted). Still open, all failing closed or cosmetic:- Shizuku read path verified, session behaviour still not — the 0.5.0-beta0 report on
frankel(see the 2nd-device ledger row) shows Amply's own Shizuku service reading the key and offering the control on real hardware, under R8, so those two are closed. What that report does not cover, because the reporter did not run them: an 80% hold observed through the app rather than over adb, a full-charge session and its restore, and boot recovery. Every enforcement claim on this ROM still rests on thekomodoadb runs, and one of them (the replug latch) is refuted onfrankel— see the next entry. - External writes are not honoured on every build — REFUTED on
frankel(Pixel 10, GrapheneOS 2026081301 / Android 17; issue #49, 2026-08-25). With Amply out of the loop entirely, a baresettings put global battery_charge_limit 0made while unplugged, and confirmed off in the ROM's own Settings UI, was still ignored at the next plug-in: the device held at exactly 80% (level climbed 74→80, then NOT_CHARGING at 80 with power connected, no level above 80 all day, thermals ~33 °C so not a throttle; the app's package appears nowhere in the 52k-line capture). The same device's native Settings toggle works normally, so the divergence is the external-write channel, not the cap. Cause undetermined:komodowas qualified on 2026080501 andfrankelobserved on 2026081301, neither device tested on the other's build, so a ROM release change and a Pixel 10 generation difference fit the evidence equally. No public upstream report matches this direction (the known ones are the inverse — charging past the cap while the limit is on). One caveat on the evidence: the capture carries no settings-provider logging, so "the key read 0 at plug time" rests on the reporter's statement plus the ROM's UI, not on instrumentation.- Blast radius: every write on this adapter shares one path, so on a device behaving like this the
dashboard/widget/tile persistent writes, session restore, and boot recovery are all hardware-level no-ops
that read back as verified. Boot recovery's convergence loop returns DONE on the first tick for the same
reason (a matching settings readback is treated as convergence). Nothing in the app can notice: this adapter
does not set
enforcementEvidenceRequired, and the passive verdict engine only detects a climb past a cap, which is the opposite direction from a ROM enforcing a cap that is configured off.- Do not answer this by setting
enforcementEvidenceRequiredon the adapter. Beyond the direction mismatch above, GrapheneOS deliberately runs a periodic recalibration charge to 100% with the limit still on.EnforcementVerdictEnginerefutes onclimbRose && percent >= cap + OVERSHOOT_ALLOWANCE, and its only suppressors are unplugged samples, a running Amply full-charge session, and a guided run — a ROM charging past a cap it still honours is none of those. A refutation is terminal for the build identity, so the first recalibration would switch the controls off on a device whose cap works, permanently.
- Do not answer this by setting
- The protective direction is UNRESOLVED, and it is the open safety question. Both observed failures wrote the limit off while the ROM kept enforcing, which leaves the battery protected and merely makes the UI dishonest. Whether a write turning the limit on is equally ignored — which would mean Amply claims protection that is not enforced — was asked of the reporter 2026-08-25 and run on 2026-08-27: the battery charged past a cap that had been written externally moments earlier. That settles nothing. The result is equally consistent with the ROM ignoring the external write and with the periodic recalibration charge to 100% described above, which climbs past a cap the ROM is honouring, and no signal in the run separates the two. Treat the question as open. Do not widen, re-qualify, or relax anything on this ROM until it is answered.
- Blast radius: every write on this adapter shares one path, so on a device behaving like this the
dashboard/widget/tile persistent writes, session restore, and boot recovery are all hardware-level no-ops
that read back as verified. Boot recovery's convergence loop returns DONE on the first tick for the same
reason (a matching settings readback is treated as convergence). Nothing in the app can notice: this adapter
does not set
- 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.
- A write made while unplugged had no honest state either — the entry above is about writes made during
a plug session, which do get the pending-until-replug hint (
settledrequires a hardware confirmation whenever the write was not demonstrably made unplugged). A write made unplugged counts as settled, so the replug that follows produced a plain settings read-back and the app called that verified — exactly the claimfrankelrefutes. Now a configured cap that the hardware has not confirmed is shown as set rather than in effect, and the apply message says the value was saved instead of verified. This is a presentation fix only: nothing here can tell the two ROM behaviours apart, so the honest state is "unconfirmed" on both. - Wireless charging and secondary users: NOT RUN (gated to system user).
- Shizuku read path verified, session behaviour still not — the 0.5.0-beta0 report on
- Oplus (OnePlus/Oppo/Realme) — the live gate reads
ro.build.version.oplusrom, which does not exist on pre-rebrand ColorOS, so every ColorOS 11-era build is invisible to it and lands onOnePlusLabAdapter. This is correct behaviour (those builds are unqualified either way), but it made reports from them uninformative.- Oppo F11 Pro
CPH1969(OP4863), Android 11 / ColorOS 11 — device-support report only, 2026-08-15. NOT a qualification run: no physical test, no Shizuku, no settings capture. The report carriedoplus_rom_version=noneand nothing else about the family, because the report probed only the Samsung, GrapheneOS, and LineageOS keys — the two ColorOS keys Amply already knows were never read. Absence of the property could not be distinguished from an SELinux-denied read either (SystemPropertyReaderreturns""on denial). Prompted two report changes: an unprivileged presence probe of the twosystemkeys, and tri-state probe results (present|absent|read_denied) so a refused read stops rendering as a proven negative. Still open: whether pre-rebrand ColorOS carries those keys under any name is unknown, and no legacy property (ro.build.version.opporom) is read, so pre-ColorOS-12 builds remain undetectable as Oplus by ROM version. A device with a working pre-15 ColorOS charge-protection feature would need a new gate signal, not a widened version constant — and the usual bar applies: physically observed charging cessation, not a settings mapping.
- Oppo F11 Pro
- Xiaomi — external-write handling RESOLVED (2026-08-16); the adaptive 80% hold remains unobserved and is
now believed UNQUALIFIABLE on an unused test device. Split the old "adaptive enforcement unconfirmed" gap into
its two halves — one is now closed, the other is characterized rather than merely open.
- Closed: external shell-UID writes are functionally identical to native UI taps (Xiaomi 13T
aristotle, HyperOS 2OS2.0.216.0.VMFEUXM, Android 15, maintainer hardware). Both paths produce the same daemon chain with the same values, within ~80 ms of the write:ChargeProtectionUtils: getProtectMode mode:N→SmartChargeProtectManager: checkUiModeProtect:N→BaseChargeProtect_: MODE_NIGHT,setEnable fromShouldWork:false,to:false,enable:{true|false}. Verified in both directions and both origins (UI tap to Charge fully / Intelligent, externalsettings put … 0/… 1). There is no hidden internal flag that only the Settings UI sets — an explicitly tested hypothesis, refuted. The native UI also reflects an externally-written key on next open. So Amply's write mechanism is complete and correct on HyperOS 2; nothing in the adapter needs changing for the write path. - Still unobserved: the hold itself. With the key at
1(Intelligent, written externally), the device charged 59% → 100% continuously with no plateau at any level — uniform ~4 min per point, voltage and charge counter rising monotonically through 80 (4265→4293 mV, counter 3390000→3493000),status: 2throughout.getNightChargingStatewas evaluated 140 times across the run and returned0every time (60 s cadence while charging). No hardware hold signal exists —Charging state: 0/Charging policy: 0in all states, same as HyperOS 3. - Root cause of the non-engagement: the gate is a LEARNED schedule, not a clock window. Strings in
com.miui.securitycenter(com.miui.powercenter.nightcharge) show the feature keeps a statistical model of habitual overnight charging:key_ave_night_charge_start_minutes,key_night_charge_start_minutes_sd,key_night_charge_end_minutes_sd(standard deviations),key_enter_night_charge_times(occurrence count),key_earliest_night_charge_end_minutes,key_night_charge_record, plus four distinctisNeedNightChargeProtection return false case 1..4rejection paths. Directly corroborated: forcing the device clock to 02:30 (viacmd time_detector set_time_state_for_tests; shell UID lacks bothSET_TIMEandSUGGEST_MANUAL_TIME_AND_ZONE) did not open the window —isNightChargeProtectionOpenstayedfalse. Time of day alone is insufficient; the daemon wants a low-variance charging history the device does not have. - Consequence for qualification: this device cannot settle it. The 13T is a maintainer test device with no normal daily use, so it can never accumulate the routine the model requires. Qualifying Adaptive would need either weeks of genuine (or convincingly simulated) nightly charging at a consistent time, or root to seed SecurityCenter's private prefs. Record the July "could not be triggered" and this run as the same result with a now-known cause, not as two independent failures. Adaptive on Xiaomi is therefore BLOCKED, not FAILED — no evidence exists that it fails to hold, only that its precondition never became true.
- Consequence for the adapter, RESOLVED in the same change (see
XiaomiChargingAdapter.kt,defaultProtectivePolicy = Adaptive): Amply's protective policy on HyperOS 2 is a mode that, by the OEM's own design, only acts inside a learned overnight window, so a device with Adaptive configured charges to 100% outside it. The read was never wrong (the mode genuinely is configured) but "protected" overstated it. The default stays — HyperOS 2 offers no unconditional protective mode — and the honesty moved to presentation:ChargePolicy.enforcementIsConditional+ChargeObservation.provesPolicyInEffect()withhold the confirmed checkmark from a conditional policy that only settings can vouch for. Verified onaristotleitself (2026-08-16, foss debug + direct WSS): Adaptive renders with the neutral shield and "the system chooses when it applies", 100% still renders with the green check and the plain readback line, and both apply without a settling spinner — the last point being the visible symptom if the predicate ever leaks into pending logic. Note HyperOS blocks adb installs behind an on-device "Install via USB" dialog with a 5s auto-deny, so an install must be confirmed on screen while it is awake. - Dead end, do not re-investigate:
global battery_charging_state_enforce_levelandbattery_charging_state_update_delay(both-1on this device) look like an enforcement lever from their names — they are not. Resolved 2026-08-16 by disassembling the device's own/system/framework/services.jar(dexdump): both are read bycom.android.server.power.stats.BatteryStatsImpl$Constants, alongsideKEY_BATTERY_CHARGED_DELAY_MS,KEY_MAX_HISTORY_FILES, andKEY_KERNEL_UID_READERS_THROTTLE_TIME. This is stock AOSP battery-statistics bookkeeping (when batterystats treats the device as charged, for history reset), not Xiaomi charge control, and it cannot cap charging current. Never written to. Recorded here specifically so the plausible-sounding name does not cost anyone a second investigation. - HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified at the time; since landed,
see the LANDED bullet below). 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 was initially unconfirmed (answered 2026-08-13: not HyperOS-3-wide — see the marblein data point below). 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) and GitHub issue #48.- Follow-up (2026-08-13, comment on PR #52, same
tanzanitedevice): cap-percent candidate 80 — still unqualified. The contributor claims mode2hard-caps at 80% and attached adumpsys batterysnapshot at the cap (level: 80,AC powered: true). Not accepted as hold evidence: it is a single snapshot (no sustained-hold observation, no current reading — a battery passing through 80 looks identical), the dump itself reportsstatus: 2(= CHARGING) withCharging state: 0/Charging policy: 0(no hardware hold signal), and how mode2was set (native UI vs external write) is unstated — so daemon enforcement of external writes, the decisive question for Amply and open even on qualified HyperOS 2, remains unproven. Follow-up runs requested in the issue #48 thread: sustained hold at 80 withcurrent_now, and the both-direction external-write test (adbsettings putto2below the cap → hold; back to0mid-hold → charging resumes past 80). Delivered 2026-08-14 — see the enforcement bullet below. - Both-direction external-write enforcement DEMONSTRATED (2026-08-14, issue #48, same
tanzanitedevice). The contributor ran the requested protocol via on-device adb shell — the shell UID, the same write path Amply's Shizuku service uses. (1) Below 80% plugged, UI manually set to "Charge fully",settings put secure security_pc_secure_protect_mode_key 2→ Battery Protection activated and the Settings UI reflected it immediately, so the daemon reacts to external key writes, not just its own UI. (2) Held at 80% for ~20 minutes plugged under active use without gaining a point; sysfscurrent_nowwas permission-denied so there is no current reading, but the dumps corroborate the hold indirectly (voltage 4228 mV at the hold vs 4391 mV after resume; charge counter 4283 vs 4341). (3)settings put … 0mid-hold → charging resumed immediately, UI followed, level passed 80. Caveats: hold evidence is level observation plus voltage/counter deltas, not a current measurement, and both dumps showstatus: 2/Charging state: 0/Charging policy: 0— HyperOS 3 exposes no hardware hold signal, so a future adapter gets readback-only verification (like Samsung, unlike Pixel/GrapheneOS). This answers the decisive daemon-enforcement question fortanzanitemode2; the remaining items (gate design, boundary widening, factory/absent-key semantics, sessions/access-tiers/R8) are tracked in the landing bullet below. - Second HyperOS 3 data point (support mail, 2026-08-13): the hard-cap mode is NOT HyperOS-3-wide. A
Poco F5
23049PCD8I(marblein, Android 15 / SDK 35, HyperOS 3.0.2, ROMOS3.0.2.0.VMRINXM) reported via the contribution wizard only the two HyperOS-2-style modes on the same key (1= Intelligent charging,0= Charge fully) — no value2and no hard-cap option captured. So mode2is model- and/or Android-16/OS-3.0.3-dependent, and a future HyperOS 3 gate cannot infer the hard-cap mode fromro.mi.os.version.code == 3alone. Caveats: the wizard sawchanged_rows=2but the contributor withheld one row in the privacy review (mapping possibly incomplete), and the device was previously
- Follow-up (2026-08-13, comment on PR #52, same
- Closed: external shell-UID writes are functionally identical to native UI taps (Xiaomi 13T
…(truncated)