Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import eu.darken.amply.main.ui.dashboard.DashboardUiState
import eu.darken.amply.main.ui.dashboard.StatsDashboardState
import eu.darken.amply.stats.core.ChargeCurvePoint
import eu.darken.amply.stats.core.StatsLiveSession
import eu.darken.amply.main.ui.settings.ChargingSettingsScreen
import eu.darken.amply.main.ui.settings.GeneralSettingsScreen
import eu.darken.amply.main.ui.settings.ReconnectGestureSettingsScreen

// Device spec shared by every Play Store screenshot. 1080x1920 (9:16) is Play's recommended phone
// size and stays within its "longest side may not exceed 2x the shorter side" rule — 1080x2400
Expand Down Expand Up @@ -62,10 +62,12 @@ internal fun SettingsContent() = PreviewWrapper {

@Composable
internal fun ReconnectGestureContent() = PreviewWrapper {
ReconnectGestureSettingsScreen(
ChargingSettingsScreen(
gestureEnabled = true,
anyLevelEnabled = false,
canEnableGesture = true,
onBack = {},
onGestureEnabledChange = {},
onAnyLevelChange = {},
)
}
Expand All @@ -85,7 +87,6 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
onRestore = {},
onApply = {},
onQuickFullChargeChange = {},
onOpenReconnectSettings = {},
onAlarmEnabledChange = {},
onAlarmTargetChange = {},
onFixNotifications = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ class ChargeSessionService : Service() {
val level = battery?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = battery?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
val percent = if (level >= 0 && scale > 0) level * 100 / scale else -1
val chargingStatus = battery?.getIntExtra(BatteryManager.EXTRA_CHARGING_STATUS, 0) ?: 0

val session = fullChargeStore.currentSession()

Expand Down Expand Up @@ -370,7 +371,15 @@ class ChargeSessionService : Service() {
return
}

if (!fullChargeStore.isQuickFullChargeEnabled() || !reconnectGestureAvailable()) {
// Resolved once, here: DeviceInfo.current() resolves activities/providers, reads Samsung
// settings and queries UserManager, so it must stay behind the session early-return and the
// cheap enabled check — hoisting it would newly charge active sessions, disabled gestures
// and watcher-only ticks for it under commandMutex. Net cost is unchanged: the availability
// check already resolved a selection at exactly this point, and the hardware decode below
// reuses this one.
val gestureEnabled = fullChargeStore.isQuickFullChargeEnabled()
val adapter = if (gestureEnabled) adapterRegistry.select().adapter else null
if (!gestureEnabled || adapter?.reconnectGestureSupported != true) {
dispatchWatchers(plugged, percent, status, sessionOwned = false, battery, observedAtElapsed)
// Gesture inactive: keep running only if a watcher still wants the service, showing the
// quiet monitoring notification instead of the gesture cue.
Expand All @@ -383,25 +392,37 @@ class ChargeSessionService : Service() {
}

val anyLevel = fullChargeStore.isQuickFullChargeAnyLevel()
// "Protective" means Amply's own persistent configuration, freshly read on every tick — the
// engine revokes an any-level arming/window the moment this goes false, so a trigger can
// never rest on a belief older than its own evaluation. (A change made in native Settings
// stays invisible without Shizuku; ChargeSessionManager.begin() re-verifies live state and
// refuses when readback proves charging is already unrestricted.)
val policyProtective = anyLevel && preferences.lastPersistentPolicyNow()
.let { it != null && it != ChargePolicy.Unrestricted }
// The live charging-policy hardware state, freshly decoded on every tick. It is the
// authoritative source for the any-level basis: a limit set natively (or by a previous
// install) leaves Amply's own journal empty, and gating on the journal alone meant the
// basis never armed on such a device. The hardware signal is only reported while powered,
// so unplugged ticks yield inconclusive evidence, which the engine treats as "no change".
val hardware = adapter.decodeHardware(chargingStatus, plugged)
val lastPersistent = preferences.lastPersistentPolicyNow()
val policyEvidence = if (anyLevel) {
GestureBasis.evidence(hardware, lastPersistent)
} else {
PolicyEvidence.UNKNOWN
}
val output = quickGesture.update(
QuickFullChargeGesture.Input(
nowMillis = observedAtElapsed,
plugged = plugged,
percent = percent,
batteryStatus = status,
chargingStatus = battery?.getIntExtra(BatteryManager.EXTRA_CHARGING_STATUS, 0) ?: 0,
chargingStatus = chargingStatus,
anyLevelEnabled = anyLevel,
policyProtective = policyProtective,
policyEvidence = policyEvidence,
),
)
val decision = output.decision
// Every gesture tick, not just the interesting ones: a gesture that never arms leaves no
// other trace, and diagnosing that from a debug log must not require a DataStore teardown.
log(TAG, Logging.Priority.VERBOSE) {
"Reconnect gesture tick: plugged=$plugged percent=$percent batteryStatus=$status " +
"chargingStatus=$chargingStatus anyLevel=$anyLevel policyEvidence=$policyEvidence " +
"decision=$decision"
}
if (decision != QuickFullChargeDecision.IDLE) {
log(TAG) {
"Reconnect gesture: decision=$decision anyLevelBasis=${output.anyLevelBasis} " +
Expand All @@ -423,14 +444,27 @@ class ChargeSessionService : Service() {
log(TAG) { "Reconnect gesture triggered one-time full charging" }
beginOrResume()
} else {
val armed = decision == QuickFullChargeDecision.ARMED ||
decision == QuickFullChargeDecision.WAITING_FOR_RECONNECT
startAsForeground(
SessionNotifications.gesture(
this,
armed = armed,
// Armed copy reflects what actually armed; idle copy explains the enabled mode.
anyLevel = if (armed) output.anyLevelBasis else anyLevel,
decision = decision,
anyLevel = when (decision) {
// The armed copy states the condition the gesture will fire under. The
// latched basis is not that condition: at the limit, LIMIT_HOLD wins the
// latch even while any-level is on and qualifying, and naming the limit
// there would understate a gesture that will in fact fire at any level.
// Requiring PROTECTIVE evidence keeps it from overstating in the opposite
// direction, when the option is on but nothing protective is detected.
QuickFullChargeDecision.ARMED,
QuickFullChargeDecision.WAITING_FOR_RECONNECT,
-> anyLevel && policyEvidence == PolicyEvidence.PROTECTIVE
// Idle copy explains the enabled mode rather than a live basis.
else -> anyLevel
},
// Verified evidence only, never Amply's write journal: naming a number is a
// user-facing claim, and a limit removed natively must not keep being claimed.
// Unverified state falls back to the generic "charge limit is holding" copy.
limitPercent = GestureBasis.limitPercent(hardware),
),
)
// Expiry isn't broadcast-driven: without a nudge the "reconnect now" copy could linger
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/eu/darken/amply/fullcharge/core/GestureBasis.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package eu.darken.amply.fullcharge.core

import eu.darken.amply.charging.core.ChargeObservation
import eu.darken.amply.charging.core.ChargePolicy

/**
* Turns what is actually known about the current charge configuration into the any-level gesture's
* arming evidence, plus the limit to name in the waiting notification.
*
* [evidence] has two sources, in order ([limitPercent] has only the first — see below):
* 1. [ChargeObservation.Verified] — the live hardware/settings readback. Conclusive on its own; the
* journal is not consulted at all, so a native change Amply never made still decides.
* 2. `lastPersistentPolicy` — Amply's own journal of persistent writes. Null until Amply's first
* persistent write, which is exactly why it cannot be the only source: a limit set natively (or
* by a previous install) leaves it absent, and the any-level basis would never arm even though
* the limit genuinely is set.
*
* "Protective" is `!allowsFullCharge`, never `!= Unrestricted` — `PauseAtFull` and `FixedLimit(100)`
* both reach 100 % and must not arm a gesture whose whole purpose is to lift a cap.
*
* `ChargingPreferences.protectivePolicyNow()` is deliberately **not** a source here: it defaults to
* `FixedLimit(80)` with no history and keeps a stale baseline after a persistent `Unrestricted`
* write, so it would both arm and display a number that is not the configured policy.
*
* [limitPercent] deliberately has **no** journal fallback: naming a number is a user-facing claim
* ("charging pauses at your 80 % limit"), and the journal records what Amply last wrote, not what is
* configured now. A user who applied 80 % through Amply and then set the native charging setting to
* unrestricted decodes as [ChargeObservation.Unknown] on Pixel's powered NORMAL state, so a journal
* fallback would keep claiming a limit that no longer exists. Only a [ChargeObservation.Verified]
* fixed limit may be named. In practice nothing loses a percentage it legitimately had: only the
* Pixel adapter implements `decodeHardware` and only Pixel supports the reconnect gesture, so an
* unverified state simply falls back to the generic "while your charge limit is holding" copy.
*
* Accepted residual (arming only, distinct from the above): with any-level ON, inconclusive hardware
* evidence plus a stale protective journal still arms even if charging was since set unrestricted
* natively. `SessionStartDecider` refuses such a start whenever the current policy is verifiable, so
* this only bites on a WSS-only Pixel with no Shizuku, and it is pre-existing behaviour. Failing
* closed would reverse `SessionStartDecider`'s explicit "a stale last-request must never block a
* session" contract.
*/
object GestureBasis {

fun evidence(hardware: ChargeObservation?, lastPersistent: ChargePolicy?): PolicyEvidence {
if (hardware is ChargeObservation.Verified) {
return if (hardware.policy.allowsFullCharge) {
PolicyEvidence.UNRESTRICTED
} else {
PolicyEvidence.PROTECTIVE
}
}
return when {
lastPersistent == null -> PolicyEvidence.UNKNOWN
lastPersistent.allowsFullCharge -> PolicyEvidence.UNRESTRICTED
else -> PolicyEvidence.PROTECTIVE
}
}

/**
* The limit percent to name in the waiting notification, or null when nothing *verified* says
* one. Only a verified observation answers: the journal records Amply's last write, not the
* current configuration, so falling through to it would keep claiming "your 80 % limit" after
* the user removed that limit natively.
*/
fun limitPercent(hardware: ChargeObservation?): Int? =
(hardware as? ChargeObservation.Verified)?.policy?.limitPercentOrNull()

private fun ChargePolicy.limitPercentOrNull(): Int? =
(this as? ChargePolicy.FixedLimit)?.percent?.takeIf { !allowsFullCharge }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,34 @@ enum class QuickFullChargeDecision {
TRIGGER,
}

/** How conclusively the current charge configuration is known, for the any-level arming basis. */
enum class PolicyEvidence { PROTECTIVE, UNRESTRICTED, UNKNOWN }

/**
* Detects a deliberate unplug/replug gesture that starts a one-time full charge.
*
* 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 Amply's persistently configured
* policy is protective; percent, battery status, and the hardware hold are deliberately ignored.
* This basis is revoked the moment the option or the protective policy is withdrawn — including
* an already-open reconnect window — so an explicit opt-out can never produce a trigger.
* - 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.
*
* [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
* battery broadcast's charging-policy hardware state is only reported while external power is
* present. So the very unplug tick that opens the reconnect window reports UNKNOWN; treating that as
* a withdrawal would revoke the basis before the powered→unpowered edge is even recorded and the
* window would never open. An open window survives inconclusive evidence for at most the 10 s
* reconnect ceiling, and `ChargeSessionManager.begin()` re-verifies live state and refuses
* (`SessionStartDecision.AlreadyChargesFull`) when readback proves charging already reaches full.
*
* A *continuously plugged* tick that goes inconclusive does drop the basis: powered, the evidence is
* available, so an inconclusive reading means the configuration is no longer known to be protective
* (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.
*
* 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
Expand All @@ -40,7 +58,7 @@ class QuickFullChargeGesture(
val batteryStatus: Int,
val chargingStatus: Int,
val anyLevelEnabled: Boolean,
val policyProtective: Boolean,
val policyEvidence: PolicyEvidence,
)

data class Output(
Expand All @@ -60,12 +78,25 @@ class QuickFullChargeGesture(
input.chargingStatus == CHARGING_STATUS_POLICY &&
input.batteryStatus != BatteryManager.BATTERY_STATUS_CHARGING &&
input.percent in MIN_ARM_PERCENT..MAX_ARM_PERCENT
val anyLevelHeld = input.anyLevelEnabled && input.plugged && input.policyProtective

// An any-level basis is continuously observable and is dropped as soon as its inputs are
// withdrawn. A latched limit-hold basis survives option flips: its evidence was the
// (momentary) hardware hold, which is mode-independent.
if (armedBy == ArmedBy.ANY_LEVEL && (!input.anyLevelEnabled || !input.policyProtective)) {
val anyLevelHeld = input.anyLevelEnabled &&
input.plugged &&
input.policyEvidence == PolicyEvidence.PROTECTIVE

// 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.
// A latched limit-hold basis survives option flips: its evidence was the (momentary)
// hardware hold, which is mode-independent.
if (armedBy == ArmedBy.ANY_LEVEL &&
(!input.anyLevelEnabled ||
input.policyEvidence == PolicyEvidence.UNRESTRICTED ||
(input.policyEvidence == PolicyEvidence.UNKNOWN &&
input.plugged &&
disconnectedAtMillis == null))
) {
armedBy = null
disconnectedAtMillis = null
}
Expand Down
Loading
Loading