Skip to content

Commit 3c8bb2d

Browse files
authored
Charging: Add HyperOS 3 Battery Protection support for qualified Xiaomi devices (#64)
* Charging: Record HyperOS 3 both-direction enforcement evidence in the ledger The tanzanite contributor (issue #48) ran the requested protocol via on-device adb shell (shell UID, same path as the Shizuku service): external write of mode 2 below the cap activated Battery Protection with the Settings UI following, the device held 80% for ~20 minutes under active use, and writing 0 mid-hold resumed charging past 80. This answers the daemon-enforcement-of-external-writes question for tanzanite mode 2. No current reading (sysfs denied) and no hardware hold signal in dumpsys, so a future adapter is readback-only. Support still needs gate design (mode 2 is not HyperOS-3-wide), the reviewed write-domain widening, absent-key semantics, and a test-build qualification run. * Charging: Add HyperOS 3 Battery Protection support for qualified Xiaomi devices New XiaomiHyperOs3ChargingAdapter (xiaomi-hyperos3-v1) over the same secure key as HyperOS 2, adding mode 2 = Battery protection as FixedLimit(80). Gate: manufacturer + ro.mi.os.version.code == 3 + a qualified-codename allowlist (ships with tanzanite) + system user. Version-only gating is impossible: mode 2 is model-/build-dependent within HyperOS 3 (marblein on 3.0.2 lacks it) and cannot be probed at runtime (key absent in factory state). SYNC_READBACK only; dumpsys battery exposes no hardware hold signal on HyperOS 3. Both-direction hardware enforcement of external shell-UID writes was demonstrated on tanzanite (issue #48): write 2 below the cap holds at 80%, write 0 mid-hold resumes past 80, Settings UI follows, applied synchronously. Boundary: SettingWritePolicy widens the key's write domain to {0,1,2} globally; accepted on HyperOS 2 because no code path emits 2 there and its decode refuses it (AdapterMutationDomainTest pins the pairing). ContributionAllowlist discloses 2 as a named OEM mode. Absent-key decode (absent = Intelligent) mirrors HyperOS 2 but is unverified on HyperOS 3 — flagged in comments, pending the remote qualification run before this leaves draft. * Charging: Add the tanzanite HyperOS 3 ledger row and landing gaps Records the issue-#48 remote qualification (both-direction external shell-UID write enforcement) as a Verified-devices row and documents the GrapheneOS-precedent landing: app-level items (absent-key semantics, sessions, access tiers, R8) stay open as failing-closed known gaps, to be verified by the contributor on the next beta.
1 parent c334202 commit 3c8bb2d

19 files changed

Lines changed: 445 additions & 51 deletions

.claude/CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ timeout.
1111
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
1212
Android 15+ when Google's charging-optimization controller is present. **Samsung battery protection** (global
1313
`protect_battery` keys) is gated to verified One UI generations — One UI 8 multi-mode, and the legacy One UI 4/5
14-
toggle — on the system user. **Xiaomi charging protection** (secure `security_pc_secure_protect_mode_key`,
15-
binary Adaptive/Unrestricted) is gated to the HyperOS 2.x ROM (`ro.mi.os.version.code == 2`) on Xiaomi devices.
14+
toggle — on the system user. **Xiaomi charging protection** (secure `security_pc_secure_protect_mode_key`)
15+
has two adapters: binary Adaptive/Unrestricted gated to the HyperOS 2.x ROM (`ro.mi.os.version.code == 2`), and a
16+
HyperOS 3 three-mode variant adding a FixedLimit(80) hard cap — gated to HyperOS 3 **plus a qualified-codename
17+
allowlist** (mode `2` is not HyperOS-3-wide and cannot be probed at runtime).
1618
**OnePlus/ColorOS charging protection** (mutually-exclusive `system` keys `regular_/smart_charge_protection_switch_state`
1719
= FixedLimit(80)/Adaptive) is gated to ColorOS 15 (`ro.build.version.oplusrom == 15`) across the Oplus family
1820
(OnePlus/Oppo/Realme) — **writes require Shizuku** (system namespace). **LineageOS charging control** (the private
@@ -26,7 +28,7 @@ system user; **reads are unprivileged (ContentResolver), writes require Shizuku*
2628
packages including WSS holders, with only the shell UID exempt. The ROM **latches the key at plug-session start**
2729
(`policyLatchesAtPlug`), so external writes take effect at the next unplug→replug — handled by a
2830
pending-until-replug verification state and a 30s session grace window; the reconnect gesture is unsupported
29-
there. Other Pixels, Samsung on unverified One UI versions (6/7, 9+), non-HyperOS-2 Xiaomi devices,
31+
there. Other Pixels, Samsung on unverified One UI versions (6/7, 9+), unqualified Xiaomi devices,
3032
non-ColorOS-15 Oplus devices, and unqualified LineageOS builds remain diagnostics-only. See the qualification
3133
ledger (`.claude/skills/device-qualification/`) for the verified devices and mappings.
3234

.claude/rules/privileged-access.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,26 @@ without a qualified device; record results in the qualification ledger (`device-
7979

8080
### Xiaomi
8181

82-
Xiaomi control requires **all** of: Xiaomi manufacturer (covers Redmi/POCO, which report Xiaomi as
83-
manufacturer), `ro.mi.os.version.code == 2` (HyperOS 2.x — the ROM feature is version-scoped, not
84-
model-scoped), and the system user. Use `ro.mi.os.version.code`, NOT the frozen legacy
85-
`ro.miui.ui.version.code`. Do not widen to HyperOS 3+ without qualifying a device; record results in the qualification ledger (`device-qualification` skill). The single key is per-user `secure`, applied synchronously. Two deliberate
86-
assumptions: the feature is treated as present on any HyperOS 2 device (a device lacking it reads the key
87-
absent → a harmless false claim of control), and daemon-level enforcement of external writes is pending
88-
long-term observation (see Known gaps below).
82+
Two live adapters over the same per-user `secure` key (applied synchronously). Use `ro.mi.os.version.code`, NOT
83+
the frozen legacy `ro.miui.ui.version.code`.
84+
85+
**HyperOS 2** (`xiaomi-hyperos2-v1`) requires **all** of: Xiaomi manufacturer (covers Redmi/POCO, which report
86+
Xiaomi as manufacturer), `ro.mi.os.version.code == 2` (the two-mode feature is version-scoped, not model-scoped),
87+
and the system user. Two deliberate assumptions: the feature is treated as present on any HyperOS 2 device (a
88+
device lacking it reads the key absent → a harmless false claim of control), and daemon-level enforcement of
89+
external writes is pending long-term observation (see Known gaps below).
90+
91+
**HyperOS 3** (`xiaomi-hyperos3-v1`, adds mode `2` = Battery protection, hard cap 80%) requires **all** of:
92+
Xiaomi manufacturer, `ro.mi.os.version.code == 3`, a **physically-qualified device codename**
93+
(`XiaomiHyperOs3ChargingAdapter.QUALIFIED_CODENAMES`, ships with `tanzanite`), and the system user. The gate
94+
CANNOT be version-only: mode `2` is model-/build-dependent within HyperOS 3 (a Poco F5 `marblein` on HyperOS
95+
3.0.2 carries only the two old modes), the version property exposes no minor version, and no runtime probe for
96+
mode-2 presence exists — the key is absent in factory state and reading it returns only the current value. Both-
97+
direction hardware enforcement of external shell-UID writes was demonstrated on `tanzanite` (issue #48); no
98+
hardware hold signal exists in `dumpsys battery`, so verification is read-back only. The boundary write domain
99+
for the key is `{0,1,2}` **globally** — accepted on HyperOS 2 because no Amply code path emits `2` there and the
100+
HyperOS 2 decode refuses it. Widen the codename allowlist only with a qualified device plus a ledger row
101+
(`device-qualification` skill); unqualified HyperOS 3 devices fall to the lab adapter.
89102

90103
### GrapheneOS
91104

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
5151
| Xiaomi | Xiaomi 13T `2306EPN60G` HyperOS 2.0 (`ro.mi.os.version.code=2`) | **Partial** — mapping/readback/session verified; the adaptive 80% hold could not be triggered, so daemon-level hardware enforcement is **not yet demonstrated** | Read matrix, both-direction writes, session at 100%, unknown-value refusal, R8 beta | 2026-07-21 |
5252
| OnePlus (Oplus) | OnePlus Nord CE4 Lite `CPH2621` ColorOS 15 (`ro.build.version.oplusrom=V15.0.0`) | Full — enforcement directly observable (device holds at 80%); external writes stick | Two mutually-exclusive `system` keys (Charging limit / Smart charging), WSS-only write rejected + Shizuku write succeeds for all three policies, WSS-only UX (controls disabled + Shizuku-required banner) | 2026-07-21 |
5353
| GrapheneOS | Pixel 9 Pro XL `komodo`, GrapheneOS 2026080501 / Android 17 — **REMOTE qualification via issue #49** (tester-run protocol, not maintainer hardware) | **Enforcement observed**: held at 80% with shield, `dumpsys battery` status=4/Charging state=4/policy=2 (limit on) vs 2/1/1 (off); shell-UID writes move the Settings UI live, **latch at plug-session start** — mid-session writes have no hardware effect until unplug→replug, replug reliably applies the current value | Key isolation (`settings list` diff → single `global battery_charge_limit` 0/1), write→UI both directions, mid-session no-op both directions, replug latch both directions, hardware signal both states. **NOT run**: app-context access tiers (WSS write from Amply, `app.grapheneos.*` package visibility), sessions/boot recovery, wireless, factory-absent key state, secondary user | 2026-08-12 |
54+
| 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), external write → UI both directions, sustained hold, mid-hold release. **NOT run** (GrapheneOS-precedent landing; verify on the next beta via issue #48): app-context access tiers, sessions/boot recovery, factory-absent key state, wireless, R8 | 2026-08-14 |
5455
| 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 |
5556

5657
## Known gaps
@@ -136,7 +137,8 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
136137
- **Wireless charging and secondary users**: NOT RUN (gated to system user).
137138
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
138139
the 80% hold is physically observed.
139-
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified, stays diagnostics-only).** A
140+
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified at the time; since landed,
141+
see the LANDED bullet below).** A
140142
Redmi Note 14 `24117RN76G` (`tanzanite`, Android 16 / SDK 36, `ro.mi.os.version.code=3`, ROM
141143
`3.0.302.0.WOGMIXM.C08`) reported via the contribution wizard that the **same key**
142144
`secure/security_pc_secure_protect_mode_key` now carries **three** modes: `0` = Charge fully and
@@ -161,7 +163,22 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
161163
**external** writes, the decisive question for Amply and open even on qualified HyperOS 2, remains
162164
unproven. Follow-up runs requested in the issue #48 thread: sustained hold at 80 with `current_now`, and
163165
the both-direction external-write test (adb `settings put` to `2` below the cap → hold; back to `0`
164-
mid-hold → charging resumes past 80).
166+
mid-hold → charging resumes past 80). Delivered 2026-08-14 — see the enforcement bullet below.
167+
- **Both-direction external-write enforcement DEMONSTRATED (2026-08-14, issue #48, same `tanzanite`
168+
device).** The contributor ran the requested protocol via on-device adb shell — the **shell UID, the same
169+
write path Amply's Shizuku service uses**. (1) Below 80% plugged, UI manually set to "Charge fully",
170+
`settings put secure security_pc_secure_protect_mode_key 2` → Battery Protection activated and the
171+
Settings UI reflected it immediately, so the daemon reacts to external key writes, not just its own UI.
172+
(2) Held at 80% for ~20 minutes plugged under active use without gaining a point; sysfs
173+
`current_now` was permission-denied so there is no current reading, but the dumps corroborate the hold
174+
indirectly (voltage 4228 mV at the hold vs 4391 mV after resume; charge counter 4283 vs 4341). (3)
175+
`settings put … 0` mid-hold → charging resumed immediately, UI followed, level passed 80. Caveats: hold
176+
evidence is level observation plus voltage/counter deltas, not a current measurement, and both dumps
177+
show `status: 2` / `Charging state: 0` / `Charging policy: 0` — HyperOS 3 exposes **no hardware hold
178+
signal**, so a future adapter gets readback-only verification (like Samsung, unlike Pixel/GrapheneOS).
179+
This answers the decisive daemon-enforcement question for `tanzanite` mode `2`; the remaining items
180+
(gate design, boundary widening, factory/absent-key semantics, sessions/access-tiers/R8) are tracked in
181+
the landing bullet below.
165182
- **Second HyperOS 3 data point (support mail, 2026-08-13): the hard-cap mode is NOT HyperOS-3-wide.** A
166183
Poco F5 `23049PCD8I` (`marblein`, Android 15 / SDK 35, HyperOS 3.0.2, ROM `OS3.0.2.0.VMRINXM`) reported
167184
via the contribution wizard only the two HyperOS-2-style modes on the same key (`1` = Intelligent
@@ -171,5 +188,20 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
171188
one row in the privacy review (mapping possibly incomplete), and the device was previously rooted with
172189
FDE.ai driving the charge limit. Thread: support mail "Amply device-support discovery", 2026-08-13; the
173190
contributor was asked whether the settings screen shows any third mode / 80% option.
191+
- **LANDED 2026-08-14: `xiaomi-hyperos3-v1`, gated to a qualified-codename allowlist (`tanzanite` only) —
192+
GrapheneOS-precedent landing** (remote enforcement qualification via issue #48, see the Verified devices
193+
row; app-level items open, all failing closed, to be verified by the contributor on the next beta):
194+
- **Absent-key decode unverified**: absent decodes as Intelligent/Adaptive, mirroring HyperOS 2's
195+
factory-state assumption; HyperOS 3 factory semantics are unknown. Worst case if wrong: a session
196+
restore writes `1` onto a factory state that was not Intelligent — bounded, no battery hazard.
197+
Next-beta ask: `settings delete secure security_pc_secure_protect_mode_key`, then `settings get` +
198+
observe which mode the native UI shows.
199+
- **Sessions / boot recovery / access tiers (WSS-only vs Shizuku) / R8**: NOT RUN on HyperOS 3;
200+
the mechanism is the shared session engine + the same `secure`-namespace write path qualified on
201+
HyperOS 2, but on-device confirmation is pending the next beta.
202+
- **Adaptive (mode `1`) enforcement undemonstrated** — identical provisional status as HyperOS 2 (the
203+
top-level Xiaomi gap above); only mode `2` has demonstrated hardware enforcement.
204+
- The gate cannot widen past the codename allowlist: record any new HyperOS 3 device here plus a
205+
Verified-devices row before adding its codename to `XiaomiHyperOs3ChargingAdapter.QUALIFIED_CODENAMES`.
174206
- **Pixel** — wireless at-threshold hold/charge-past and the widget under Shizuku-only remain unexercised (both share
175207
the verified wired mechanism).

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,33 @@ diagnostics-only lab adapter. An external `protect_battery=0` makes One UI forge
2929
to the OEM default on re-enable), so Amply restores the exact prior policy itself rather than trusting Samsung's
3030
bookkeeping. Verified devices + coverage: see the qualification ledger (`device-qualification` skill).
3131

32-
## Xiaomi Adapter
33-
34-
One live adapter (`xiaomi-hyperos2-v1`), gated to the HyperOS ROM version (the setting is a ROM
35-
feature, not a per-model one): manufacturer Xiaomi (covers Redmi/POCO — they report Xiaomi as
36-
manufacturer) + `ro.mi.os.version.code == 2` (HyperOS 2.x) + system user. Use `ro.mi.os.version.code`,
37-
NOT the frozen legacy `ro.miui.ui.version.code`. Single key
38-
`secure/security_pc_secure_protect_mode_key`: `0`=charge fully, `1`=Intelligent (heuristic 80% hold →
39-
`ChargePolicy.Adaptive`), absent=Intelligent (factory state). No hard-cap mode exists **on HyperOS 2**; a
40-
HyperOS 3 contribution report (2026-08-07) shows the same key with a third value `2`="Battery protection" —
41-
a candidate hard-cap mode, unqualified (see the `device-qualification` skill). SYNC_READBACK
42-
with read-back equality; session override = Unrestricted; protective default = Adaptive. HyperOS 1,
43-
pre-HyperOS MIUI, and HyperOS 3 fall to `XiaomiLabAdapter` (diagnostics + contribution). Two
44-
documented assumptions: the feature is treated as present on any HyperOS 2 device (a device lacking it
45-
reads the key absent → a harmless false claim of control), and daemon-level enforcement of external
46-
writes is pending long-term observation (see the `device-qualification` skill).
32+
## Xiaomi Adapters
33+
34+
Two live adapters over the single key `secure/security_pc_secure_protect_mode_key` (both in
35+
`XiaomiChargingAdapter.kt`). Use `ro.mi.os.version.code`, NOT the frozen legacy `ro.miui.ui.version.code`.
36+
Manufacturer Xiaomi covers Redmi/POCO — they report Xiaomi as manufacturer.
37+
38+
- **HyperOS 2 (`xiaomi-hyperos2-v1`)** — gated to the HyperOS ROM version (the two-mode setting is a ROM
39+
feature, not a per-model one): manufacturer Xiaomi + `ro.mi.os.version.code == 2` + system user. Values:
40+
`0`=charge fully, `1`=Intelligent (heuristic 80% hold → `ChargePolicy.Adaptive`), absent=Intelligent
41+
(factory state); `2` does not exist on HyperOS 2 and decodes `Unknown(unrecognizedValue)`. Session
42+
override = Unrestricted; protective default = Adaptive. Two documented assumptions: the feature is treated
43+
as present on any HyperOS 2 device (a device lacking it reads the key absent → a harmless false claim of
44+
control), and daemon-level enforcement of external writes is pending long-term observation.
45+
- **HyperOS 3 (`xiaomi-hyperos3-v1`)** — same key plus `2`="Battery protection" (hard cap →
46+
`FixedLimit(80)`; both-direction enforcement of external shell-UID writes demonstrated on `tanzanite`,
47+
issue #48). Gate: manufacturer Xiaomi + `ro.mi.os.version.code == 3` + **qualified-codename allowlist**
48+
(`QUALIFIED_CODENAMES`, ships `tanzanite`) + system user. Version-only gating is impossible: mode `2` is
49+
model-/build-dependent within HyperOS 3 (`marblein` on 3.0.2 has only 0/1), the property has no minor
50+
version, and mode-2 presence cannot be probed (key absent in factory state). Session override =
51+
Unrestricted; protective default = FixedLimit(80) — the only Xiaomi mode with demonstrated enforcement.
52+
Absent=Intelligent mirrors HyperOS 2 but is **unverified on HyperOS 3** (pending the issue-#48
53+
qualification run). No hardware decode: `dumpsys battery` exposes no hold signal on HyperOS 3.
54+
55+
Both are SYNC_READBACK with read-back equality; writes are WSS-capable (`secure` namespace). HyperOS 1,
56+
pre-HyperOS MIUI, and unqualified HyperOS 3 devices fall to `XiaomiLabAdapter` (diagnostics + contribution).
57+
The boundary write domain for the key is `{0,1,2}` globally (see `privileged-access.md`). Qualification
58+
evidence: `device-qualification` skill.
4759

4860
## OnePlus / ColorOS Adapter
4961

app/src/main/java/eu/darken/amply/charging/core/access/shizuku/ChargingControlUserService.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,12 @@ internal object SettingWritePolicy {
199199
// Pixel charging optimization
200200
"charge_optimization_mode" to setOf("0", "1"),
201201
"adaptive_charging_enabled" to setOf("0", "1"),
202-
// Xiaomi HyperOS charging protection
203-
"security_pc_secure_protect_mode_key" to setOf("0", "1"),
202+
// Xiaomi HyperOS charging protection. "2" = HyperOS 3 "Battery protection" (hard cap 80,
203+
// qualified on tanzanite — issue #48 / ledger). The domain is global, so "2" is also
204+
// boundary-writable on HyperOS 2 devices: accepted, no Amply code path emits it there
205+
// (the HyperOS 2 adapter maps no policy to it and its decode refuses it) — the boundary
206+
// owns the per-key domain, not per-ROM routing.
207+
"security_pc_secure_protect_mode_key" to setOf("0", "1", "2"),
204208
),
205209
"global" to mapOf(
206210
// GrapheneOS charge limit (1 = fixed 80% cap; the ROM latches it at plug-session start)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class AdapterRegistry @Inject constructor(
2323
samsungLegacy: SamsungLegacyChargingAdapter,
2424
samsungLab: SamsungLabAdapter,
2525
xiaomi: XiaomiChargingAdapter,
26+
xiaomiHyperOs3: XiaomiHyperOs3ChargingAdapter,
2627
xiaomiLab: XiaomiLabAdapter,
2728
onePlus: OnePlusChargingAdapter,
2829
onePlusLab: OnePlusLabAdapter,
@@ -38,7 +39,7 @@ class AdapterRegistry @Inject constructor(
3839
// Live adapters otherwise match only their verified scopes; same-OEM misses fall to the lab adapters.
3940
private val adapters = listOf(
4041
lineage, lineageLab, grapheneOs,
41-
pixel, samsungModern, samsungLegacy, samsungLab, xiaomi, xiaomiLab, onePlus, onePlusLab,
42+
pixel, samsungModern, samsungLegacy, samsungLab, xiaomi, xiaomiHyperOs3, xiaomiLab, onePlus, onePlusLab,
4243
)
4344

4445
fun select(device: DeviceInfo = DeviceInfo.current(context)): AdapterSelection {

0 commit comments

Comments
 (0)