Skip to content

Commit 77adddc

Browse files
committed
FullCharge: Offer persistent charge-mode actions on the gesture notification
The reconnect-gesture notification was display-only. It now carries two actions on its standing states: the adapter's declared protective default (named after defaultProtectivePolicy, so Adaptive-default adapters aren't mislabelled as a percentage limit) and "Always 100%". Both route through the existing serialized ACTION_SET_PERSISTENT_POLICY command, the same path the widget uses, so they inherit its writability guard, recovery-target persistence before the risky write, session cancel-without-restore, forced re-write and surface updates. No new write path and no change to the writable-setting allowlist. Actions are attached on IDLE and ARMED only — an explicit allowlist rather than "not the countdown", because TRIGGER also reaches the builder and a tick that is starting a full charge must not offer a competing persistent write. The two PendingIntents differ only in an extra, which does not factor into PendingIntent equality, so they use distinct request codes. setPersistentPolicy's refusal branch now posts the recovery notification: the widget and tile pre-check writability and open the app instead of dispatching, but a notification action cannot, so its tap would otherwise be a silent no-op.
1 parent 0aef520 commit 77adddc

4 files changed

Lines changed: 150 additions & 4 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,9 @@ class ChargeSessionService : Service() {
427427
SessionNotifications.gesture(
428428
this,
429429
decision = decision,
430+
// The notification's mode actions write persistently, so they must offer this
431+
// adapter's declared protective default, not a hardcoded limit.
432+
protectPolicy = adapter.defaultProtectivePolicy,
430433
anyLevel = when (decision) {
431434
// The armed copy states the condition the gesture will fire under. The
432435
// latched basis is not that condition: at the limit, LIMIT_HOLD wins the
@@ -643,6 +646,10 @@ class ChargeSessionService : Service() {
643646
// would strand a pending target that never converges. The app's controls guide setup.
644647
if (!repository.refresh().canApply) {
645648
log(TAG, Logging.Priority.WARN) { "Persistent policy skipped: charging control not writable" }
649+
// Tell the user why nothing happened. The widget/tile pre-check writability and open the
650+
// app instead of dispatching, but a notification action cannot pre-check, so without this
651+
// its tap is a silent no-op (it also covers a surface racing a lost write capability).
652+
SessionNotifications.showRecovery(this, R.string.recovery_notification_body_unavailable)
646653
SurfaceUpdater.updateNow(this)
647654
continueGestureOrStop()
648655
return

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
1313
import android.content.pm.PackageManager
1414
import android.os.Build
1515
import eu.darken.amply.R
16+
import eu.darken.amply.charging.core.ChargePolicy
1617
import eu.darken.amply.main.ui.MainActivity
1718

1819
object SessionNotifications {
@@ -146,9 +147,15 @@ object SessionNotifications {
146147
.build()
147148
}
148149

150+
/**
151+
* [protectPolicy] is the adapter's declared protective default and has no default value on
152+
* purpose: the action writes it persistently, so the capability has to be handed in by the
153+
* caller that resolved the adapter rather than guessed here.
154+
*/
149155
fun gesture(
150156
context: Context,
151157
decision: QuickFullChargeDecision,
158+
protectPolicy: ChargePolicy,
152159
anyLevel: Boolean = false,
153160
limitPercent: Int? = null,
154161
): Notification {
@@ -208,9 +215,53 @@ object SessionNotifications {
208215
),
209216
)
210217
}
218+
// Mode switches only on the two standing states. An explicit allowlist, not "not
219+
// WAITING_FOR_RECONNECT": TRIGGER also reaches this builder, and neither the 10s countdown
220+
// nor the tick that starts a full charge should offer a competing persistent write.
221+
if (decision == QuickFullChargeDecision.IDLE || decision == QuickFullChargeDecision.ARMED) {
222+
builder
223+
.addAction(
224+
R.drawable.ic_launcher_monochrome,
225+
protectActionLabel(context, protectPolicy),
226+
persistentPolicyIntent(context, 8, protectPolicy),
227+
)
228+
.addAction(
229+
R.drawable.ic_launcher_monochrome,
230+
context.getString(R.string.gesture_notification_action_always_full),
231+
persistentPolicyIntent(context, 9, ChargePolicy.Unrestricted),
232+
)
233+
}
211234
return builder.build()
212235
}
213236

237+
/** Mirrors the widget's protect-button naming; other policies are not protective defaults today. */
238+
private fun protectActionLabel(context: Context, policy: ChargePolicy): String = when (policy) {
239+
is ChargePolicy.FixedLimit -> context.getString(
240+
R.string.gesture_notification_action_protect_fixed,
241+
policy.percent,
242+
)
243+
ChargePolicy.Adaptive -> context.getString(R.string.gesture_notification_action_protect_adaptive)
244+
else -> context.getString(R.string.gesture_notification_action_protect)
245+
}
246+
247+
/**
248+
* Both actions share [ChargeSessionService.ACTION_SET_PERSISTENT_POLICY] and differ only in an
249+
* extra, which does NOT factor into PendingIntent equality — hence the distinct [requestCode]
250+
* per action, or the second would overwrite the first's target.
251+
*/
252+
private fun persistentPolicyIntent(
253+
context: Context,
254+
requestCode: Int,
255+
policy: ChargePolicy,
256+
): PendingIntent = PendingIntent.getForegroundService(
257+
context,
258+
requestCode,
259+
Intent(context, ChargeSessionService::class.java)
260+
.setAction(ChargeSessionService.ACTION_SET_PERSISTENT_POLICY)
261+
.putExtra(ChargeSessionService.EXTRA_TARGET_POLICY, policy.stableId),
262+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
263+
)
264+
214265
/**
215266
* Progress while a restore converges on the charging hardware. Deliberately shares
216267
* [SESSION_CHANNEL] with [session] rather than the alerting recovery channel: this is the

app/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121
<string name="gesture_notification_armed_holding">Ready while your charge limit is holding: unplug for 2 seconds, then replug within 10 to charge fully.</string>
2222
<string name="gesture_notification_waiting_any_level">Waiting for a quick unplug and reconnect at any charge level.</string>
2323
<string name="gesture_notification_disable_hint">You can turn this notification off (long-press it) without stopping the gesture.</string>
24+
<string name="gesture_notification_action_protect_fixed">Limit to %1$d%%</string>
25+
<string name="gesture_notification_action_protect_adaptive">Adaptive charging</string>
26+
<string name="gesture_notification_action_protect">Charge limit</string>
27+
<string name="gesture_notification_action_always_full" formatted="false">Always 100%</string>
2428
<string name="recovery_channel_name">Charge policy recovery</string>
2529
<string name="recovery_channel_description">Alerts you when your protective charge limit could not be restored</string>
2630
<string name="recovery_notification_title">Charge limit needs attention</string>
2731
<string name="recovery_notification_body">Open Amply and restart Shizuku to restore your protective policy.</string>
2832
<string name="recovery_notification_body_convergence">The protective policy was rewritten, but the charging hardware has not confirmed it yet. Open Amply to check.</string>
33+
<string name="recovery_notification_body_unavailable">Your charge mode wasn\'t changed — charging control isn\'t available right now. Open Amply to check your setup.</string>
2934
<string name="recovering_notification_title">Restoring charge limit</string>
3035
<string name="recovering_notification_body">Confirming the protective policy with the charging hardware.</string>
3136
<string name="setup_unsupported_title">This device isn\'t supported yet</string>

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

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package eu.darken.amply.fullcharge.core
22

3+
import android.app.Application
34
import android.app.Notification
5+
import android.content.ComponentName
46
import android.content.Context
57
import androidx.test.core.app.ApplicationProvider
68
import eu.darken.amply.R
9+
import eu.darken.amply.charging.core.ChargePolicy
710
import io.kotest.matchers.shouldBe
811
import io.kotest.matchers.shouldNotBe
912
import io.kotest.matchers.string.shouldContain
1013
import io.kotest.matchers.string.shouldNotContain
1114
import org.junit.Test
1215
import org.junit.runner.RunWith
1316
import org.robolectric.RobolectricTestRunner
17+
import org.robolectric.Shadows.shadowOf
1418
import org.robolectric.annotation.Config
1519

1620
@RunWith(RobolectricTestRunner::class)
@@ -19,12 +23,33 @@ class SessionNotificationsGestureTest {
1923

2024
private val context: Context = ApplicationProvider.getApplicationContext()
2125

26+
private fun notification(
27+
decision: QuickFullChargeDecision,
28+
protectPolicy: ChargePolicy = ChargePolicy.FixedLimit(80),
29+
anyLevel: Boolean = false,
30+
limitPercent: Int? = null,
31+
): Notification = SessionNotifications.gesture(
32+
context,
33+
decision = decision,
34+
protectPolicy = protectPolicy,
35+
anyLevel = anyLevel,
36+
limitPercent = limitPercent,
37+
)
38+
39+
private fun actions(
40+
decision: QuickFullChargeDecision,
41+
protectPolicy: ChargePolicy = ChargePolicy.FixedLimit(80),
42+
): List<Notification.Action> = notification(decision, protectPolicy).actions?.toList().orEmpty()
43+
44+
private fun targetPolicyOf(action: Notification.Action): String? = shadowOf(action.actionIntent)
45+
.savedIntent
46+
.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY)
47+
2248
private fun text(
2349
decision: QuickFullChargeDecision,
2450
anyLevel: Boolean = false,
2551
limitPercent: Int? = null,
26-
): String? = SessionNotifications
27-
.gesture(context, decision = decision, anyLevel = anyLevel, limitPercent = limitPercent)
52+
): String? = notification(decision, anyLevel = anyLevel, limitPercent = limitPercent)
2853
.extras
2954
.getCharSequence(Notification.EXTRA_TEXT)
3055
?.toString()
@@ -33,8 +58,7 @@ class SessionNotificationsGestureTest {
3358
decision: QuickFullChargeDecision,
3459
anyLevel: Boolean = false,
3560
limitPercent: Int? = null,
36-
): String? = SessionNotifications
37-
.gesture(context, decision = decision, anyLevel = anyLevel, limitPercent = limitPercent)
61+
): String? = notification(decision, anyLevel = anyLevel, limitPercent = limitPercent)
3862
.extras
3963
.getCharSequence(Notification.EXTRA_BIG_TEXT)
4064
?.toString()
@@ -98,4 +122,63 @@ class SessionNotificationsGestureTest {
98122
bigText(QuickFullChargeDecision.ARMED)!! shouldContain hint
99123
bigText(QuickFullChargeDecision.WAITING_FOR_RECONNECT) shouldBe null
100124
}
125+
126+
@Test
127+
fun `only the standing states offer the mode actions`() {
128+
actions(QuickFullChargeDecision.IDLE).size shouldBe 2
129+
actions(QuickFullChargeDecision.ARMED).size shouldBe 2
130+
actions(QuickFullChargeDecision.WAITING_FOR_RECONNECT).size shouldBe 0
131+
actions(QuickFullChargeDecision.TRIGGER).size shouldBe 0
132+
}
133+
134+
@Test
135+
fun `the protect action is labelled after the adapter's protective default`() {
136+
actions(QuickFullChargeDecision.IDLE, ChargePolicy.FixedLimit(80))[0].title.toString() shouldBe
137+
context.getString(R.string.gesture_notification_action_protect_fixed, 80)
138+
actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)[0].title.toString() shouldBe
139+
context.getString(R.string.gesture_notification_action_protect_adaptive)
140+
actions(QuickFullChargeDecision.IDLE, ChargePolicy.PauseAtFull)[0].title.toString() shouldBe
141+
context.getString(R.string.gesture_notification_action_protect)
142+
}
143+
144+
@Test
145+
fun `the protect action targets the passed policy and the other one always full`() {
146+
targetPolicyOf(actions(QuickFullChargeDecision.IDLE, ChargePolicy.FixedLimit(80))[0]) shouldBe "fixed:80"
147+
targetPolicyOf(actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)[0]) shouldBe "adaptive"
148+
targetPolicyOf(actions(QuickFullChargeDecision.IDLE)[1]) shouldBe "unrestricted"
149+
}
150+
151+
@Test
152+
fun `both actions are distinct foreground-service intents for the session service`() {
153+
val (protect, alwaysFull) = actions(QuickFullChargeDecision.ARMED)
154+
155+
listOf(protect, alwaysFull).forEach { action ->
156+
val shadow = shadowOf(action.actionIntent)
157+
shadow.isForegroundService shouldBe true
158+
shadow.savedIntent.component shouldBe
159+
ComponentName(context, ChargeSessionService::class.java)
160+
shadow.savedIntent.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
161+
}
162+
// Extras don't factor into PendingIntent equality — only the distinct request codes keep
163+
// the second action from overwriting the first one's target.
164+
protect.actionIntent shouldNotBe alwaysFull.actionIntent
165+
}
166+
167+
@Test
168+
fun `sending an action starts the session service with its policy`() {
169+
val application: Application = ApplicationProvider.getApplicationContext()
170+
val (protect, alwaysFull) = actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)
171+
shadowOf(application).clearStartedServices()
172+
173+
protect.actionIntent.send()
174+
val protectStart = shadowOf(application).nextStartedService
175+
protectStart.component shouldBe ComponentName(context, ChargeSessionService::class.java)
176+
protectStart.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
177+
protectStart.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY) shouldBe "adaptive"
178+
179+
alwaysFull.actionIntent.send()
180+
val alwaysFullStart = shadowOf(application).nextStartedService
181+
alwaysFullStart.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
182+
alwaysFullStart.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY) shouldBe "unrestricted"
183+
}
101184
}

0 commit comments

Comments
 (0)