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 @@ -378,6 +378,10 @@ class ChargeSessionService : Service() {
} else {
PolicyEvidence.UNKNOWN
}
// Verified readback only, deliberately not GestureBasis.evidence()'s journal fallback: this
// feeds the *default* limit-hold basis, which must never arm off a limit Amply merely
// remembers writing. The same value names the limit in the notification below.
val verifiedLimitPercent = GestureBasis.limitPercent(hardware)
val output = quickGesture.update(
QuickFullChargeGesture.Input(
nowMillis = observedAtElapsed,
Expand All @@ -387,6 +391,7 @@ class ChargeSessionService : Service() {
chargingStatus = chargingStatus,
anyLevelEnabled = anyLevel,
policyEvidence = policyEvidence,
verifiedLimitPercent = verifiedLimitPercent,
),
)
val decision = output.decision
Expand Down Expand Up @@ -438,7 +443,7 @@ class ChargeSessionService : Service() {
// 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),
limitPercent = verifiedLimitPercent,
),
)
// Expiry isn't broadcast-driven: without a nudge the "reconnect now" copy could linger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ object GestureBasis {
* 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.
*
* This is also the limit-hold gesture's *settled at the limit* arming input
* (`QuickFullChargeGesture.Input.verifiedLimitPercent`), which is why the no-journal rule is
* load-bearing beyond copy: [evidence]'s journal fallback would arm the default basis off a
* limit Amply merely remembers writing. Keep this function journal-free.
*/
fun limitPercent(hardware: ChargeObservation?): Int? =
(hardware as? ChargeObservation.Verified)?.policy?.limitPercentOrNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,23 @@ enum class PolicyEvidence { PROTECTIVE, UNRESTRICTED, UNKNOWN }
* 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.
* - Limit hold (default): the charge limit is established as active by either of two paths, both
* requiring Android's charging-policy hardware state, never Amply's cached request. *Held* adds a
* battery status other than `CHARGING`. *Settled at the limit* instead requires that the battery
* has already reached the verified limit ([Input.verifiedLimitPercent]), and deliberately does
* **not** wait for the battery status. That wait is the problem it exists to solve: for the
* ~10–12 s the Pixel HAL takes to act on a freshly written limit the phone is physically topping
* back up and reports `CHARGING`, so every path that writes the limit while already in the arming
* band — a session restore, boot recovery, the widget's persistent-policy buttons — left the
* gesture unable to arm at all, and an unplug in that window opened no reconnect window. Neither
* path subsumes the other: ordinary drift to 79 % under an 80 % limit is *held* but not *settled*,
* and the window after a write is *settled* but not *held*. Reaching the limit is what carries the
* intent the dropped battery status used to carry — it separates sitting at the limit from
* climbing through the band, so a replug at 76 % under an 80 % limit still arms nothing.
* Accepting `CHARGING` is what makes the steady-plugged drop below load-bearing, because the
* hardware state lags a policy change in both directions. Since both paths require the
* charging-policy state, a policy that reports a *different* hardware state — Pixel's adaptive
* charging — never arms this basis at all; the any-level basis is the only one that covers it.
* - 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
Expand Down Expand Up @@ -84,6 +99,12 @@ class QuickFullChargeGesture(
val chargingStatus: Int,
val anyLevelEnabled: Boolean,
val policyEvidence: PolicyEvidence,
/**
* Percent of a *verified* active fixed limit, null when nothing verified names one. Must come
* from the live hardware/settings readback only — never from Amply's write journal, which
* still reports a limit the user has since removed natively.
*/
val verifiedLimitPercent: Int? = null,
)

data class Output(
Expand Down Expand Up @@ -111,10 +132,21 @@ class QuickFullChargeGesture(
private var state: State = State.Idle

fun update(input: Input): Output {
val heldAtLimit = input.plugged &&
input.chargingStatus == CHARGING_STATUS_POLICY &&
val inArmingBand = input.percent in MIN_ARM_PERCENT..MAX_ARM_PERCENT
val policyActive = input.plugged && input.chargingStatus == CHARGING_STATUS_POLICY
// Anything but CHARGING — NOT_CHARGING at a settled hold, but also FULL/DISCHARGING/UNKNOWN,
// all of which equally mean the battery is not being driven up right now.
val heldAtLimit = policyActive &&
input.batteryStatus != BatteryManager.BATTERY_STATUS_CHARGING &&
input.percent in MIN_ARM_PERCENT..MAX_ARM_PERCENT
inArmingBand
// The battery already reached the verified limit, so the limit is established without
// waiting out the HAL transition that keeps the battery status at CHARGING right after a
// write. Reaching the limit is what the dropped battery status is traded for.
val settledAtLimit = policyActive &&
inArmingBand &&
input.verifiedLimitPercent != null &&
input.percent >= input.verifiedLimitPercent
val limitBasis = heldAtLimit || settledAtLimit
val anyLevelHeld = input.anyLevelEnabled &&
input.plugged &&
input.policyEvidence == PolicyEvidence.PROTECTIVE
Expand Down Expand Up @@ -154,7 +186,7 @@ class QuickFullChargeGesture(
previousPlugged = input.plugged

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

Expand Down Expand Up @@ -186,20 +218,35 @@ class QuickFullChargeGesture(
// 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)
state = armFrom(limitBasis, anyLevelHeld)
statusOutput(input)
}
}
}
state = armFrom(heldAtLimit, anyLevelHeld)
state = armFrom(limitBasis, anyLevelHeld)
return statusOutput(input)
}

if (input.plugged) {
when {
// Latch the hold: at the later unplug tick the hardware evidence is already gone.
heldAtLimit -> state = State.Armed(ArmedBy.LIMIT_HOLD)
limitBasis -> state = State.Armed(ArmedBy.LIMIT_HOLD)
state is State.Idle && anyLevelHeld -> state = State.Armed(ArmedBy.ANY_LEVEL)
// Positive proof the limit is not holding: current is flowing while the hardware
// reports no charging policy at all. Required because the settled path accepts a
// `CHARGING` reading, and the hardware state lags a policy change in *both*
// directions — leaving an 80% limit at 80% briefly still reports state 4, which
// would otherwise latch a limit hold that no longer exists and survive (this
// branch has never dropped a latch) until the battery left the band. Confined to
// the steady-plugged branch on purpose: a replug tick legitimately reads
// `CHARGING` with no policy state yet, and dropping there would destroy exactly
// the carried basis the reconnect window exists to preserve.
// An unreadable percent (`< 0`) marks the whole reading as a failed sticky-broadcast
// read, so its charging status is no proof of anything either.
state.armingBasis == ArmedBy.LIMIT_HOLD &&
input.percent >= 0 &&
input.batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING &&
!policyActive -> state = State.Idle
}
} else {
val awaiting = state as? State.AwaitingReconnect
Expand All @@ -215,8 +262,8 @@ class QuickFullChargeGesture(
state = State.Idle
}

private fun armFrom(heldAtLimit: Boolean, anyLevelHeld: Boolean): State = when {
heldAtLimit -> State.Armed(ArmedBy.LIMIT_HOLD)
private fun armFrom(limitBasis: Boolean, anyLevelHeld: Boolean): State = when {
limitBasis -> State.Armed(ArmedBy.LIMIT_HOLD)
anyLevelHeld -> State.Armed(ArmedBy.ANY_LEVEL)
else -> State.Idle
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,121 @@ class QuickFullChargeGestureTest {
) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `reconnect right after the limit is written triggers before the battery status settles`() {
// The regression: a restore re-applies the limit, the phone tops back up and still reports
// CHARGING for the ~10s HAL transition. Reaching the verified limit arms regardless.
freshlyLimited(1_000) shouldBe QuickFullChargeDecision.ARMED
disconnected(2_000) shouldBe QuickFullChargeDecision.WAITING_FOR_RECONNECT
charging(5_000) shouldBe QuickFullChargeDecision.TRIGGER
}

@Test
fun `climbing through the arming band below the limit does not arm`() {
freshlyLimited(1_000, percent = 76) shouldBe QuickFullChargeDecision.IDLE
step(
now = 2_000,
plugged = false,
percent = 76,
batteryStatus = BatteryManager.BATTERY_STATUS_DISCHARGING,
) shouldBe QuickFullChargeDecision.IDLE
freshlyLimited(5_000, percent = 76) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `reaching the limit above the arming band does not arm`() {
freshlyLimited(1_000, percent = 95) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `an unverified limit does not arm while the battery is still charging`() {
step(
now = 1_000,
plugged = true,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
verifiedLimit = null,
) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `an adaptive policy never arms the limit-hold basis`() {
// Adaptive reports hardware state 5, and both limit-hold paths require state 4 — so it
// arms through neither, whatever the battery status says.
step(
now = 1_000,
plugged = true,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = 5,
verifiedLimit = null,
) shouldBe QuickFullChargeDecision.IDLE
step(
now = 2_000,
plugged = true,
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
chargingStatus = 5,
verifiedLimit = null,
) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `a stale policy state while leaving the limit does not leave the gesture armed`() {
// The hardware state lags a policy change in both directions, so switching away from the
// limit at 80% is briefly indistinguishable from having just written it. Once current
// flows with no policy state, the latch must go — it used to survive until the battery
// left the 75-90% band, so a replug during the climb started an unwanted full charge.
freshlyLimited(1_000) shouldBe QuickFullChargeDecision.ARMED
step(
now = 4_000,
plugged = true,
percent = 82,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = 5,
) shouldBe QuickFullChargeDecision.IDLE
step(
now = 6_000,
plugged = false,
percent = 82,
batteryStatus = BatteryManager.BATTERY_STATUS_DISCHARGING,
) shouldBe QuickFullChargeDecision.IDLE
step(
now = 9_000,
plugged = true,
percent = 82,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = 5,
) shouldBe QuickFullChargeDecision.IDLE
}

@Test
fun `a replug still reconstructs the carried basis despite no policy state yet`() {
// The drop above must not reach the reconnect path: a replugged phone legitimately reads
// CHARGING with the policy state not yet re-reported, which is precisely why the basis is
// carried across the gap rather than re-derived.
atLimit(1_000) shouldBe QuickFullChargeDecision.ARMED
disconnected(2_000) shouldBe QuickFullChargeDecision.WAITING_FOR_RECONNECT
step(
now = 5_000,
plugged = true,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = 1,
) shouldBe QuickFullChargeDecision.TRIGGER
}

@Test
fun `drift below the limit still arms through the settled hold`() {
// 79% under an 80% limit is not "at the limit", but current is visibly cut — the original
// path must keep arming on its own.
step(
now = 1_000,
plugged = true,
percent = 79,
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
verifiedLimit = 80,
) shouldBe QuickFullChargeDecision.ARMED
}

@Test
fun `reconnect window boundaries`() {
// Debounce floor: exactly the minimum triggers, one millisecond less does not.
Expand Down Expand Up @@ -670,6 +785,7 @@ class QuickFullChargeGestureTest {
chargingStatus: Int = 0,
anyLevel: Boolean = false,
evidence: PolicyEvidence = PolicyEvidence.UNKNOWN,
verifiedLimit: Int? = null,
) = QuickFullChargeGesture.Input(
nowMillis = now,
plugged = plugged,
Expand All @@ -678,6 +794,7 @@ class QuickFullChargeGestureTest {
chargingStatus = chargingStatus,
anyLevelEnabled = anyLevel,
policyEvidence = evidence,
verifiedLimitPercent = verifiedLimit,
)

private fun step(
Expand All @@ -688,15 +805,31 @@ class QuickFullChargeGestureTest {
chargingStatus: Int = 0,
anyLevel: Boolean = false,
evidence: PolicyEvidence = PolicyEvidence.UNKNOWN,
verifiedLimit: Int? = null,
) = gesture.update(
input(now, plugged, percent, batteryStatus, chargingStatus, anyLevel, evidence),
input(now, plugged, percent, batteryStatus, chargingStatus, anyLevel, evidence, verifiedLimit),
).decision

/** The settled hold: policy state 4 with current already cut. Mirrors what the Pixel adapter supplies. */
private fun atLimit(now: Long) = step(
now = now,
plugged = true,
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
verifiedLimit = 80,
)

/**
* The window right after the limit is written: policy state 4 is already reported while the
* phone is still topping back up, so the battery status has not settled yet.
*/
private fun freshlyLimited(now: Long, percent: Int = 80) = step(
now = now,
plugged = true,
percent = percent,
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
verifiedLimit = 80,
)

private fun charging(now: Long) = step(
Expand Down