Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
54372ed
Charging: Add the enforcement-evidence record, verdict engine, and store
d4rken Aug 16, 2026
08b4a02
Charging: Gate LineageOS control on observed enforcement, not a codename
d4rken Aug 16, 2026
120c76e
Charging: Record enforcement evidence from the charge monitor
d4rken Aug 16, 2026
0233c58
Dashboard: Surface the enforcement tier and the verification opt-in
d4rken Aug 16, 2026
7ebac81
General: Document the enforcement evidence gate
d4rken Aug 16, 2026
05a3a6c
Dashboard: Keep the enforcement slot out of the list when it renders …
d4rken Aug 16, 2026
ce238aa
Dashboard: Pass the new verification callback from the remaining fixt…
d4rken Aug 16, 2026
e499933
Charging: Require a hardware hold signal to confirm enforcement
d4rken Aug 17, 2026
96a922c
Charging: Track the enforcement climb from any level, not only below …
d4rken Aug 17, 2026
b5636d8
Widget: Withhold the limit claim on unverified and refuted builds
d4rken Aug 17, 2026
12f53fc
Charging: Let a persisted restore bypass the enforcement evidence gate
d4rken Aug 17, 2026
2ea965b
Charging: Skip the enforcement tier when the probe already refused co…
d4rken Aug 17, 2026
a3f1948
General: Correct the enforcement gate rules for the hardware-signal fix
d4rken Aug 17, 2026
62ec511
Charging: Drop the enforcement confirmation path, keep refutation
d4rken Aug 17, 2026
5ed5c70
Charging: Bump the enforcement algorithm version to 2
d4rken Aug 17, 2026
bf45fb6
FullCharge: Keep pending user writes on the gated recovery path
d4rken Aug 17, 2026
92a4dbd
Charging: Migrate version-1 enforcement records per verdict
d4rken Aug 17, 2026
d25b0a3
Charging: Drop the enforcement inputs the confirmation removal orphaned
d4rken Aug 17, 2026
90a02c5
Dashboard: Name the unconfirmed-build previews after what they show
d4rken Aug 17, 2026
dc72d9e
General: Record the oriole LineageOS 23.2 re-observation in the ledger
d4rken Aug 17, 2026
7df479c
General: Record the unattributable post-run oriole observation
d4rken Aug 17, 2026
318fbfa
Dashboard: Let the hero state the enforcement tier
d4rken Aug 17, 2026
0f37328
Dashboard: Pin the hero wording for the withheld enforcement tiers
d4rken Aug 17, 2026
8f9dfdf
Merge origin/main into the enforcement-evidence gate
d4rken Aug 17, 2026
eb26509
Merge origin/main into the enforcement-evidence gate
d4rken Aug 17, 2026
File filter

Filter by extension

Filter by extension

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

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

- `charging/core` — policies, device capability checks, OEM adapters, WSS, Shizuku access (`access/shizuku`, `adapter`)
- `charging/core/enforcement` — the observed-enforcement gate: verdict engine, durable evidence, monitor watcher
- `fullcharge/core` — temporary sessions, boot recovery, reconnect gesture
- `main/ui` — activity, onboarding, dashboard, settings, setup guide, `tile`, `widget`
- `diagnostics/core` + `diagnostics/ui` — "Help add support" contribution wizard: read-only multi-mode setting
Expand Down
76 changes: 63 additions & 13 deletions .claude/rules/privileged-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ the qualification ledger (`device-qualification` skill).

### LineageOS

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

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

**Blocker before the first codename is added to `QUALIFIED_CODENAMES`:** the live gate is codename-scoped, but HAL
capability is *build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical
hardware. A bare codename entry would therefore claim more than any single qualification run proves. Scope the entry
by codename **plus** the qualified Lineage generation / API level (and treat a property-only or derivative match as
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
live/lab adapters are ordered **before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/
Xiaomi/OnePlus hardware must never be swallowed by a manufacturer-based lab adapter. Unqualified LineageOS builds
fall to `LineageLabAdapter` (diagnostics/contribution).
`QUALIFIED_CODENAMES` survives as the **maintainer fast path** only (still empty): a codename there is the ONLY
route to the confirmed tier, since nothing Amply can observe confirms a cap (see below). It is deliberately no
longer what makes the adapter reachable, because HAL capability is
*build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical hardware —
so a bare codename entry claims more than any single qualification run proves. Add one only with a qualified device
plus a ledger row, and only when you mean every build on that device.

LineageOS is **manufacturer-agnostic** (it runs on many OEMs), so the Lineage live/lab adapters are ordered
**before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/Xiaomi/OnePlus hardware must never
be swallowed by a manufacturer-based lab adapter. A build **without** the settings provider does not match the live
adapter at all and falls to `LineageLabAdapter` (diagnostics/contribution), which keeps that ordering for it.

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

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

## Enforcement Evidence Gate

For adapters that set `ChargingAdapter.enforcementEvidenceRequired` (LineageOS today), a settings read-back is
**not** a licence to offer control: it proves the ROM stored the value, not that the charging hardware acts on it.
`AdapterRegistry.select()` therefore takes an explicit `EnforcementEvidenceState` — **no default**, so "the caller
forgot", "not read yet" and "genuinely nothing stored" cannot collapse into control-enabled — and resolves a tier in
this order: **REFUTED** (or a corrupt record, which may be one) → control off, contribution wanted; **CONFIRMED**
(a maintainer codename, and nothing else) → control as probed; **UNVERIFIED** (the user accepted the unconfirmed
build) → control as probed, but no surface may claim the cap is proven; otherwise **CANDIDATE** → control off.
Callers that only need adapter *capabilities* pass `EnforcementEvidenceState.Loading`, which can never enable
control. A probe that **already** refused control (secondary user, missing provider) short-circuits the whole
resolution: enforcement stays null and no surface offers an opt-in that could not change anything.

The gate governs **new control only**. A restore the user is already owed — the session restore, its rollback, boot
recovery of one — goes through `ChargingRepository.restorePersistent()`, which applies every adapter precondition
but not the evidence tier: an OTA mid-session changes the build identity, and refusing the owed protective write
would strand the device in the session's Unrestricted state.

Pending recovery work is **not** automatically an owed restore, so every recovery target carries a persisted
`RecoveryOrigin` and `writeRecoveryTarget()` dispatches on it: `SESSION_RESTORE` takes the ungated path,
`USER_REQUEST` (a widget/tile persistent choice, which `setPersistentPolicy` persists *before* its write) stays on
the gated `reapplyPersistent()`. A fresh user write — `Unrestricted` included — must not reach a build the gate
refuses just because a process death turned it into recovery work. The field's default is the gated
`USER_REQUEST`, so a record from a build without it cannot bypass the gate either.

Evidence is produced by `charging/core/enforcement/`: a pure `EnforcementVerdictEngine` over the monitor's battery
ticks, persisted by `EnforcementEvidenceStore`. Three properties are load-bearing and must not be relaxed:

- **Observation can only refute, never confirm — `EnforcementVerdict` has exactly one value.** No passively
observable signal distinguishes a cap hold from a thermal or weak-supply pause. `EXTRA_CHARGING_STATUS` == 4
looked like one, but it is *session-scoped*: measured on a Pixel 6 / LineageOS 23.2, the extra read 4 while the
device was actively charging at level 70 under an 80% cap — it means "limit mode is enabled for this plug
session", exactly as `StatsLimitHitDetector`'s KDoc documents for Pixel. The only field that differs between a
cap hold and a thermal pause is `EXTRA_STATUS`, which both produce. So Amply never claims a cap is verified from
observation; the confirmed tier comes solely from physical qualification. Earning a real confirmation would take
a **guided two-cap challenge** (write a cap below the current level and watch charging cut, raise it and watch it
resume, cut again) — known, and deliberately not implemented. **REFUTE keys on an upward level trend** through
the cap from any starting level, needs no hardware signal, and deliberately ignores the reported battery status,
which a ROM can misreport while charging past the limit.
- Evidence is scoped to a **composite build identity** (fingerprint + incremental + build time + provider version
code, hashed). `Build.FINGERPRINT` alone is useless here: LineageOS spoofs it to stock.
- A refutation is **terminal** for its scope, and a corrupt record is treated as a refutation. Both are fail-closed
on purpose — the one error this gate exists to prevent is claiming protection that isn't there.

## Foreground Service Requirement

The temporary override uses a `specialUse` foreground service because dormant apps cannot reliably receive
Expand Down
43 changes: 39 additions & 4 deletions .claude/skills/device-qualification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,45 @@ only after adding a row here. Detailed run narratives live in each adapter's lan

## Known gaps

- **LineageOS** — landed **diagnostics-only**: `QUALIFIED_CODENAMES` ships **empty**, so the live adapter never
matches and every LineageOS build falls to `LineageLabAdapter`. A codename is added only after that device passes
qualification (real charging cessation at the limit, wired + wireless, below/at/above threshold) and gets a
"Verified devices" row.
- **LineageOS** — the live adapter now **matches every LineageOS build that ships the `lineagesettings` provider**;
`QUALIFIED_CODENAMES` still 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 into `QUALIFIED_CODENAMES` only
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_STATUS` does **not** break the tie —
it is session-scoped, measured still reading `4` while 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 lineagehealth` binds
`ccprovider.Limit` (so the HAL *does* advertise the LIMIT mode bit again), `charging_control_mode` reads back
as `3` and is **not** coerced to `1`, and with `enabled=1 / mode=3 / limit=70` the 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**: raising `charging_control_charging_limit` to `80` resumed 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 to `70` and re-verified by read-back; `enabled`/`mode` were never written.
**This is NOT a GO and oriole is NOT in `QUALIFIED_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 %** with `charging_control_charging_limit` still reading `70` — 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 to `Tue 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 spoofs `Build.FINGERPRINT` to 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 via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
onOpenConditions = {},
onOpenBatteryHub = {},
onRetryCapture = {},
onStartVerification = {},
onPinWidget = {},
onAddTile = {},
onDismissQuickAccess = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ class ChargingPreferences @Inject constructor(
writer = { state -> json.encodeToString(PolicyState.serializer(), state) },
)

/**
* The build identity (see `charging/core/enforcement/BuildIdentity`) the user explicitly started
* enforcement verification for, or null. Its own key rather than a [PolicyState] field: it is
* written once by a deliberate user action and read on every adapter selection, so it must not
* wake the policy record's collectors — and it degrades independently of the protective baseline.
*
* Scoped to a build because that is what the evidence is scoped to: a ROM update re-opens the
* question, and the opt-in for the old build must not silently carry over to the new one.
*/
private val verificationStarted = dataStore.createValue(
key = stringPreferencesKey("enforcement.verification_started_for"),
reader = { raw -> raw as? String },
writer = { value -> value },
)

val verificationStartedFor: Flow<String?> = verificationStarted.flow

suspend fun verificationStartedForNow(): String? = verificationStarted.value()

/** Record the explicit "enable charge limiting anyway" opt-in for [buildIdentity]. */
suspend fun startVerification(buildIdentity: String) {
verificationStarted.value(buildIdentity)
}

// Each projection dedupes on its own: they all ride one record now, so without this a
// lastRequestedAt-only write would re-emit every one of them.
val lastRequested: Flow<ChargePolicy?> = policyState.flow
Expand Down
Loading