Skip to content

Commit ab96543

Browse files
committed
Merge origin/main into the milli-unit telemetry branch
Main landed the observed-enforcement gate (#79) and the quick-action buttons (#77) while this branch was waiting on CI. Two textual conflicts, both the same shape and both in test setup where main added ChargingRepository's evidenceStore/buildIdentity arguments on the exact line this branch added BatteryReader's unitCalibration argument. Both sides are wanted, so both were kept. A third break was semantic rather than textual, so git merged it cleanly and the compiler caught it: ChargingRepositoryRestoreGateTest arrived with main as a new file and constructs BatteryReader without the calibration argument this branch added. Same fix as the other five call sites. Verified after resolution: 1345 tests across both flavors. Checked specifically that main's adapter-gating rework did not invalidate this branch's UnmappedDeviceSettingsIntentTest, which asserts a device matches no adapter at all; LineageOS now matches on the provider rather than a codename allowlist, but a bare Robolectric device still selects nothing.
2 parents f7e11a2 + f823b7a commit ab96543

92 files changed

Lines changed: 10623 additions & 347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,23 @@ allowlist** (mode `2` is not HyperOS-3-wide and cannot be probed at runtime).
1919
= FixedLimit(80)/Adaptive) is gated to ColorOS 15 (`ro.build.version.oplusrom == 15`) across the Oplus family
2020
(OnePlus/Oppo/Realme) — **writes require Shizuku** (system namespace). **LineageOS charging control** (the private
2121
`lineagesettings` provider, keys `charging_control_enabled`/`_mode`/`_charging_limit`) is manufacturer-agnostic —
22-
gated to a **physically-qualified device-codename allowlist** (HAL enforcement is per-device) plus the provider and
23-
system user; **reads are unprivileged (ContentResolver), writes require Shizuku** (the shell UID holds
24-
`lineageos.permission.WRITE_SETTINGS`, which `WRITE_SECURE_SETTINGS` does not cover). **GrapheneOS charge limit**
22+
it matches every LineageOS build that ships the provider (plus the system user), but control is gated on
23+
**enforcement evidence for that build** (HAL enforcement is per-build, and a read-back proves only that the ROM
24+
stored the value): a device stays a candidate with controls off until a maintainer qualified its codename or the
25+
user explicitly enables control on an unconfirmed build, and loses them for good if the battery is seen charging
26+
past the cap. Observation can only ever **refute** a cap, never confirm one — no passively observable signal tells
27+
a cap hold from a thermal or weak-supply pause. **Reads are unprivileged
28+
(ContentResolver), writes require Shizuku** (the shell UID holds `lineageos.permission.WRITE_SETTINGS`, which
29+
`WRITE_SECURE_SETTINGS` does not cover). **GrapheneOS charge limit**
2530
(`global battery_charge_limit`, binary FixedLimit(80)/Unrestricted) is gated to GrapheneOS identity (its
2631
`app.grapheneos.*` core packages; no property/feature/fingerprint marker exists) plus the system user —
2732
**reads AND writes require Shizuku**: GrapheneOS marks the key `@Protected`, denying it to all third-party
2833
packages including WSS holders, with only the shell UID exempt. The ROM **latches the key at plug-session start**
2934
(`policyLatchesAtPlug`), so external writes take effect at the next unplug→replug — handled by a
3035
pending-until-replug verification state and a 30s session grace window; the reconnect gesture is unsupported
3136
there. Other Pixels, Samsung on unverified One UI versions (6/7, 9+), unqualified Xiaomi devices,
32-
non-ColorOS-15 Oplus devices, and unqualified LineageOS builds remain diagnostics-only. See the qualification
37+
non-ColorOS-15 Oplus devices, and LineageOS builds without the settings provider remain diagnostics-only. See the
38+
qualification
3339
ledger (`.claude/skills/device-qualification/`) for the verified devices and mappings.
3440

3541
Package: `eu.darken.amply`. License: GPL-3.0-or-later. Status: pre-launch (current version in `VERSION`).
@@ -49,6 +55,7 @@ constraints:
4955
Under `app/src/main/java/eu/darken/amply/`:
5056

5157
- `charging/core` — policies, device capability checks, OEM adapters, WSS, Shizuku access (`access/shizuku`, `adapter`)
58+
- `charging/core/enforcement` — the observed-enforcement gate: verdict engine, durable evidence, monitor watcher
5259
- `fullcharge/core` — temporary sessions, boot recovery, reconnect gesture
5360
- `main/ui` — activity, onboarding, dashboard, settings, setup guide, `tile`, `widget`
5461
- `diagnostics/core` + `diagnostics/ui` — "Help add support" contribution wizard: read-only multi-mode setting

.claude/rules/privileged-access.md

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ the qualification ledger (`device-qualification` skill).
142142

143143
### LineageOS
144144

145-
LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), a **physically-qualified
146-
device codename** (`Build.DEVICE``LineageChargingAdapter.QUALIFIED_CODENAMES`), the `lineagesettings` provider
147-
present, and the system user.
145+
LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), the `lineagesettings` provider
146+
present, the system user, and clearing the **enforcement evidence gate** for this exact build (a maintainer
147+
codename, or the user's explicit opt-in on an unconfirmed build, and no refutation — see "Enforcement evidence gate"
148+
below). The adapter *matches* every provider-carrying LineageOS build; what it may *do* there is decided by the
149+
gate, not by a codename.
148150

149151
**Detect LineageOS with the `org.lineageos.android` system feature, never `ro.lineage.build.version`.** All five
150152
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
@@ -154,14 +156,17 @@ adapter (verified on oriole / LineageOS 23.2 / Android 16). `hasSystemFeature` n
154156
permission. `lineageOsVersion` remains a **secondary identity signal**`isLineageOs` ORs it in so derivatives that
155157
relabel the property still match — and is normally null on real hardware; it is not "diagnostics only".
156158

157-
**Blocker before the first codename is added to `QUALIFIED_CODENAMES`:** the live gate is codename-scoped, but HAL
158-
capability is *build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical
159-
hardware. A bare codename entry would therefore claim more than any single qualification run proves. Scope the entry
160-
by codename **plus** the qualified Lineage generation / API level (and treat a property-only or derivative match as
161-
insufficient for the live gate), or qualify every build you intend to cover. It is **manufacturer-agnostic** (LineageOS runs on many OEMs), so the Lineage
162-
live/lab adapters are ordered **before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/
163-
Xiaomi/OnePlus hardware must never be swallowed by a manufacturer-based lab adapter. Unqualified LineageOS builds
164-
fall to `LineageLabAdapter` (diagnostics/contribution).
159+
`QUALIFIED_CODENAMES` survives as the **maintainer fast path** only (still empty): a codename there is the ONLY
160+
route to the confirmed tier, since nothing Amply can observe confirms a cap (see below). It is deliberately no
161+
longer what makes the adapter reachable, because HAL capability is
162+
*build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical hardware —
163+
so a bare codename entry claims more than any single qualification run proves. Add one only with a qualified device
164+
plus a ledger row, and only when you mean every build on that device.
165+
166+
LineageOS is **manufacturer-agnostic** (it runs on many OEMs), so the Lineage live/lab adapters are ordered
167+
**before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/Xiaomi/OnePlus hardware must never
168+
be swallowed by a manufacturer-based lab adapter. A build **without** the settings provider does not match the live
169+
adapter at all and falls to `LineageLabAdapter` (diagnostics/contribution), which keeps that ordering for it.
165170

166171
The three keys live in the private `content://lineagesettings/system` provider — **NOT** any AOSP `settings`
167172
namespace. Modeled as `SettingNamespace.LINEAGE_SYSTEM`: **reads are unprivileged** (`LineageSettingsClient`,
@@ -174,11 +179,56 @@ Writable keys/domains (`LineageSettingWritePolicy`, independent of the adapter):
174179

175180
**Crucial gate rationale:** the setting can be written while the `vendor.lineage.health.IChargingControl` HAL never
176181
actually limits (the `mIsLimitSet:false` class of bug) — setting readback does **not** prove hardware enforcement.
177-
That is why the gate is a qualified-codename allowlist, not "any LineageOS device": a device must be physically
178-
proven (see the qualification protocol) before its codename is added. The adapter also **refuses** (reads
182+
That is why control is never granted on "any LineageOS device": either the maintainer proved the hardware (a ledger
183+
row), or the user accepted an explicitly unconfirmed build that Amply keeps watching for a refutation. The adapter also
184+
**refuses** (reads
179185
`Unknown(unrecognizedValue=true)`) any native state it cannot restore exactly — AUTO/CUSTOM schedule modes, off-tick
180186
limits, or an absent/malformed `enabled` — so a temporary session never clobbers the user's own choice.
181187

188+
## Enforcement Evidence Gate
189+
190+
For adapters that set `ChargingAdapter.enforcementEvidenceRequired` (LineageOS today), a settings read-back is
191+
**not** a licence to offer control: it proves the ROM stored the value, not that the charging hardware acts on it.
192+
`AdapterRegistry.select()` therefore takes an explicit `EnforcementEvidenceState`**no default**, so "the caller
193+
forgot", "not read yet" and "genuinely nothing stored" cannot collapse into control-enabled — and resolves a tier in
194+
this order: **REFUTED** (or a corrupt record, which may be one) → control off, contribution wanted; **CONFIRMED**
195+
(a maintainer codename, and nothing else) → control as probed; **UNVERIFIED** (the user accepted the unconfirmed
196+
build) → control as probed, but no surface may claim the cap is proven; otherwise **CANDIDATE** → control off.
197+
Callers that only need adapter *capabilities* pass `EnforcementEvidenceState.Loading`, which can never enable
198+
control. A probe that **already** refused control (secondary user, missing provider) short-circuits the whole
199+
resolution: enforcement stays null and no surface offers an opt-in that could not change anything.
200+
201+
The gate governs **new control only**. A restore the user is already owed — the session restore, its rollback, boot
202+
recovery of one — goes through `ChargingRepository.restorePersistent()`, which applies every adapter precondition
203+
but not the evidence tier: an OTA mid-session changes the build identity, and refusing the owed protective write
204+
would strand the device in the session's Unrestricted state.
205+
206+
Pending recovery work is **not** automatically an owed restore, so every recovery target carries a persisted
207+
`RecoveryOrigin` and `writeRecoveryTarget()` dispatches on it: `SESSION_RESTORE` takes the ungated path,
208+
`USER_REQUEST` (a widget/tile persistent choice, which `setPersistentPolicy` persists *before* its write) stays on
209+
the gated `reapplyPersistent()`. A fresh user write — `Unrestricted` included — must not reach a build the gate
210+
refuses just because a process death turned it into recovery work. The field's default is the gated
211+
`USER_REQUEST`, so a record from a build without it cannot bypass the gate either.
212+
213+
Evidence is produced by `charging/core/enforcement/`: a pure `EnforcementVerdictEngine` over the monitor's battery
214+
ticks, persisted by `EnforcementEvidenceStore`. Three properties are load-bearing and must not be relaxed:
215+
216+
- **Observation can only refute, never confirm — `EnforcementVerdict` has exactly one value.** No passively
217+
observable signal distinguishes a cap hold from a thermal or weak-supply pause. `EXTRA_CHARGING_STATUS` == 4
218+
looked like one, but it is *session-scoped*: measured on a Pixel 6 / LineageOS 23.2, the extra read 4 while the
219+
device was actively charging at level 70 under an 80% cap — it means "limit mode is enabled for this plug
220+
session", exactly as `StatsLimitHitDetector`'s KDoc documents for Pixel. The only field that differs between a
221+
cap hold and a thermal pause is `EXTRA_STATUS`, which both produce. So Amply never claims a cap is verified from
222+
observation; the confirmed tier comes solely from physical qualification. Earning a real confirmation would take
223+
a **guided two-cap challenge** (write a cap below the current level and watch charging cut, raise it and watch it
224+
resume, cut again) — known, and deliberately not implemented. **REFUTE keys on an upward level trend** through
225+
the cap from any starting level, needs no hardware signal, and deliberately ignores the reported battery status,
226+
which a ROM can misreport while charging past the limit.
227+
- Evidence is scoped to a **composite build identity** (fingerprint + incremental + build time + provider version
228+
code, hashed). `Build.FINGERPRINT` alone is useless here: LineageOS spoofs it to stock.
229+
- A refutation is **terminal** for its scope, and a corrupt record is treated as a refutation. Both are fail-closed
230+
on purpose — the one error this gate exists to prevent is claiming protection that isn't there.
231+
182232
## Foreground Service Requirement
183233

184234
The temporary override uses a `specialUse` foreground service because dormant apps cannot reliably receive

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,45 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
5656

5757
## Known gaps
5858

59-
- **LineageOS** — landed **diagnostics-only**: `QUALIFIED_CODENAMES` ships **empty**, so the live adapter never
60-
matches and every LineageOS build falls to `LineageLabAdapter`. A codename is added only after that device passes
61-
qualification (real charging cessation at the limit, wired + wireless, below/at/above threshold) and gets a
62-
"Verified devices" row.
59+
- **LineageOS** — the live adapter now **matches every LineageOS build that ships the `lineagesettings` provider**;
60+
`QUALIFIED_CODENAMES` still ships **empty** but is only a maintainer fast path, no longer what makes the adapter
61+
reachable. Control is gated on **per-build enforcement evidence** instead: a device is a candidate with controls
62+
off until the user explicitly enables control on an unconfirmed build, and loses control permanently for that
63+
build if the battery is observed charging past the cap. A codename still goes into `QUALIFIED_CODENAMES` only
64+
after full qualification (real charging cessation at the limit, wired + wireless, below/at/above threshold) plus
65+
a "Verified devices" row — that list now buys skipping the opt-in, not access itself.
66+
- **Observation can only refute, never confirm** (established 2026-08-17, see the oriole re-observation below):
67+
no passively observable signal distinguishes a cap hold from a thermal or weak-supply pause. Both present as
68+
plugged + `BATTERY_STATUS_NOT_CHARGING` + a static level. `EXTRA_CHARGING_STATUS` does **not** break the tie —
69+
it is session-scoped, measured still reading `4` while the device charged ten points *below* its cap. A guided
70+
two-cap cut/resume/cut challenge (raise the cap, verify charging resumes and re-stops at the new threshold) is
71+
the known way to earn real confirmation and is deliberately not implemented.
72+
- **Pixel 6 (oriole) on LineageOS 23.2 / Android build `BP4A.251205.006` — re-observed 2026-08-17. The
73+
2026-07-22 LOS 23.2 NO-GO below does NOT hold on this build.** `dumpsys lineagehealth` binds
74+
`ccprovider.Limit` (so the HAL *does* advertise the LIMIT mode bit again), `charging_control_mode` reads back
75+
as `3` and is **not** coerced to `1`, and with `enabled=1 / mode=3 / limit=70` the device sat at exactly 70 %
76+
on AC for ~4 h: `status: 4` (NOT_CHARGING), `Charging state: 4`, `Charging policy: 1`, voltage 4072 mV.
77+
**Causal check**: raising `charging_control_charging_limit` to `80` resumed charging within 20 s —
78+
`status: 2`, voltage 4072 → 4183 mV, temperature 294 → 311, charge counter 2908000 → 2910000 — i.e. the
79+
setting demonstrably drives the charging hardware, which is stronger evidence than an observed plateau.
80+
The limit was restored to `70` and re-verified by read-back; `enabled`/`mode` were never written.
81+
**This is NOT a GO and oriole is NOT in `QUALIFIED_CODENAMES`**: only part of step 2 was run — wired only, no
82+
wireless, no below/at/above sweep, no hold observed at the raised cap, and none of steps 3-5 (access tiers,
83+
sessions, boot recovery, R8). It supersedes the "HAL dropped LIMIT" claim for *this build only*.
84+
**Unexplained later observation, recorded so this row does not overclaim**: a few hours after the run above,
85+
the device was found at **78 %** with `charging_control_charging_limit` still reading `70` — i.e. a level
86+
above the cap. It is **not attributable** and must not be read either as enforcement failing or as anything
87+
else: by then the phone had left this run's control entirely — physically unplugged and moved, and its
88+
system clock force-set to `Tue Jul 21 02:01 CEST` (a month in the past, at 02:00) by another workflow, which
89+
is a scheduled/night-charge experiment signature. Any of that could produce the rise. The controlled
90+
observations above stand as recorded; this one is logged only so a later reader does not find it and
91+
conclude the row was written selectively. Re-check under controlled conditions before treating either as
92+
settled.
93+
**Why it matters beyond oriole**: same device, same Lineage major version, different build, opposite HAL
94+
capability. That is the concrete case for HAL capability being **build-scoped, not codename-scoped**, and it
95+
is why enforcement evidence is keyed on a composite build identity (fingerprint + incremental + build time +
96+
provider package version) rather than a codename — LineageOS spoofs `Build.FINGERPRINT` to stock, so the
97+
fingerprint alone cannot carry it.
6398
- **Pixel 6 (oriole) on LineageOS 20.0 / Android 13 — tested 2026-07-22, result NO-GO (no hardware enforcement).**
6499
The *software chain is fully validated* — both raw (shell-UID `content query`/`content insert`) **and through the
65100
app's real Shizuku backend end-to-end** (R8 debug build + Shizuku granted): tapping 80 % wrote the trio via

app/src/debug/java/eu/darken/amply/screenshots/ScreenshotContent.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ internal fun ReconnectGestureContent() = PreviewWrapper {
6767
gestureEnabled = true,
6868
anyLevelEnabled = false,
6969
canEnableGesture = true,
70+
// A two-policy device, so the shot stays on the gesture options themselves.
71+
availablePolicies = listOf(ChargePolicy.FixedLimit(80), ChargePolicy.Unrestricted),
72+
selectedPolicyIds = listOf(ChargePolicy.FixedLimit(80).stableId, ChargePolicy.Unrestricted.stableId),
7073
onBack = {},
7174
onGestureEnabledChange = {},
7275
onAnyLevelChange = {},
76+
onNotificationPolicyToggle = { _, _ -> },
7377
)
7478
}
7579

@@ -91,8 +95,10 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
9195
onAlarmEnabledChange = {},
9296
onAlarmTargetChange = {},
9397
onFixNotifications = {},
98+
onOpenConditions = {},
9499
onOpenBatteryHub = {},
95100
onRetryCapture = {},
101+
onStartVerification = {},
96102
onPinWidget = {},
97103
onAddTile = {},
98104
onDismissQuickAccess = {},

app/src/main/AndroidManifest.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1010
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
1111

12+
<!-- Charge conditions: a Bluetooth-device rule needs the connect/disconnect broadcasts and the
13+
bonded-device list. Runtime-requested on API 31+, install-time below that. -->
14+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
15+
<uses-permission
16+
android:name="android.permission.BLUETOOTH"
17+
android:maxSdkVersion="30" />
18+
1219
<queries>
1320
<package android:name="moe.shizuku.privileged.api" />
1421
<package android:name="com.google.android.settings.intelligence" />
@@ -70,6 +77,17 @@
7077
</intent-filter>
7178
</activity>
7279

80+
<!-- The AppWidget host launches a configuration activity by component name, so this one
81+
cannot live inside MainActivity. It hosts a single Compose screen, no navigation. -->
82+
<activity
83+
android:name=".main.ui.widget.WidgetConfigActivity"
84+
android:excludeFromRecents="true"
85+
android:exported="true">
86+
<intent-filter>
87+
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
88+
</intent-filter>
89+
</activity>
90+
7391
<service
7492
android:name=".fullcharge.core.ChargeSessionService"
7593
android:exported="false"
@@ -104,6 +122,15 @@
104122
android:resource="@xml/amply_widget_info" />
105123
</receiver>
106124

125+
<receiver
126+
android:name=".rules.core.BluetoothRuleReceiver"
127+
android:exported="true">
128+
<intent-filter>
129+
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
130+
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
131+
</intent-filter>
132+
</receiver>
133+
107134
<receiver
108135
android:name=".fullcharge.core.BootReceiver"
109136
android:directBootAware="false"

0 commit comments

Comments
 (0)