Skip to content

Commit 3d31322

Browse files
authored
Charging: Distinguish a configured policy from one that is in effect (#73)
* Charging: Distinguish a configured policy from one that is in effect ChargeObservation.Verified conflated two claims: that a configured value was read back, and that the charger is acting on it. The dashboard granted its confirmed checkmark for any Verified, so an OEM mode that the ROM has not engaged looked identical to an enforced cap. Physically confirmed on a Xiaomi 13T (aristotle, HyperOS 2, 2026-08-16): with Adaptive written via the shell UID and read back as Verified(Adaptive, SHIZUKU), the phone charged 59% to 100% with no hold at any level. HyperOS engages Intelligent charging only inside a learned overnight window, its BaseChargeProtect_Night daemon keeping average/stddev/count preferences of habitual charging; getNightChargingState returned 0 on all 140 evaluations during the run. The write path is not at fault, external writes drive the daemon identically to native UI taps. ChargePolicy.enforcementIsConditional marks Adaptive as a policy whose timing the OEM decides. ChargeObservation.provesPolicyInEffect() combines it with the backend: hardware evidence settles any policy, a readback settles only unconditional ones. It is deliberately about knowledge rather than safety, so Unrestricted qualifies too, it is in effect exactly as verifiably as a cap. Presentation only. The two sites that decide whether a write landed, the repository's `settled` computation and computeRefreshPending's sync arm, keep clearing on any matching readback; adopting the predicate there would spin every Xiaomi adaptive apply for the full settling window, on that adapter's own protective default. Recovery, sessions, the native-change guard and the reconnect gesture are untouched for the same reason. Pixel now under-reports when the HAL reports an engaged adaptive profile, because refreshLocked prefers a settings Verified over the hardware decode and discards state 5. Under-claiming is the safe direction and matches how the rest of the app fails closed; plumbing live hardware state through would flip the card between states through a single night. The HyperOS 2 and 3 adapters no longer share a readiness string, so HyperOS 3 keeps the stronger hardware claim its qualification supports. * Charging: Record the on-device verification of the conditional-policy fix Closes the ledger's open adapter question: the HyperOS 2 protective default stays Adaptive because the ROM offers nothing unconditional, and the honesty lives in presentation instead. Verified on aristotle with a foss debug build over direct WSS, including that neither policy shows a settling spinner, which is the visible symptom of the predicate leaking into pending logic. * Fix: Pass onOpenConditions in the conditional-policy preview The charge-rules feature added a required callback to DashboardScreen after this branch was cut. No textual conflict, but the merged tree stopped compiling because the new preview passes the callback list explicitly. * Fix: Drop onOpenConditions again after the charge-rules revert The callback arrived on main and was reverted twenty minutes later, so the preview needed the parameter and then needed it gone. Nothing about this branch changed either time.
1 parent 2970b9c commit 3d31322

14 files changed

Lines changed: 408 additions & 14 deletions

File tree

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
4848
|---|---|---|---|---|
4949
| 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 |
5050
| 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 |
51-
| 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 |
51+
| 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 |
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 |
5454
| 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 |
@@ -149,8 +149,60 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
149149
(`ro.build.version.opporom`) is read, so pre-ColorOS-12 builds remain undetectable as Oplus by ROM version. A
150150
device with a *working* pre-15 ColorOS charge-protection feature would need a new gate signal, not a widened
151151
version constant — and the usual bar applies: physically observed charging cessation, not a settings mapping.
152-
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
153-
the 80% hold is physically observed.
152+
- **Xiaomi****external-write handling RESOLVED (2026-08-16); the adaptive 80% hold remains unobserved and is
153+
now believed UNQUALIFIABLE on an unused test device.** Split the old "adaptive enforcement unconfirmed" gap into
154+
its two halves — one is now closed, the other is characterized rather than merely open.
155+
- **Closed: external shell-UID writes are functionally identical to native UI taps** (Xiaomi 13T `aristotle`,
156+
HyperOS 2 `OS2.0.216.0.VMFEUXM`, Android 15, maintainer hardware). Both paths produce the same daemon chain
157+
with the same values, within ~80 ms of the write:
158+
`ChargeProtectionUtils: getProtectMode mode:N``SmartChargeProtectManager: checkUiModeProtect:N`
159+
`BaseChargeProtect_: MODE_NIGHT,setEnable fromShouldWork:false,to:false,enable:{true|false}`.
160+
Verified in both directions and both origins (UI tap to Charge fully / Intelligent, external
161+
`settings put … 0` / `… 1`). **There is no hidden internal flag that only the Settings UI sets** — an
162+
explicitly tested hypothesis, refuted. The native UI also reflects an externally-written key on next open.
163+
So Amply's write mechanism is complete and correct on HyperOS 2; nothing in the adapter needs changing for
164+
the write path.
165+
- **Still unobserved: the hold itself.** With the key at `1` (Intelligent, written externally), the device
166+
charged **59% → 100% continuously with no plateau at any level** — uniform ~4 min per point, voltage and
167+
charge counter rising monotonically through 80 (4265→4293 mV, counter 3390000→3493000), `status: 2`
168+
throughout. `getNightChargingState` was evaluated **140 times across the run and returned `0` every time**
169+
(60 s cadence while charging). No hardware hold signal exists — `Charging state: 0` / `Charging policy: 0`
170+
in all states, same as HyperOS 3.
171+
- **Root cause of the non-engagement: the gate is a LEARNED schedule, not a clock window.** Strings in
172+
`com.miui.securitycenter` (`com.miui.powercenter.nightcharge`) show the feature keeps a statistical model of
173+
habitual overnight charging: `key_ave_night_charge_start_minutes`, `key_night_charge_start_minutes_sd`,
174+
`key_night_charge_end_minutes_sd` (standard deviations), `key_enter_night_charge_times` (occurrence count),
175+
`key_earliest_night_charge_end_minutes`, `key_night_charge_record`, plus four distinct
176+
`isNeedNightChargeProtection return false case 1..4` rejection paths. Directly corroborated: forcing the
177+
device clock to 02:30 (via `cmd time_detector set_time_state_for_tests`; shell UID lacks both `SET_TIME` and
178+
`SUGGEST_MANUAL_TIME_AND_ZONE`) did **not** open the window — `isNightChargeProtectionOpen` stayed `false`.
179+
Time of day alone is insufficient; the daemon wants a low-variance charging history the device does not have.
180+
- **Consequence for qualification: this device cannot settle it.** The 13T is a maintainer *test* device with no
181+
normal daily use, so it can never accumulate the routine the model requires. Qualifying Adaptive would need
182+
either weeks of genuine (or convincingly simulated) nightly charging at a consistent time, or root to seed
183+
SecurityCenter's private prefs. Record the July "could not be triggered" and this run as the **same** result
184+
with a now-known cause, not as two independent failures. **Adaptive on Xiaomi is therefore BLOCKED, not
185+
FAILED** — no evidence exists that it fails to hold, only that its precondition never became true.
186+
- **Consequence for the adapter, RESOLVED in the same change** (see `XiaomiChargingAdapter.kt`,
187+
`defaultProtectivePolicy = Adaptive`): Amply's protective policy on HyperOS 2 is a mode that, by the OEM's own
188+
design, only acts inside a learned overnight window, so a device with Adaptive configured charges to 100%
189+
outside it. The read was never *wrong* (the mode genuinely is configured) but "protected" overstated it. The
190+
default stays — HyperOS 2 offers no unconditional protective mode — and the honesty moved to presentation:
191+
`ChargePolicy.enforcementIsConditional` + `ChargeObservation.provesPolicyInEffect()` withhold the confirmed
192+
checkmark from a conditional policy that only settings can vouch for. **Verified on `aristotle` itself**
193+
(2026-08-16, foss debug + direct WSS): Adaptive renders with the neutral shield and "the system chooses when
194+
it applies", 100% still renders with the green check and the plain readback line, and both apply without a
195+
settling spinner — the last point being the visible symptom if the predicate ever leaks into pending logic.
196+
Note HyperOS blocks adb installs behind an on-device "Install via USB" dialog with a 5s auto-deny, so an
197+
install must be confirmed on screen while it is awake.
198+
- **Dead end, do not re-investigate:** `global battery_charging_state_enforce_level` and
199+
`battery_charging_state_update_delay` (both `-1` on this device) look like an enforcement lever from their
200+
names — they are not. Resolved 2026-08-16 by disassembling the device's own `/system/framework/services.jar`
201+
(`dexdump`): both are read by **`com.android.server.power.stats.BatteryStatsImpl$Constants`**, alongside
202+
`KEY_BATTERY_CHARGED_DELAY_MS`, `KEY_MAX_HISTORY_FILES`, and `KEY_KERNEL_UID_READERS_THROTTLE_TIME`. This is
203+
stock AOSP **battery-statistics bookkeeping** (when batterystats treats the device as charged, for history
204+
reset), not Xiaomi charge control, and it cannot cap charging current. Never written to. Recorded here
205+
specifically so the plausible-sounding name does not cost anyone a second investigation.
154206
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified at the time; since landed,
155207
see the LANDED bullet below).** A
156208
Redmi Note 14 `24117RN76G` (`tanzanite`, Android 16 / SDK 36, `ro.mi.os.version.code=3`, ROM

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ sealed interface ChargePolicy {
1212
val allowsFullCharge: Boolean
1313
get() = this == Unrestricted || this == PauseAtFull || (this is FixedLimit && percent >= 100)
1414

15+
/**
16+
* Whether the OEM decides *when* this policy acts, rather than acting whenever it is configured.
17+
* A conditional policy that is configured and read back proves the mode is selected — never that
18+
* the battery is being protected right now. Every OEM "adaptive"/"smart"/"intelligent" mode
19+
* engages only inside a learned window: a HyperOS overnight window on Xiaomi (a 13T with
20+
* Intelligent charging configured and verified charged 59%→100% with no hold, 2026-08-16), and
21+
* shortly before the usual unplug on Pixel/ColorOS.
22+
*
23+
* Consumed by [ChargeObservation.provesPolicyInEffect] for presentation only.
24+
*/
25+
val enforcementIsConditional: Boolean
26+
get() = when (this) {
27+
Adaptive -> true
28+
Unrestricted, PauseAtFull -> false
29+
is FixedLimit -> false
30+
}
31+
1532
data object Unrestricted : ChargePolicy {
1633
override val stableId = "unrestricted"
1734
override val label = R.string.charging_policy_unrestricted_label.toCaString()

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ fun ChargingState.isSettling(now: Long): Boolean {
2424
obs.policy == p.target)
2525
}
2626

27+
/**
28+
* Whether the observed policy is what the charger is actually *doing*, as opposed to what has merely
29+
* been *selected*. Note this is a statement about knowledge, not about safety: `Unrestricted` is in
30+
* effect exactly as verifiably as a fixed limit, it just protects nothing.
31+
*
32+
* For an unconditional policy the two coincide — a fixed limit caps, and no limit charges to full,
33+
* whenever configured. For a policy whose engagement the OEM decides
34+
* ([ChargePolicy.enforcementIsConditional]) they come apart: a readback proves the mode is selected
35+
* and nothing more, so only a [BackendKind.BATTERY_HARDWARE] reading can show it is engaged.
36+
*
37+
* [isSettling] asks whether the write landed; this asks whether the configuration describes reality.
38+
* Those are different questions, which is why this is **presentation only**. It must never be adopted
39+
* by pending, settling, recovery, session, or gesture logic: those all ask "is the configuration what
40+
* we asked for?", which a conditional policy answers fully. In particular `ChargingRepository`'s
41+
* `settled` computation and `computeRefreshPending`'s sync-readback arm must keep clearing on any
42+
* matching readback — adopting this there would spin a Xiaomi adaptive write for the full settling
43+
* window on every apply, on that adapter's own protective default.
44+
*/
45+
fun ChargeObservation.provesPolicyInEffect(): Boolean =
46+
this is ChargeObservation.Verified &&
47+
(backend == BackendKind.BATTERY_HARDWARE || !policy.enforcementIsConditional)
48+
2749
/** The policy a settling request is converging on, or null when nothing is pending. Surfaces choose their own copy. */
2850
fun ChargingState.settlingTarget(): ChargePolicy? = pending?.target
2951

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ class XiaomiChargingAdapter @Inject constructor() : ChargingAdapter {
4444
ChargePolicy.Unrestricted,
4545
)
4646

47+
/**
48+
* The only adapter whose protective default is conditional ([ChargePolicy.enforcementIsConditional]),
49+
* and unavoidably so: the HyperOS 2 key domain is `{0,1}`, so Adaptive is the sole protective mode
50+
* this ROM offers. HyperOS only engages it inside a learned overnight window — a 13T with Adaptive
51+
* configured and read back charged 59%→100% untouched (2026-08-16) — so the honesty burden lands on
52+
* presentation, which refuses to claim active protection for it. The HyperOS 3 adapter has an
53+
* unconditional mode available and defaults to `FixedLimit(80)` instead.
54+
*/
4755
override val defaultProtectivePolicy = ChargePolicy.Adaptive
4856
override val verification = VerificationStrategy.SYNC_READBACK
4957

@@ -152,7 +160,9 @@ class XiaomiHyperOs3ChargingAdapter @Inject constructor() : ChargingAdapter {
152160
detail = when {
153161
!matched -> R.string.adapter_detail_requires_xiaomi_hyperos3
154162
!device.isSystemUser -> R.string.adapter_detail_secondary_user
155-
else -> R.string.adapter_detail_xiaomi_ready
163+
// Distinct from the HyperOS 2 string: Battery protection has demonstrated hardware
164+
// enforcement (issue #48), so the stronger claim stays accurate here.
165+
else -> R.string.adapter_detail_xiaomi_hyperos3_ready
156166
},
157167
contributionWanted = false,
158168
)

0 commit comments

Comments
 (0)