Skip to content

Commit f291f69

Browse files
committed
FullCharge: Arm the reconnect gesture at the limit without waiting for the battery status
The gesture armed only when the charging-policy hardware state, a non-CHARGING battery status and an in-band percent held at once. After any write of the limit the phone is physically topping back up, so the battery status stays CHARGING for the ~10-12s Pixel HAL transition and nothing armed. An unplug in that window opened no reconnect window and the replug had nothing to consume, so a gesture right after "Restore now" silently did nothing. Every path that writes the limit while already in the band was affected - session restore, boot recovery, the widget's persistent-policy buttons - not just restore. Arming now also accepts a second path: the policy state is reported and the battery has already reached a *verified* limit percent. Reaching the limit replaces the dropped battery status as the signal that separates sitting at the limit from climbing through the band, so a replug at 76% under an 80% limit still arms nothing. The percent is sourced from GestureBasis.limitPercent(), which answers only from a verified readback; GestureBasis.evidence() is deliberately not used, as its journal fallback would arm the default basis off a limit Amply merely remembers writing. Accepting a CHARGING reading needs a counterweight, because the hardware state lags a policy change in both directions: leaving an 80% limit at 80% briefly still reports the old policy state, which would latch a hold that no longer exists and survive - the steady-plugged branch has never dropped a latch - until the battery left the band. That branch now retires a limit-hold basis on positive proof the limit is not holding: current flowing while no policy state is reported, from a readable broadcast. It is confined to the steady-plugged branch because a replug legitimately reads CHARGING before the policy state is re-reported, and dropping there would destroy the carried basis the reconnect window exists to preserve. Verified on a Pixel 9 Pro: re-arming after a restore went from ~10.5s to 108ms, and the full replay (gesture, Restore now, gesture again within the window) starts a second session where it previously did nothing. The climbing case (76% under an 80% limit) stays idle.
1 parent 9d34285 commit f291f69

4 files changed

Lines changed: 203 additions & 13 deletions

File tree

app/src/main/java/eu/darken/amply/fullcharge/core/ChargeSessionService.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,10 @@ class ChargeSessionService : Service() {
378378
} else {
379379
PolicyEvidence.UNKNOWN
380380
}
381+
// Verified readback only, deliberately not GestureBasis.evidence()'s journal fallback: this
382+
// feeds the *default* limit-hold basis, which must never arm off a limit Amply merely
383+
// remembers writing. The same value names the limit in the notification below.
384+
val verifiedLimitPercent = GestureBasis.limitPercent(hardware)
381385
val output = quickGesture.update(
382386
QuickFullChargeGesture.Input(
383387
nowMillis = observedAtElapsed,
@@ -387,6 +391,7 @@ class ChargeSessionService : Service() {
387391
chargingStatus = chargingStatus,
388392
anyLevelEnabled = anyLevel,
389393
policyEvidence = policyEvidence,
394+
verifiedLimitPercent = verifiedLimitPercent,
390395
),
391396
)
392397
val decision = output.decision
@@ -438,7 +443,7 @@ class ChargeSessionService : Service() {
438443
// Verified evidence only, never Amply's write journal: naming a number is a
439444
// user-facing claim, and a limit removed natively must not keep being claimed.
440445
// Unverified state falls back to the generic "charge limit is holding" copy.
441-
limitPercent = GestureBasis.limitPercent(hardware),
446+
limitPercent = verifiedLimitPercent,
442447
),
443448
)
444449
// Expiry isn't broadcast-driven: without a nudge the "reconnect now" copy could linger

app/src/main/java/eu/darken/amply/fullcharge/core/GestureBasis.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ object GestureBasis {
6060
* one. Only a verified observation answers: the journal records Amply's last write, not the
6161
* current configuration, so falling through to it would keep claiming "your 80 % limit" after
6262
* the user removed that limit natively.
63+
*
64+
* This is also the limit-hold gesture's *settled at the limit* arming input
65+
* (`QuickFullChargeGesture.Input.verifiedLimitPercent`), which is why the no-journal rule is
66+
* load-bearing beyond copy: [evidence]'s journal fallback would arm the default basis off a
67+
* limit Amply merely remembers writing. Keep this function journal-free.
6368
*/
6469
fun limitPercent(hardware: ChargeObservation?): Int? =
6570
(hardware as? ChargeObservation.Verified)?.policy?.limitPercentOrNull()

app/src/main/java/eu/darken/amply/fullcharge/core/QuickFullChargeGesture.kt

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,23 @@ enum class PolicyEvidence { PROTECTIVE, UNRESTRICTED, UNKNOWN }
3030
* the original unplug still triggers`).
3131
*
3232
* Two arming bases exist:
33-
* - Limit hold (default): Android's charging-policy hardware state reports the Pixel policy actively
34-
* holding near its limit. Only the hardware signal is trusted, never Amply's cached request.
33+
* - Limit hold (default): the charge limit is established as active by either of two paths, both
34+
* requiring Android's charging-policy hardware state, never Amply's cached request. *Held* adds a
35+
* battery status other than `CHARGING`. *Settled at the limit* instead requires that the battery
36+
* has already reached the verified limit ([Input.verifiedLimitPercent]), and deliberately does
37+
* **not** wait for the battery status. That wait is the problem it exists to solve: for the
38+
* ~10–12 s the Pixel HAL takes to act on a freshly written limit the phone is physically topping
39+
* back up and reports `CHARGING`, so every path that writes the limit while already in the arming
40+
* band — a session restore, boot recovery, the widget's persistent-policy buttons — left the
41+
* gesture unable to arm at all, and an unplug in that window opened no reconnect window. Neither
42+
* path subsumes the other: ordinary drift to 79 % under an 80 % limit is *held* but not *settled*,
43+
* and the window after a write is *settled* but not *held*. Reaching the limit is what carries the
44+
* intent the dropped battery status used to carry — it separates sitting at the limit from
45+
* climbing through the band, so a replug at 76 % under an 80 % limit still arms nothing.
46+
* Accepting `CHARGING` is what makes the steady-plugged drop below load-bearing, because the
47+
* hardware state lags a policy change in both directions. Since both paths require the
48+
* charging-policy state, a policy that reports a *different* hardware state — Pixel's adaptive
49+
* charging — never arms this basis at all; the any-level basis is the only one that covers it.
3550
* - Any level (opt-in): the user enabled the any-level option and the current charge configuration
3651
* is conclusively protective ([PolicyEvidence.PROTECTIVE]); percent, battery status, and the
3752
* hardware hold are deliberately ignored. This basis is revoked — including an already-open
@@ -84,6 +99,12 @@ class QuickFullChargeGesture(
8499
val chargingStatus: Int,
85100
val anyLevelEnabled: Boolean,
86101
val policyEvidence: PolicyEvidence,
102+
/**
103+
* Percent of a *verified* active fixed limit, null when nothing verified names one. Must come
104+
* from the live hardware/settings readback only — never from Amply's write journal, which
105+
* still reports a limit the user has since removed natively.
106+
*/
107+
val verifiedLimitPercent: Int? = null,
87108
)
88109

89110
data class Output(
@@ -111,10 +132,21 @@ class QuickFullChargeGesture(
111132
private var state: State = State.Idle
112133

113134
fun update(input: Input): Output {
114-
val heldAtLimit = input.plugged &&
115-
input.chargingStatus == CHARGING_STATUS_POLICY &&
135+
val inArmingBand = input.percent in MIN_ARM_PERCENT..MAX_ARM_PERCENT
136+
val policyActive = input.plugged && input.chargingStatus == CHARGING_STATUS_POLICY
137+
// Anything but CHARGING — NOT_CHARGING at a settled hold, but also FULL/DISCHARGING/UNKNOWN,
138+
// all of which equally mean the battery is not being driven up right now.
139+
val heldAtLimit = policyActive &&
116140
input.batteryStatus != BatteryManager.BATTERY_STATUS_CHARGING &&
117-
input.percent in MIN_ARM_PERCENT..MAX_ARM_PERCENT
141+
inArmingBand
142+
// The battery already reached the verified limit, so the limit is established without
143+
// waiting out the HAL transition that keeps the battery status at CHARGING right after a
144+
// write. Reaching the limit is what the dropped battery status is traded for.
145+
val settledAtLimit = policyActive &&
146+
inArmingBand &&
147+
input.verifiedLimitPercent != null &&
148+
input.percent >= input.verifiedLimitPercent
149+
val limitBasis = heldAtLimit || settledAtLimit
118150
val anyLevelHeld = input.anyLevelEnabled &&
119151
input.plugged &&
120152
input.policyEvidence == PolicyEvidence.PROTECTIVE
@@ -154,7 +186,7 @@ class QuickFullChargeGesture(
154186
previousPlugged = input.plugged
155187

156188
if (previous == null) {
157-
state = armFrom(heldAtLimit, anyLevelHeld)
189+
state = armFrom(limitBasis, anyLevelHeld)
158190
return statusOutput(input)
159191
}
160192

@@ -186,20 +218,35 @@ class QuickFullChargeGesture(
186218
// Too late: the window is spent, but the fresh plugged state may already
187219
// qualify again — re-arm immediately instead of waiting for another broadcast.
188220
else -> {
189-
state = armFrom(heldAtLimit, anyLevelHeld)
221+
state = armFrom(limitBasis, anyLevelHeld)
190222
statusOutput(input)
191223
}
192224
}
193225
}
194-
state = armFrom(heldAtLimit, anyLevelHeld)
226+
state = armFrom(limitBasis, anyLevelHeld)
195227
return statusOutput(input)
196228
}
197229

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

218-
private fun armFrom(heldAtLimit: Boolean, anyLevelHeld: Boolean): State = when {
219-
heldAtLimit -> State.Armed(ArmedBy.LIMIT_HOLD)
265+
private fun armFrom(limitBasis: Boolean, anyLevelHeld: Boolean): State = when {
266+
limitBasis -> State.Armed(ArmedBy.LIMIT_HOLD)
220267
anyLevelHeld -> State.Armed(ArmedBy.ANY_LEVEL)
221268
else -> State.Idle
222269
}

app/src/test/java/eu/darken/amply/fullcharge/core/QuickFullChargeGestureTest.kt

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,121 @@ class QuickFullChargeGestureTest {
5353
) shouldBe QuickFullChargeDecision.IDLE
5454
}
5555

56+
@Test
57+
fun `reconnect right after the limit is written triggers before the battery status settles`() {
58+
// The regression: a restore re-applies the limit, the phone tops back up and still reports
59+
// CHARGING for the ~10s HAL transition. Reaching the verified limit arms regardless.
60+
freshlyLimited(1_000) shouldBe QuickFullChargeDecision.ARMED
61+
disconnected(2_000) shouldBe QuickFullChargeDecision.WAITING_FOR_RECONNECT
62+
charging(5_000) shouldBe QuickFullChargeDecision.TRIGGER
63+
}
64+
65+
@Test
66+
fun `climbing through the arming band below the limit does not arm`() {
67+
freshlyLimited(1_000, percent = 76) shouldBe QuickFullChargeDecision.IDLE
68+
step(
69+
now = 2_000,
70+
plugged = false,
71+
percent = 76,
72+
batteryStatus = BatteryManager.BATTERY_STATUS_DISCHARGING,
73+
) shouldBe QuickFullChargeDecision.IDLE
74+
freshlyLimited(5_000, percent = 76) shouldBe QuickFullChargeDecision.IDLE
75+
}
76+
77+
@Test
78+
fun `reaching the limit above the arming band does not arm`() {
79+
freshlyLimited(1_000, percent = 95) shouldBe QuickFullChargeDecision.IDLE
80+
}
81+
82+
@Test
83+
fun `an unverified limit does not arm while the battery is still charging`() {
84+
step(
85+
now = 1_000,
86+
plugged = true,
87+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
88+
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
89+
verifiedLimit = null,
90+
) shouldBe QuickFullChargeDecision.IDLE
91+
}
92+
93+
@Test
94+
fun `an adaptive policy never arms the limit-hold basis`() {
95+
// Adaptive reports hardware state 5, and both limit-hold paths require state 4 — so it
96+
// arms through neither, whatever the battery status says.
97+
step(
98+
now = 1_000,
99+
plugged = true,
100+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
101+
chargingStatus = 5,
102+
verifiedLimit = null,
103+
) shouldBe QuickFullChargeDecision.IDLE
104+
step(
105+
now = 2_000,
106+
plugged = true,
107+
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
108+
chargingStatus = 5,
109+
verifiedLimit = null,
110+
) shouldBe QuickFullChargeDecision.IDLE
111+
}
112+
113+
@Test
114+
fun `a stale policy state while leaving the limit does not leave the gesture armed`() {
115+
// The hardware state lags a policy change in both directions, so switching away from the
116+
// limit at 80% is briefly indistinguishable from having just written it. Once current
117+
// flows with no policy state, the latch must go — it used to survive until the battery
118+
// left the 75-90% band, so a replug during the climb started an unwanted full charge.
119+
freshlyLimited(1_000) shouldBe QuickFullChargeDecision.ARMED
120+
step(
121+
now = 4_000,
122+
plugged = true,
123+
percent = 82,
124+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
125+
chargingStatus = 5,
126+
) shouldBe QuickFullChargeDecision.IDLE
127+
step(
128+
now = 6_000,
129+
plugged = false,
130+
percent = 82,
131+
batteryStatus = BatteryManager.BATTERY_STATUS_DISCHARGING,
132+
) shouldBe QuickFullChargeDecision.IDLE
133+
step(
134+
now = 9_000,
135+
plugged = true,
136+
percent = 82,
137+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
138+
chargingStatus = 5,
139+
) shouldBe QuickFullChargeDecision.IDLE
140+
}
141+
142+
@Test
143+
fun `a replug still reconstructs the carried basis despite no policy state yet`() {
144+
// The drop above must not reach the reconnect path: a replugged phone legitimately reads
145+
// CHARGING with the policy state not yet re-reported, which is precisely why the basis is
146+
// carried across the gap rather than re-derived.
147+
atLimit(1_000) shouldBe QuickFullChargeDecision.ARMED
148+
disconnected(2_000) shouldBe QuickFullChargeDecision.WAITING_FOR_RECONNECT
149+
step(
150+
now = 5_000,
151+
plugged = true,
152+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
153+
chargingStatus = 1,
154+
) shouldBe QuickFullChargeDecision.TRIGGER
155+
}
156+
157+
@Test
158+
fun `drift below the limit still arms through the settled hold`() {
159+
// 79% under an 80% limit is not "at the limit", but current is visibly cut — the original
160+
// path must keep arming on its own.
161+
step(
162+
now = 1_000,
163+
plugged = true,
164+
percent = 79,
165+
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
166+
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
167+
verifiedLimit = 80,
168+
) shouldBe QuickFullChargeDecision.ARMED
169+
}
170+
56171
@Test
57172
fun `reconnect window boundaries`() {
58173
// Debounce floor: exactly the minimum triggers, one millisecond less does not.
@@ -670,6 +785,7 @@ class QuickFullChargeGestureTest {
670785
chargingStatus: Int = 0,
671786
anyLevel: Boolean = false,
672787
evidence: PolicyEvidence = PolicyEvidence.UNKNOWN,
788+
verifiedLimit: Int? = null,
673789
) = QuickFullChargeGesture.Input(
674790
nowMillis = now,
675791
plugged = plugged,
@@ -678,6 +794,7 @@ class QuickFullChargeGestureTest {
678794
chargingStatus = chargingStatus,
679795
anyLevelEnabled = anyLevel,
680796
policyEvidence = evidence,
797+
verifiedLimitPercent = verifiedLimit,
681798
)
682799

683800
private fun step(
@@ -688,15 +805,31 @@ class QuickFullChargeGestureTest {
688805
chargingStatus: Int = 0,
689806
anyLevel: Boolean = false,
690807
evidence: PolicyEvidence = PolicyEvidence.UNKNOWN,
808+
verifiedLimit: Int? = null,
691809
) = gesture.update(
692-
input(now, plugged, percent, batteryStatus, chargingStatus, anyLevel, evidence),
810+
input(now, plugged, percent, batteryStatus, chargingStatus, anyLevel, evidence, verifiedLimit),
693811
).decision
694812

813+
/** The settled hold: policy state 4 with current already cut. Mirrors what the Pixel adapter supplies. */
695814
private fun atLimit(now: Long) = step(
696815
now = now,
697816
plugged = true,
698817
batteryStatus = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
699818
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
819+
verifiedLimit = 80,
820+
)
821+
822+
/**
823+
* The window right after the limit is written: policy state 4 is already reported while the
824+
* phone is still topping back up, so the battery status has not settled yet.
825+
*/
826+
private fun freshlyLimited(now: Long, percent: Int = 80) = step(
827+
now = now,
828+
plugged = true,
829+
percent = percent,
830+
batteryStatus = BatteryManager.BATTERY_STATUS_CHARGING,
831+
chargingStatus = QuickFullChargeGesture.CHARGING_STATUS_POLICY,
832+
verifiedLimit = 80,
700833
)
701834

702835
private fun charging(now: Long) = step(

0 commit comments

Comments
 (0)