Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7dfe02c
Charging: Add the guided qualification run core
d4rken Aug 18, 2026
9886357
Charging: Harden the qualification run against false verdicts
d4rken Aug 18, 2026
df9fb51
Charging: Stop charge rules from acting during a qualification run
d4rken Aug 18, 2026
8d1faf6
Charging: Treat a qualification pass that licenses nothing as no pass
d4rken Aug 18, 2026
a81c110
Charging: Anchor the qualification run's measurement and its ownershi…
d4rken Aug 18, 2026
a839bad
Charging: Invalidate qualification passes from the superseded protocol
d4rken Aug 18, 2026
9f7bb90
Charging: Release the qualification finalization claim instead of lat…
d4rken Aug 18, 2026
46b8ccc
Charging: Drop the unreachable baseline phase-budget branch
d4rken Aug 18, 2026
0f5af7b
Charging: Keep a qualification finalization honest across cancellatio…
d4rken Aug 18, 2026
48c5273
Qualification: Scale the baseline progress bar to the baseline window
d4rken Aug 18, 2026
ab59ba7
Charging: Recover a qualification claim its own release could not giv…
d4rken Aug 18, 2026
bb4009c
Charging: Replay an interrupted qualification finalization instead of…
d4rken Aug 18, 2026
b0af59d
Charging: Stamp replayed qualification evidence with the versions tha…
d4rken Aug 19, 2026
9601310
Charging: Restore a qualification baseline only while the run still o…
d4rken Aug 19, 2026
6905a1b
Charging: Pin an unstamped qualification record to the version that m…
d4rken Aug 19, 2026
b7f29e6
Charging: Refuse boot recovery of a recovery target a live run owns
d4rken Aug 19, 2026
bafcdde
Charging: Hand a failed qualification close-out back to boot recovery
d4rken Aug 19, 2026
ab54721
Charging: Keep the service alive for an owed restore instead of racin…
d4rken Aug 19, 2026
cfca5da
Qualification: Show the numbers behind the pre-check verdict
d4rken Aug 19, 2026
9e28c53
Qualification: Stop the pre-check block from stating what it has not …
d4rken Aug 19, 2026
fda0f85
Qualification: Poll the battery for the screen, not for the wizard step
d4rken Aug 19, 2026
8bfff5a
Qualification: Give the wizard a card structure instead of stacked pa…
d4rken Aug 19, 2026
105af4f
Qualification: End the run truthfully when the plug signal vanishes a…
d4rken Aug 19, 2026
d412086
Qualification: Bound the plug-mask window below as well as above
d4rken Aug 19, 2026
14229a3
Qualification: Say what the plug-loss result actually observed
d4rken Aug 19, 2026
4322262
Qualification: Only claim the restore the close-out actually made
d4rken Aug 19, 2026
7a00a42
Qualification: Don't state a phase's write until it is acknowledged
d4rken Aug 19, 2026
c6f2a13
Qualification: Say what the close-out did to the charge setting, not …
d4rken Aug 19, 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
4 changes: 3 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ 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
- `charging/core/qualification` — the guided qualification run: an *active* cut → resume → cut challenge that
proves the charging hardware obeys a cap, where the enforcement gate can only ever refute one
- `fullcharge/core` — temporary sessions, boot recovery, reconnect gesture
- `main/ui` — activity, onboarding, dashboard, settings, setup guide, `tile`, `widget`
- `main/ui` — activity, onboarding, dashboard, settings, setup guide, `qualification`, `tile`, `widget`
- `diagnostics/core` + `diagnostics/ui` — "Help add support" contribution wizard: read-only multi-mode setting
discovery + on-device privacy review
- `common` — shared DataStore owner (`AppDataStore`) and cross-feature primitives
Expand Down
55 changes: 55 additions & 0 deletions .claude/rules/privileged-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,61 @@ ticks, persisted by `EnforcementEvidenceStore`. Three properties are load-bearin
- 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.

## Guided Qualification Run

`charging/core/qualification/` is the **active** counterpart to the passive gate above: instead of watching, it
drives the cap and watches the hardware answer. Behind `BuildConfig.ENABLE_QUALIFICATION_RUN` (debug/beta only
until a real device pass). `QualificationRunEngine`'s cut → resume → cut sequence is the "guided two-cap
challenge" `EnforcementVerdictEngine`'s KDoc names as the only way to earn a real confirmation.

Rules that must not be relaxed:

- **A phase timeout is `INCONCLUSIVE`, never `REFUTED`.** Refutation stays reachable only from an observed climb
past the cap, with the same `OVERSHOOT_ALLOWANCE` the passive engine uses. A cold room or a weak charger must
never permanently disable control on a working device.
- **A run may not refute a mapping it guessed.** On a candidate adapter (PR 2; the engine already implements it)
the commanded value is an assumption — a One UI 6/7 device whose `protect_battery` means "cap at 85" charges
past a commanded 80 while enforcing perfectly. That is `CAP_MISMATCH` plus the observed hold level, never a
refutation.
- **A pass is stored in its own record**, `qualification.result.v1`, never as a constant in `EnforcementVerdict`.
The two record different things — passive observation versus a driven experiment with a protocol version, a run
shape and a measurement signal — and only one of them can ever be positive. (An earlier version of this note
claimed a positive constant there would be outright fail-open via a record that lost `algorithmVersion`; that
was overstated, since such a record decodes as version 0 and is then scoped out by the algorithm-version check.
The separation stands on the domain difference, not on that argument.) Bumping `ALGORITHM_VERSION` instead
would be actively unsafe: `scope()` would read every existing version-2 record as `Absent`, silently
un-refuting every already-refuted device.
The new record's fail-closed guard is an explicit `protocolVersion` **plus a credibility check** — a one-constant
*positive* enum has no safe default, so a record carrying only a matching build and protocol version would
otherwise decode as a pass with no adapter, cap, signal or exercised policies. Its `Corrupt` state means **not
qualified** — the opposite direction from the enforcement store's, and the same principle: the unreadable state
is the restrictive one.
- **A pass licenses only the policies the run exercised** (`AdapterSupport.licensedPolicies`), enforced at both
the display and the write path. A run writes two policies and proves nothing about the adapter's others, which
matters most on a candidate device where the value mapping is itself a guess.
- **Every phase is judged against the run's own baseline rate**, never an absolute threshold. A fixed
"charging has stopped" bar is simultaneously too high for a weak supply — a phone charging steadily at 100 mA
sits under it forever and reads as held, which is a false pass — and meaningless on a battery whose capacity or
reporting units are unknown. Do not reintroduce one.
- **`EnforcementStatus.SELF_QUALIFIED`, not `CONFIRMED`.** `CONFIRMED` means a maintainer physically qualified the
device and earned a ledger row; a local pass is one user's device, one build, reset by an OTA. A refutation
still outranks it.
- **`ChargingRepository.applyForQualification` requires a run token** matching a live `QualificationRunStore`
record. It is ungated for the same reason `restorePersistent` is, and the token is what stops that from
becoming a general bypass. Its writes are non-persistent, so the user's protective baseline and the reconnect
gesture's arming basis survive the run.
- **The restore is registered before the first write**, as a `FullChargeStore` recovery target with
`RecoveryOrigin.SESSION_RESTORE`, so process death and reboot are covered by the shipped boot recovery rather
than anything new.
- **No write-allowlist change, ever, for this feature.** Every key and value a run writes is already in
`SettingWritePolicy` / `LineageSettingWritePolicy`. If a future adapter needs a new one, that is a boundary
change reviewed on its own merits, not a qualification-run change.

Excluded adapters and why: `policyLatchesAtPlug` (GrapheneOS, a future HONOR adapter) — mid-run writes have no
hardware effect until a replug, so the sequence is structurally impossible; Adaptive-only adapters (Xiaomi
HyperOS 2) — `enforcementIsConditional`, nothing to challenge on demand; Pixel — already maintainer-qualified with
a real hardware signal.

## Foreground Service Requirement

The temporary override uses a `specialUse` foreground service because dormant apps cannot reliably receive
Expand Down
17 changes: 17 additions & 0 deletions .claude/skills/device-qualification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ commit device serials or user data. Requalify after a relevant OS / OEM-componen
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
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ android {
}

buildTypes {
// ENABLE_QUALIFICATION_RUN gates the guided qualification run (charging/core/qualification).
// It is the first feature that deliberately writes charge settings on a build whose hardware
// Amply has never seen honour a cap, so it rides the debug/beta channels until a real device
// pass. Flip the release value to true once that has happened.
debug {
isMinifyEnabled = false
buildConfigField("boolean", "ENABLE_QUALIFICATION_RUN", "true")
}
create("beta") {
lint {
Expand All @@ -91,6 +96,7 @@ android {
}
isMinifyEnabled = true
isShrinkResources = true
buildConfigField("boolean", "ENABLE_QUALIFICATION_RUN", "true")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
Expand All @@ -103,6 +109,7 @@ android {
}
isMinifyEnabled = true
isShrinkResources = true
buildConfigField("boolean", "ENABLE_QUALIFICATION_RUN", "false")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/eu/darken/amply/charging/core/ChargePolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,17 @@ data class ApplyResult(
val observation: ChargeObservation,
val message: String,
)

/**
* What became of a qualification run's baseline restore — see
* `ChargingRepository.restoreQualificationBaselineIfOwned`.
*
* Deliberately **not** an [ApplyResult] with a success flag: "no write was made because this run no
* longer owes the restore" and "a write was made and failed" must not collapse into one answer. The
* first is the run finishing correctly and leaving a newer choice alone; the second leaves a policy
* the user is still owed, and the recovery target has to stay behind for it.
*/
internal sealed interface QualificationRestoreOutcome {
data class Applied(val result: ApplyResult) : QualificationRestoreOutcome
data object Superseded : QualificationRestoreOutcome
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import eu.darken.amply.charging.core.enforcement.BuildIdentitySource
import eu.darken.amply.charging.core.enforcement.EnforcementEvidenceState
import eu.darken.amply.charging.core.enforcement.EnforcementEvidenceStore
import eu.darken.amply.charging.core.enforcement.EnforcementStatus
import eu.darken.amply.charging.core.qualification.QualificationEvidenceState
import eu.darken.amply.charging.core.qualification.QualificationEvidenceStore
import eu.darken.amply.charging.core.qualification.QualificationRunStore
import eu.darken.amply.common.ca.CaString
import eu.darken.amply.common.ca.caString
import eu.darken.amply.common.ca.toCaString
import eu.darken.amply.common.debug.logging.Logging
import eu.darken.amply.common.debug.logging.log
import eu.darken.amply.common.debug.logging.logTag
import eu.darken.amply.fullcharge.core.FullChargeStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
Expand Down Expand Up @@ -171,6 +175,9 @@ class ChargingRepository @Inject constructor(
private val settleScheduler: SettleScheduler,
private val batteryReader: BatteryReader,
private val evidenceStore: EnforcementEvidenceStore,
private val qualificationStore: QualificationEvidenceStore,
private val runStore: QualificationRunStore,
private val fullChargeStore: FullChargeStore,
private val buildIdentity: BuildIdentitySource,
) {
private val operationMutex = Mutex()
Expand Down Expand Up @@ -242,6 +249,76 @@ class ChargingRepository @Inject constructor(
applyLocked(policy, persistent = true, forceNotify = forceNotify, evidenceGated = false)
}

/**
* The baseline restore that closes a qualification run out, written **only while that run still
* owns the outstanding restore obligation**.
*
* The restore is replayable: a finalization can be interrupted after it restored and cleared its
* recovery target, and then run again in a later process. Between the two the user can make an
* explicit persistent choice — the widget's fixed-limit/unrestricted buttons are not refused
* while a run record exists, and `setPersistentPolicy` clears its own recovery target once its
* write lands. Replaying the restore unconditionally would silently revert that choice to a
* baseline nobody is owed any more, with nothing left behind to bring it back.
*
* Ownership of the single recovery slot is the proxy for "does this run still owe a restore",
* and it is exact in both directions: a first attempt that never reached the restore still owns
* the target, a failed restore leaves it owned (finalization deliberately does not clear it),
* while a completed restore — or any newer producer having taken the slot — does not.
*
* The check runs **inside [operationMutex], the same lock as the write**, which is what makes it
* a decision rather than a guess: `setPersistentPolicy` registers its new recovery record
* *before* entering this mutex, so either the replay's restore completes first and the user's
* write lands on top of it, or the replay sees the newer owner and skips. Checked outside the
* lock, the two could interleave and the stale baseline would win.
*
* Ungated exactly like [restorePersistent]: this is a policy the user already had.
*/
internal suspend fun restoreQualificationBaselineIfOwned(
runId: String,
policy: ChargePolicy,
): QualificationRestoreOutcome = operationMutex.withLock {
val owner = fullChargeStore.currentRecovery()?.workId
if (owner != runId) {
log(TAG, Logging.Priority.INFO) {
"Qualification restore for $runId skipped: the owed restore now belongs to $owner"
}
return@withLock QualificationRestoreOutcome.Superseded
}
QualificationRestoreOutcome.Applied(
applyLocked(policy, persistent = true, forceNotify = true, evidenceGated = false),
)
}

/**
* A write commanded by a guided qualification run. Ungated for the same reason as
* [restorePersistent] — the tier is exactly the question the run exists to answer, so requiring it
* first would make the run unable to run on any device that needs it — but with two extra guards
* that [restorePersistent] does not need:
*
* - **[runToken] must match a live run record.** Without a run in flight this path does not exist,
* which is what keeps an ungated write from becoming a general bypass of the enforcement gate.
* The token is generated per run and never leaves the process except into that record.
* - **`persistent = false`.** `ChargingPreferences.recordRequested` only writes `protective` and
* `lastPersistent` for persistent requests, so a run cycling through policies never disturbs the
* user's protective baseline or the reconnect gesture's any-level arming basis. Only the final
* restore, which goes through [restorePersistent], touches those.
*
* `forceNotify` routes through `ChargingAdapter.reapply`: the second cut writes a value that may
* already be configured, and a same-value write does not re-trigger every OEM's observer.
*/
internal suspend fun applyForQualification(policy: ChargePolicy, runToken: String): ApplyResult =
operationMutex.withLock {
val live = runStore.currentRun()
if (live == null || live.runToken.isBlank() || live.runToken != runToken) {
log(TAG, Logging.Priority.WARN) { "applyForQualification refused: no live run for this token" }
val observation = ChargeObservation.Unsupported(
R.string.charging_reason_qualification_not_running.toCaString(),
)
return@withLock ApplyResult(false, observation, "No qualification run in progress")
}
applyLocked(policy, persistent = false, forceNotify = true, evidenceGated = false)
}

suspend fun requestShizukuPermission(): Boolean {
val result = runCatching { shizukuController.requestPermission() }.getOrDefault(false)
refresh(
Expand Down Expand Up @@ -301,7 +378,10 @@ class ChargingRepository @Inject constructor(
* produce is `controlEnabled = false`, which none of them reads.
*/
private fun capabilityAdapter(): ChargingAdapter? =
registry.select(evidenceState = EnforcementEvidenceState.Loading).adapter
registry.select(
evidenceState = EnforcementEvidenceState.Loading,
qualification = QualificationEvidenceState.Loading,
).adapter

/**
* Never null: a device with no adapter still gets the generic battery-settings chain. Returning null here made
Expand Down Expand Up @@ -362,7 +442,11 @@ class ChargingRepository @Inject constructor(
)
return ApplyResult(false, observation, context.getString(selection.support.detail))
}
if (policy !in adapter.supportedPolicies) {
// The licensed set, not the adapter's raw one: a self-qualified tier proves only the policies
// its run exercised, and the display narrowing above would be cosmetic if a write could still
// reach the others.
val writable = selection.support.licensedPolicies ?: adapter.supportedPolicies
if (policy !in writable) {
val observation = ChargeObservation.Unsupported(
caString {
it.getString(
Expand Down Expand Up @@ -537,6 +621,7 @@ class ChargingRepository @Inject constructor(
registry.select(
device = device,
evidenceState = evidenceStore.currentState(),
qualification = qualificationStore.currentState(),
verificationStarted = preferences.verificationStartedForNow() == buildIdentity.current(),
)

Expand All @@ -547,7 +632,7 @@ class ChargingRepository @Inject constructor(
* the tier decides.
*/
private fun selectForRestore(device: DeviceInfo = DeviceInfo.current(context)): AdapterSelection {
val adapter = registry.select(device, EnforcementEvidenceState.Loading).adapter
val adapter = registry.select(device, EnforcementEvidenceState.Loading, QualificationEvidenceState.Loading).adapter
?: return AdapterSelection(
adapter = null,
support = AdapterSupport(
Expand Down Expand Up @@ -650,7 +735,10 @@ class ChargingRepository @Inject constructor(
device = DeviceInfo.current(context),
adapterName = adapter?.displayName ?: R.string.adapter_name_unsupported.toCaString(),
adapterId = adapter?.id,
supportedPolicies = adapter?.supportedPolicies.orEmpty(),
// Narrowed by the support decision where a tier licenses only some of them (a guided-run
// pass covers the two policies it exercised, not the adapter's whole set).
supportedPolicies = selection.support.licensedPolicies
?: adapter?.supportedPolicies.orEmpty(),
defaultProtectivePolicy = adapter?.defaultProtectivePolicy,
reconnectSupported = adapter?.reconnectGestureSupported == true,
syncVerification = adapter?.verification == VerificationStrategy.SYNC_READBACK,
Expand Down
Loading