@@ -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. */
680796internal const val NO_LATCHED_LIMIT = 100
681797
0 commit comments