Skip to content

Commit 1ebb25f

Browse files
committed
Charging: Warn when the hardware never confirms a fixed limit
An ASYNC_HARDWARE settling window that expired without a hardware confirmation was indistinguishable from success: the spinner vanished and the dashboard read like the change applied. That silence hid the silent-failure class where the configured setting reads back fine while charging is never actually limited (the Settings Intelligence worker not running, a HAL ignoring the key). Adapters now declare when a hardware confirmation is EXPECTED (confirmationExpected): Pixel expects one only for a plugged fixed limit on a live, unmasked channel - state 4 spans the entire plugged fixed-limit session, while adaptive idles at the ambiguous state 1 and unrestricted maps to the same 1, and thermal states mask the signal. A pure detector (computeUnconfirmedTarget) surfaces the standing contradiction as ChargingState.unconfirmedTarget once a request is 30s old (2x the settling window) and the expectation is still unmet; an authoritative readback of a DIFFERENT configured policy - a native or competing change - obsoletes the request instead of warning about it. A 15s stability debounce (debounceUnconfirmed, carried across refreshes in the repository) damps the plug-in transient, where an old fixed-limit request legitimately reads state 1 for the ~12s HAL transition. The settle scheduler enqueues a second one-shot refresh at the threshold so the warning appears even when the failing HAL produces no broadcast of its own. The signal deliberately lives outside PendingRequest (mutating pending at expiry would loop the dashboard's deadline observer). Every apply publication - success, cancellation, metadata failure, write failure, and the unsupported/needs-setup refusals - clears it, so a stale warning can never sit over a new request or a state the detector's own contract excludes. Sync-readback and plug-latched adapters carry no expectation and never warn. The dashboard renders the contradiction as an error-tinted warning line; single sticky battery readout per refresh feeds the decode, the pending computation, and the expectation check.
1 parent 8d94d56 commit 1ebb25f

8 files changed

Lines changed: 455 additions & 15 deletions

File tree

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

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ data class ChargingState(
8585
val access: AccessSnapshot? = null,
8686
val observation: ChargeObservation = ChargeObservation.Unknown(R.string.charging_reason_loading.toCaString()),
8787
val pending: PendingRequest? = null,
88+
/**
89+
* Standing contradiction: the charging hardware was EXPECTED to confirm the last requested
90+
* policy (see [eu.darken.amply.charging.core.adapter.ChargingAdapter.confirmationExpected]) and
91+
* still has not, well past the settling window. Catches both a stuck apply and "plugged in
92+
* today, HAL never engaged" — the silent-failure class where the configured setting reads back
93+
* fine while charging is not actually limited. Null whenever no expectation exists.
94+
*/
95+
val unconfirmedTarget: ChargePolicy? = null,
8896
val busy: Boolean = false,
8997
// Set only while a Shizuku-driven WRITE_SECURE_SETTINGS grant is in flight, so the setup card can
9098
// show a progress cue on that specific action without conflating it with a policy apply (both busy).
@@ -150,6 +158,12 @@ class ChargingRepository @Inject constructor(
150158
private val batteryReader: BatteryReader,
151159
) {
152160
private val operationMutex = Mutex()
161+
162+
// Debounce carry-over for the hardware-unconfirmed detector (see debounceUnconfirmed). Only
163+
// touched inside refreshLocked, which always runs under operationMutex. Process death resets it —
164+
// the warning then just needs one more stability interval, never the unsafe direction.
165+
private var unconfirmedCandidate: ChargePolicy? = null
166+
private var unconfirmedSince: Long = 0L
153167
// Separate from operationMutex: the ~25s grant deliberately never holds operationMutex (see below),
154168
// but manual and automatic callers must still be single-flighted against each other.
155169
private val grantMutex = Mutex()
@@ -283,11 +297,15 @@ class ChargingRepository @Inject constructor(
283297
if (adapter == null || !selection.support.controlEnabled) {
284298
val detail = selection.support.detail.toCaString()
285299
val observation = ChargeObservation.Unsupported(detail)
286-
// Also drop the standing ready note: this branch means the gate just failed on a fresh
287-
// selection (a capability can vanish between refresh and tap), and the field's contract
288-
// is "null whenever control is unavailable" — keeping a stale "changes apply
289-
// immediately" under the failure reason would contradict it.
290-
mutableState.value = state.value.copy(observation = observation, message = detail, adapterDetail = null)
300+
// Also drop the standing ready note and any hardware-unconfirmed warning: this branch
301+
// means the gate just failed on a fresh selection (a capability can vanish between
302+
// refresh and tap), and both fields' contracts exclude Unsupported states.
303+
mutableState.value = state.value.copy(
304+
observation = observation,
305+
message = detail,
306+
adapterDetail = null,
307+
unconfirmedTarget = null,
308+
)
291309
return ApplyResult(false, observation, context.getString(selection.support.detail))
292310
}
293311
if (policy !in adapter.supportedPolicies) {
@@ -308,6 +326,8 @@ class ChargingRepository @Inject constructor(
308326
mutableState.value = state.value.copy(
309327
observation = observation,
310328
message = R.string.charging_message_setup_required.toCaString(),
329+
// NeedsSetup never warns (detector contract); drop a now-contradictory stale warning.
330+
unconfirmedTarget = null,
311331
)
312332
return ApplyResult(false, observation, "Setup required")
313333
}
@@ -340,10 +360,14 @@ class ChargingRepository @Inject constructor(
340360
log(TAG, Logging.Priority.ERROR) { "Settings write failed for ${policy.stableId}" }
341361
val observation = ChargeObservation.Unknown(R.string.charging_reason_write_failed.toCaString())
342362
// Clear any stale pending so the failure is not masked by a prior request's "applying…" cue.
363+
// The old unconfirmed warning goes too: a multi-key write can fail after partially changing
364+
// configuration, so the previous target is no longer a safe standing claim — the next
365+
// refresh recomputes from live evidence.
343366
mutableState.value = state.value.copy(
344367
busy = false,
345368
observation = observation,
346369
pending = null,
370+
unconfirmedTarget = null,
347371
message = R.string.charging_message_write_failed.toCaString(),
348372
)
349373
return ApplyResult(false, observation, "Write failed")
@@ -401,6 +425,10 @@ class ChargingRepository @Inject constructor(
401425
access = access,
402426
observation = observation,
403427
pending = pending,
428+
// A fresh write voids any prior contradiction: the detector re-arms via refresh once
429+
// the new request is past its own grace threshold. Stale warnings must never render
430+
// over a new request's settling phase (all three publication copies clear this).
431+
unconfirmedTarget = null,
404432
message = message,
405433
)
406434
// Always schedule an eventual surface re-push, even for a settled write (pending == null).
@@ -424,6 +452,7 @@ class ChargingRepository @Inject constructor(
424452
busy = false,
425453
observation = ChargeObservation.LastRequested(policy),
426454
pending = PendingRequest(policy, now, awaitingReplug = fallbackAwaitsReplug(adapter, pluggedAtWrite)),
455+
unconfirmedTarget = null,
427456
)
428457
settleScheduler.schedule(now)
429458
throw e
@@ -437,6 +466,7 @@ class ChargingRepository @Inject constructor(
437466
busy = false,
438467
observation = observation,
439468
pending = PendingRequest(policy, now, awaitingReplug = fallbackAwaitsReplug(adapter, pluggedAtWrite)),
469+
unconfirmedTarget = null,
440470
message = message,
441471
)
442472
settleScheduler.schedule(now)
@@ -448,6 +478,13 @@ class ChargingRepository @Inject constructor(
448478
val selection: AdapterSelection = registry.select()
449479
val access = accessResolver.snapshot()
450480
val adapter = selection.adapter
481+
// ONE sticky observation for everything hardware-derived this refresh — plug state, the
482+
// hardware decode, and the confirmation expectation must join on the same readout
483+
// (BatteryReader's doc: never pair plug state across two sticky reads). Behavior-identical
484+
// to the previous per-call adapter.readHardware(context): a missing extra decodes as
485+
// INVALID(0) and an unreadable broadcast as unplugged, both yielding the same results.
486+
val battery = batteryReader.read()
487+
val hardware = adapter?.decodeHardware(battery.chargingStatus ?: 0, battery.onCharger)
451488
val observation = when {
452489
adapter == null -> ChargeObservation.Unsupported(selection.support.detail.toCaString())
453490
!selection.support.controlEnabled -> ChargeObservation.Unsupported(selection.support.detail.toCaString())
@@ -465,7 +502,7 @@ class ChargingRepository @Inject constructor(
465502
// A readable-but-unrecognized OEM value must not be masked by a stale last
466503
// request — the state is genuinely unknown, and a session start refuses on it.
467504
read is ChargeObservation.Unknown && read.unrecognizedValue -> read
468-
else -> adapter.readHardware(context)
505+
else -> hardware
469506
?: preferences.lastRequestedNow()?.let(ChargeObservation::LastRequested)
470507
?: read
471508
?: ChargeObservation.Unknown(R.string.charging_reason_state_unavailable.toCaString())
@@ -476,29 +513,44 @@ class ChargingRepository @Inject constructor(
476513
// or WSS the settings readback is `Verified` while the HAL is still converging, so pending would
477514
// otherwise never clear until the window expired.
478515
val latches = adapter?.policyLatchesAtPlug == true
479-
val battery = if (latches) batteryReader.read() else null
480516
// Plug-latched adapters: an observed unpowered moment durably resolves an unresolved latched
481517
// request — the plug session that sampled the old value is over, so the next one samples the
482518
// new value. Persisted as a watermark so a later *plugged* refresh still knows it happened.
483-
if (latches && battery?.plugged == 0 &&
519+
if (latches && battery.plugged == 0 &&
484520
preferences.lastRequestedPluggedNow() == true &&
485521
preferences.unpluggedSeenAtNow() <= preferences.lastRequestedAtNow()
486522
) {
487523
preferences.recordUnpluggedSeen(System.currentTimeMillis())
488524
}
525+
val reqPolicy = preferences.lastRequestedNow()
526+
val reqAt = preferences.lastRequestedAtNow()
527+
val now = System.currentTimeMillis()
489528
val pending = computeRefreshPending(
490-
reqPolicy = preferences.lastRequestedNow(),
491-
reqAt = preferences.lastRequestedAtNow(),
492-
now = System.currentTimeMillis(),
529+
reqPolicy = reqPolicy,
530+
reqAt = reqAt,
531+
now = now,
493532
observation = observation,
494-
hardware = adapter?.readHardware(context),
533+
hardware = hardware,
495534
verification = adapter?.verification ?: VerificationStrategy.ASYNC_HARDWARE,
496535
policyLatchesAtPlug = latches,
497536
reqPlugged = if (latches) preferences.lastRequestedPluggedNow() else null,
498537
unpluggedSeenAt = if (latches) preferences.unpluggedSeenAtNow() else 0L,
499538
battery = battery,
500539
limitPercent = adapter?.latchedLimitPercent() ?: NO_LATCHED_LIMIT,
501540
)
541+
val rawUnconfirmed = computeUnconfirmedTarget(
542+
reqPolicy = reqPolicy,
543+
reqAt = reqAt,
544+
now = now,
545+
observation = observation,
546+
hardware = hardware,
547+
confirmationExpected = reqPolicy != null &&
548+
adapter?.confirmationExpected(reqPolicy, battery.chargingStatus, battery.onCharger) == true,
549+
)
550+
val debounce = debounceUnconfirmed(rawUnconfirmed, now, unconfirmedCandidate, unconfirmedSince)
551+
unconfirmedCandidate = debounce.candidate
552+
unconfirmedSince = debounce.sinceMillis
553+
val unconfirmedTarget = debounce.surfaced
502554
val built = ChargingState(
503555
device = DeviceInfo.current(context),
504556
adapterName = adapter?.displayName ?: R.string.adapter_name_unsupported.toCaString(),
@@ -522,6 +574,7 @@ class ChargingRepository @Inject constructor(
522574
access = access,
523575
observation = observation,
524576
pending = pending,
577+
unconfirmedTarget = unconfirmedTarget,
525578
busy = false,
526579
// grantingWss is intentionally left default here; mergeRefreshedState (below) carries an
527580
// in-flight grant's spinner over from the previous state so a concurrent refresh can't clear it.
@@ -676,6 +729,69 @@ internal fun computeRefreshPending(
676729
return PendingRequest(reqPolicy, reqAt)
677730
}
678731

732+
/**
733+
* Standing contradiction detector behind [ChargingState.unconfirmedTarget]: the last requested policy
734+
* was EXPECTED to be hardware-confirmed (see ChargingAdapter.confirmationExpected — live channel,
735+
* reliably-reported policy class, nothing masking) and still is not, well past the settling window.
736+
*
737+
* No pending interplay is needed: the threshold exceeds the settling window, so a windowed pending has
738+
* always expired by the time this can fire, and plug-latched pendings belong to adapters whose
739+
* expectation is false by default. The threshold's slack (2× the window vs the measured ~11–12s Pixel
740+
* HAL transition) keeps a merely-slow transition from flickering a warning; `now < reqAt` (backwards
741+
* clock) falls under the same guard.
742+
*/
743+
internal fun computeUnconfirmedTarget(
744+
reqPolicy: ChargePolicy?,
745+
reqAt: Long,
746+
now: Long,
747+
observation: ChargeObservation,
748+
hardware: ChargeObservation?,
749+
confirmationExpected: Boolean,
750+
): ChargePolicy? {
751+
if (reqPolicy == null || reqAt <= 0L) return null
752+
if (observation is ChargeObservation.Unsupported || observation is ChargeObservation.NeedsSetup) return null
753+
// An authoritative readback of a DIFFERENT configured policy means a competing/native change
754+
// already replaced the request — warning that the obsolete request "may not be applying" would
755+
// contradict the very policy the card above verifies. Same for a readable-but-unrecognized
756+
// value (the state a session start refuses on). A readback verifying the REQUESTED policy while
757+
// the hardware disagrees is exactly the contradiction this detector exists for.
758+
if (observation is ChargeObservation.Verified && observation.policy != reqPolicy) return null
759+
if (observation is ChargeObservation.Unknown && observation.unrecognizedValue) return null
760+
if (now - reqAt < UNCONFIRMED_THRESHOLD_MILLIS) return null
761+
if (!confirmationExpected) return null
762+
if (hardwareConfirms(hardware, reqPolicy)) return null
763+
return reqPolicy
764+
}
765+
766+
/**
767+
* Stability debounce over the raw detector output: the contradiction must hold across refreshes for
768+
* [UNCONFIRMED_STABILITY_MILLIS] before it surfaces. Damps the plug-in transient — a device plugged
769+
* in with an OLD fixed-limit request legitimately reads state 1 for the ~11–12s HAL transition, and
770+
* the age threshold alone (measured from the write, not the plug) would warn instantly. Pure:
771+
* callers thread the previous (candidate, sinceMillis) pair through.
772+
*/
773+
internal fun debounceUnconfirmed(
774+
candidate: ChargePolicy?,
775+
now: Long,
776+
prevCandidate: ChargePolicy?,
777+
prevSince: Long,
778+
): UnconfirmedDebounce {
779+
if (candidate == null) return UnconfirmedDebounce(null, 0L, surfaced = null)
780+
// A changed candidate — or a backwards clock, which voids the stability evidence — restarts.
781+
val since = if (candidate == prevCandidate && now >= prevSince) prevSince else now
782+
val surfaced = candidate.takeIf { now - since >= UNCONFIRMED_STABILITY_MILLIS }
783+
return UnconfirmedDebounce(candidate, since, surfaced)
784+
}
785+
786+
internal data class UnconfirmedDebounce(
787+
val candidate: ChargePolicy?,
788+
val sinceMillis: Long,
789+
val surfaced: ChargePolicy?,
790+
)
791+
792+
internal const val UNCONFIRMED_THRESHOLD_MILLIS = 2 * SETTLING_WINDOW_MILLIS
793+
internal const val UNCONFIRMED_STABILITY_MILLIS = SETTLING_WINDOW_MILLIS
794+
679795
/** Sentinel for "no latched fixed-limit cap": no observable percent can exceed it, disabling limit-disproof. */
680796
internal const val NO_LATCHED_LIMIT = 100
681797

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ interface ChargingAdapter {
7676
*/
7777
val policyLatchesAtPlug: Boolean get() = false
7878

79+
/**
80+
* Whether the charging hardware is currently EXPECTED to confirm [policy]: the policy class is
81+
* one this hardware reliably reports, the evidence channel is live (plugged — unplugged sticky
82+
* values are stale), and nothing is masking the signal (thermal throttling). Drives the
83+
* "hardware never confirmed" warning: expected-and-missing is the contradiction worth showing.
84+
* False by default — sync-readback adapters verify via settings and plug-latched adapters
85+
* legitimately confirm only at the next plug session, so neither carries an expectation.
86+
*/
87+
fun confirmationExpected(policy: ChargePolicy, chargingStatus: Int?, plugged: Boolean): Boolean =
88+
false
89+
7990
fun probe(device: DeviceInfo): AdapterSupport
8091
fun readHardware(context: Context): ChargeObservation? = null
8192
fun decodeHardware(chargingState: Int, plugged: Boolean): ChargeObservation? = null

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ class PixelChargingAdapter @Inject constructor() : ChargingAdapter {
126126
return apply(policy, backend)
127127
}
128128

129+
/**
130+
* A fixed limit is the only policy the hardware reliably reports: state 4 stays set for the
131+
* ENTIRE plugged fixed-limit session, even far below the cap (see [eu.darken.amply.stats.core.
132+
* StatsLimitHitDetector]). Adaptive is deliberately excluded — an idle adaptive profile reads
133+
* NORMAL(1), so its absence proves nothing — and Unrestricted maps to the same ambiguous 1.
134+
* States 1/4/5 all count as a live, unmasked channel: 4 is "expected and delivered" (the
135+
* confirms check clears it), 5 while a fixed limit was requested is a real contradiction worth
136+
* warning about. Thermal (2/3) masks the policy and invalid/unrecognized values prove nothing.
137+
*/
138+
override fun confirmationExpected(policy: ChargePolicy, chargingStatus: Int?, plugged: Boolean): Boolean =
139+
plugged &&
140+
policy == ChargePolicy.FixedLimit(80) &&
141+
chargingStatus in setOf(CHARGING_STATE_NORMAL, CHARGING_STATE_LONG_LIFE, CHARGING_STATE_ADAPTIVE)
142+
129143
override fun nativeSettingsIntent(context: Context): Intent {
130144
val specific = Intent().setComponent(
131145
ComponentName(

app/src/main/java/eu/darken/amply/main/core/WorkManagerSettleScheduler.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.work.WorkManager
77
import dagger.hilt.android.qualifiers.ApplicationContext
88
import eu.darken.amply.charging.core.SETTLING_WINDOW_MILLIS
99
import eu.darken.amply.charging.core.SettleScheduler
10+
import eu.darken.amply.charging.core.UNCONFIRMED_THRESHOLD_MILLIS
1011
import java.util.concurrent.TimeUnit
1112
import javax.inject.Inject
1213
import javax.inject.Singleton
@@ -23,19 +24,34 @@ class WorkManagerSettleScheduler @Inject constructor(
2324
) : SettleScheduler {
2425

2526
override fun schedule(requestedAtMillis: Long) {
26-
val fireAt = requestedAtMillis + SETTLING_WINDOW_MILLIS + CLEAR_BUFFER_MILLIS
27-
val delay = (fireAt - System.currentTimeMillis()).coerceAtLeast(0L)
27+
enqueue(
28+
SettleRefreshWorker.UNIQUE_NAME,
29+
requestedAtMillis + SETTLING_WINDOW_MILLIS + CLEAR_BUFFER_MILLIS,
30+
)
31+
// Second, separate push at the hardware-unconfirmed threshold: the silent-failure case this
32+
// detector exists for (no HAL transition) may produce no charging-status broadcast, and the
33+
// dashboard's own deadline observer disarms when pending clears at the window — without this
34+
// the warning could wait indefinitely for an unrelated refresh trigger.
35+
enqueue(
36+
UNCONFIRMED_UNIQUE_NAME,
37+
requestedAtMillis + UNCONFIRMED_THRESHOLD_MILLIS + CLEAR_BUFFER_MILLIS,
38+
)
39+
}
40+
41+
private fun enqueue(uniqueName: String, fireAtMillis: Long) {
42+
val delay = (fireAtMillis - System.currentTimeMillis()).coerceAtLeast(0L)
2843
val request = OneTimeWorkRequestBuilder<SettleRefreshWorker>()
2944
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
3045
.build()
3146
WorkManager.getInstance(context).enqueueUniqueWork(
32-
SettleRefreshWorker.UNIQUE_NAME,
47+
uniqueName,
3348
ExistingWorkPolicy.REPLACE,
3449
request,
3550
)
3651
}
3752

3853
private companion object {
3954
const val CLEAR_BUFFER_MILLIS = 1_000L
55+
const val UNCONFIRMED_UNIQUE_NAME = "${SettleRefreshWorker.UNIQUE_NAME}_unconfirmed"
4056
}
4157
}

0 commit comments

Comments
 (0)