Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ class ChargeSessionService : Service() {
// Gesture inactive: keep running only if a watcher still wants the service, showing the
// quiet monitoring notification instead of the gesture cue.
if (anyWatcherEnabled()) {
// Only stopMonitoring() resets the engine, so a watcher keeping this instance alive
// across a disable/re-enable cycle would otherwise resume on stale gesture state
// (a latched basis or an open reconnect window from before the disable).
quickGesture.reset()
startAsForeground(SessionNotifications.monitoring(this))
} else {
stopMonitoring()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,29 @@ enum class PolicyEvidence { PROTECTIVE, UNRESTRICTED, UNKNOWN }
/**
* Detects a deliberate unplug/replug gesture that starts a one-time full charge.
*
* The engine is a three-state machine — `Idle`, `Armed(basis)`, `AwaitingReconnect(basis, since)` —
* plus the orthogonal plug-edge memory [previousPlugged]. The arming basis is *carried* through the
* unplugged gap by `AwaitingReconnect`, and a replug that is merely mistimed hands it back to
* `Armed` instead of discarding it. That carry-over is what makes retrying possible: a physically
* replugged phone reports `CHARGING` while it tops back up, so re-deriving the basis from the replug
* reading alone can never reconstruct a limit hold, and every retry after a missed window used to be
* inert. A rejected sub-[minReconnectMillis] gap is deliberately treated as a *non-event*: the basis
* returns to the ordinary plugged-period latch and from there persists exactly as long as a freshly
* observed hold would. Carrying no timestamp of its own, it is subject to the same retirement paths
* as any latched basis — the out-of-band check below, an any-level revocation, expiry of a reconnect
* window it later opens, a replug that re-derives to nothing, [reset], or consuming a trigger. What
* it is *not* bound by is the original reconnect window; that is the point (see `a retry long after
* the original unplug still triggers`).
*
* Two arming bases exist:
* - Limit hold (default): Android's charging-policy hardware state reports the Pixel policy actively
* holding near its limit. Only the hardware signal is trusted, never Amply's cached request.
* - Any level (opt-in): the user enabled the any-level option and the current charge configuration
* is conclusively protective ([PolicyEvidence.PROTECTIVE]); percent, battery status, and the
* hardware hold are deliberately ignored. This basis is revoked — including an already-open
* reconnect window — by an explicit opt-out or by *conclusive* [PolicyEvidence.UNRESTRICTED]
* evidence, so an opt-out can never produce a trigger.
* evidence, so an opt-out can never produce a trigger. Revocation runs before the plug edges, so a
* revoked basis is never carried over by a mistimed replug either.
*
* [PolicyEvidence.UNKNOWN] is tolerated **only** on an unplugged tick or while a reconnect window is
* open — that is the one place the strongest evidence is structurally unavailable, because the
Expand All @@ -38,8 +53,18 @@ enum class PolicyEvidence { PROTECTIVE, UNRESTRICTED, UNKNOWN }
* (a natively-removed limit reads as UNKNOWN, not UNRESTRICTED, on a journal-less device). Dropping
* costs nothing — the basis re-arms on the very next tick that reports protective evidence again.
*
* A latched *limit-hold* basis is retired by any readable percent outside the arming band. This is
* defence in depth for a **pre-existing** gap, not a consequence of the carry-over: the
* steady-plugged branch has never dropped a latched basis, so a limit removed in system settings
* left the gesture armed while the battery climbed past the band. The check runs before the plug
* edges, so an out-of-band reading also cancels an already-open reconnect window. It is deliberately
* narrow: an any-level basis is percent-independent by design and would lose windows it may
* legitimately hold, and an unreadable percent (`< 0`) retires nothing, so one failed
* sticky-broadcast read cannot disarm a healthy gesture.
*
* The reconnect window has a debounce floor: a disconnect shorter than [minReconnectMillis] never
* triggers, filtering momentary power cuts (car ignition, connector jostle). Timestamps must come
* triggers, filtering momentary power cuts (car ignition, connector jostle); such a replug returns to
* `Armed` with the carried basis, so the next deliberate attempt can fire. Timestamps must come
* from `SystemClock.elapsedRealtime()` so wall-clock changes cannot distort the window.
*/
class QuickFullChargeGesture(
Expand Down Expand Up @@ -69,9 +94,21 @@ class QuickFullChargeGesture(

private enum class ArmedBy { LIMIT_HOLD, ANY_LEVEL }

private sealed interface State {
data object Idle : State
data class Armed(val basis: ArmedBy) : State
data class AwaitingReconnect(val basis: ArmedBy, val sinceMillis: Long) : State
}

private val State.armingBasis: ArmedBy?
get() = when (this) {
State.Idle -> null
is State.Armed -> basis
is State.AwaitingReconnect -> basis
}

private var previousPlugged: Boolean? = null
private var armedBy: ArmedBy? = null
private var disconnectedAtMillis: Long? = null
private var state: State = State.Idle

fun update(input: Input): Output {
val heldAtLimit = input.plugged &&
Expand All @@ -85,81 +122,113 @@ class QuickFullChargeGesture(
// An any-level basis is dropped by an explicit opt-out, by conclusive evidence that charging
// is unrestricted, or by inconclusive evidence on a tick where conclusive evidence was
// available (plugged, no open window) — a natively-removed limit reads UNKNOWN, not
// UNRESTRICTED, on a journal-less device. The `disconnectedAtMillis == null` guard is
// load-bearing: this block runs before the replug edge is handled, so without it a replug
// tick whose hardware has not re-reported its hold yet would destroy its own trigger.
// UNRESTRICTED, on a journal-less device. The "not mid-window" guard is load-bearing: this
// block runs before the replug edge is handled, so without it a replug tick whose hardware
// has not re-reported its hold yet would destroy its own trigger.
// A latched limit-hold basis survives option flips: its evidence was the (momentary)
// hardware hold, which is mode-independent.
if (armedBy == ArmedBy.ANY_LEVEL &&
if (state.armingBasis == ArmedBy.ANY_LEVEL &&
(!input.anyLevelEnabled ||
input.policyEvidence == PolicyEvidence.UNRESTRICTED ||
(input.policyEvidence == PolicyEvidence.UNKNOWN &&
input.plugged &&
disconnectedAtMillis == null))
state !is State.AwaitingReconnect))
) {
armedBy = null
disconnectedAtMillis = null
state = State.Idle
}

// Defence in depth for a latched limit-hold basis: the steady-plugged branch has never
// dropped one, so a limit removed in system settings left the gesture armed while the
// battery climbed past the arming band. A reading outside the band retires it. Only a
// limit-hold basis — an any-level basis is percent-independent by design. An unreadable
// percent (< 0) retires nothing, so a single failed sticky-broadcast read cannot disarm a
// healthy gesture.
if (input.percent >= 0 &&
input.percent !in MIN_ARM_PERCENT..MAX_ARM_PERCENT &&
state.armingBasis == ArmedBy.LIMIT_HOLD
) {
state = State.Idle
}

val previous = previousPlugged
previousPlugged = input.plugged

if (previous == null) {
armedBy = basisOf(heldAtLimit, anyLevelHeld)
state = armFrom(heldAtLimit, anyLevelHeld)
return statusOutput(input)
}

if (previous && !input.plugged) {
disconnectedAtMillis = input.nowMillis.takeIf { armedBy != null }
// Carry the basis across the gap: the hardware evidence is already gone by this tick.
state = (state as? State.Armed)
?.let { State.AwaitingReconnect(it.basis, input.nowMillis) }
?: State.Idle
return statusOutput(input)
}

if (!previous && input.plugged) {
val windowBasis = armedBy
val delta = disconnectedAtMillis?.let { input.nowMillis - it }
disconnectedAtMillis = null
armedBy = null
if (windowBasis != null && delta != null && delta in minReconnectMillis..maxReconnectMillis) {
return Output(QuickFullChargeDecision.TRIGGER, windowBasis == ArmedBy.ANY_LEVEL)
val awaiting = state as? State.AwaitingReconnect
if (awaiting != null) {
val delta = input.nowMillis - awaiting.sinceMillis
return when {
delta in minReconnectMillis..maxReconnectMillis -> {
state = State.Idle
Output(QuickFullChargeDecision.TRIGGER, awaiting.basis == ArmedBy.ANY_LEVEL)
}
// Too fast (a momentary power cut): keep the basis the window carried. The
// replug reading itself can never re-derive it — a phone topping back up
// reports CHARGING, not a settled hold — so discarding it here made every
// retry after a rejected attempt inert.
delta < minReconnectMillis -> {
state = State.Armed(awaiting.basis)
statusOutput(input)
}
// Too late: the window is spent, but the fresh plugged state may already
// qualify again — re-arm immediately instead of waiting for another broadcast.
else -> {
state = armFrom(heldAtLimit, anyLevelHeld)
statusOutput(input)
}
}
}
// A too-fast or too-late replug is no trigger, but the fresh plugged state may already
// qualify again — re-arm immediately instead of waiting for another broadcast.
armedBy = basisOf(heldAtLimit, anyLevelHeld)
state = armFrom(heldAtLimit, anyLevelHeld)
return statusOutput(input)
}

if (input.plugged) {
when {
// Latch the hold: at the later unplug tick the hardware evidence is already gone.
heldAtLimit -> armedBy = ArmedBy.LIMIT_HOLD
armedBy == null && anyLevelHeld -> armedBy = ArmedBy.ANY_LEVEL
heldAtLimit -> state = State.Armed(ArmedBy.LIMIT_HOLD)
state is State.Idle && anyLevelHeld -> state = State.Armed(ArmedBy.ANY_LEVEL)
}
} else {
val awaiting = state as? State.AwaitingReconnect
if (awaiting != null && input.nowMillis - awaiting.sinceMillis > maxReconnectMillis) {
state = State.Idle
}
} else if (disconnectedAtMillis?.let { input.nowMillis - it > maxReconnectMillis } == true) {
disconnectedAtMillis = null
armedBy = null
}
return statusOutput(input)
}

fun reset() {
previousPlugged = null
armedBy = null
disconnectedAtMillis = null
state = State.Idle
}

private fun basisOf(heldAtLimit: Boolean, anyLevelHeld: Boolean): ArmedBy? = when {
heldAtLimit -> ArmedBy.LIMIT_HOLD
anyLevelHeld -> ArmedBy.ANY_LEVEL
else -> null
private fun armFrom(heldAtLimit: Boolean, anyLevelHeld: Boolean): State = when {
heldAtLimit -> State.Armed(ArmedBy.LIMIT_HOLD)
anyLevelHeld -> State.Armed(ArmedBy.ANY_LEVEL)
else -> State.Idle
}

private fun statusOutput(input: Input): Output {
val current = state
val decision = when {
input.plugged && armedBy != null -> QuickFullChargeDecision.ARMED
!input.plugged && disconnectedAtMillis != null -> QuickFullChargeDecision.WAITING_FOR_RECONNECT
input.plugged && current is State.Armed -> QuickFullChargeDecision.ARMED
!input.plugged && current is State.AwaitingReconnect -> QuickFullChargeDecision.WAITING_FOR_RECONNECT
else -> QuickFullChargeDecision.IDLE
}
return Output(decision, armedBy == ArmedBy.ANY_LEVEL)
return Output(decision, current.armingBasis == ArmedBy.ANY_LEVEL)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,12 @@ import eu.darken.amply.common.debug.logging.asLog
import eu.darken.amply.common.debug.logging.log
import eu.darken.amply.common.debug.logging.logTag
import eu.darken.amply.common.flow.combine as combine6
import eu.darken.amply.fullcharge.core.ChargeSessionManager
import eu.darken.amply.fullcharge.core.ChargeSessionRecord
import eu.darken.amply.fullcharge.core.ChargeSessionService
import eu.darken.amply.fullcharge.core.FullChargeStore
import eu.darken.amply.fullcharge.core.InterruptionEvent
import eu.darken.amply.fullcharge.core.InterruptionStore
import eu.darken.amply.fullcharge.core.ServiceDispatch
import eu.darken.amply.fullcharge.core.SessionNotifications
import eu.darken.amply.main.core.DeviceSupportReport
import eu.darken.amply.main.core.DeviceSupportReporter
import eu.darken.amply.main.core.OnboardingSettings
Expand Down Expand Up @@ -113,7 +111,6 @@ class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: ChargingRepository,
private val fullChargeStore: FullChargeStore,
private val sessionManager: ChargeSessionManager,
private val onboardingSettings: OnboardingSettings,
private val deviceSupportReporter: DeviceSupportReporter,
private val quickAccessStore: QuickAccessStore,
Expand Down Expand Up @@ -340,25 +337,23 @@ class DashboardViewModel @Inject constructor(

fun completeOnboarding() = viewModelScope.launch { onboardingSettings.complete() }

fun applyPolicy(policy: ChargePolicy) = viewModelScope.launch {
/**
* Routed through the service (same command the widget's ∞ buttons use) rather than writing the
* policy here: the service serializes the write against session/recovery writes, refuses when no
* backend can write, persists the recovery target before the risky write, suppresses its own
* settings-observer trip, force-writes so a same-value write still re-triggers the HAL, clears
* the interruption warning plus its recovery notification on success (and posts one on failure)
* — and resets the gesture engine, which the plain ACTION_MONITOR nudge never did, so a stale
* arming basis could survive a persistent-policy change.
*/
fun applyPolicy(policy: ChargePolicy) {
log(TAG, Logging.Priority.INFO) { "applyPolicy(${policy.stableId})" }
if (fullChargeStore.currentSession() != null) sessionManager.cancelWithoutRestore()
val result = repository.applyPersistent(policy)
if (result.success) {
// An explicit policy choice supersedes any non-successful interruption warning and its
// lingering recovery notification.
interruptionStore.clearPending()
SessionNotifications.cancelRecovery(context)
}
// The persistent policy is an any-level arming input; nudge a running gesture monitor so
// arming and notification copy react now instead of on the next broadcast/30s poll.
if (fullChargeStore.isQuickFullChargeEnabled()) {
ContextCompat.startForegroundService(
context,
Intent(context, ChargeSessionService::class.java)
.setAction(ChargeSessionService.ACTION_MONITOR),
)
}
ContextCompat.startForegroundService(
context,
Intent(context, ChargeSessionService::class.java)
.setAction(ChargeSessionService.ACTION_SET_PERSISTENT_POLICY)
.putExtra(ChargeSessionService.EXTRA_TARGET_POLICY, policy.stableId),
)
}

fun startFullCharge() {
Expand Down
Loading