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 @@ -427,6 +427,9 @@ class ChargeSessionService : Service() {
SessionNotifications.gesture(
this,
decision = decision,
// The notification's mode actions write persistently, so they must offer this
// adapter's declared protective default, not a hardcoded limit.
protectPolicy = adapter.defaultProtectivePolicy,
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
Expand Down Expand Up @@ -643,6 +646,10 @@ class ChargeSessionService : Service() {
// would strand a pending target that never converges. The app's controls guide setup.
if (!repository.refresh().canApply) {
log(TAG, Logging.Priority.WARN) { "Persistent policy skipped: charging control not writable" }
// Tell the user why nothing happened. The widget/tile pre-check writability and open the
// app instead of dispatching, but a notification action cannot pre-check, so without this
// its tap is a silent no-op (it also covers a surface racing a lost write capability).
SessionNotifications.showRecovery(this, R.string.recovery_notification_body_unavailable)
SurfaceUpdater.updateNow(this)
continueGestureOrStop()
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import android.os.Build
import eu.darken.amply.R
import eu.darken.amply.charging.core.ChargePolicy
import eu.darken.amply.main.ui.MainActivity

object SessionNotifications {
Expand Down Expand Up @@ -146,9 +147,15 @@ object SessionNotifications {
.build()
}

/**
* [protectPolicy] is the adapter's declared protective default and has no default value on
* purpose: the action writes it persistently, so the capability has to be handed in by the
* caller that resolved the adapter rather than guessed here.
*/
fun gesture(
context: Context,
decision: QuickFullChargeDecision,
protectPolicy: ChargePolicy,
anyLevel: Boolean = false,
limitPercent: Int? = null,
): Notification {
Expand Down Expand Up @@ -208,9 +215,53 @@ object SessionNotifications {
),
)
}
// Mode switches only on the two standing states. An explicit allowlist, not "not
// WAITING_FOR_RECONNECT": TRIGGER also reaches this builder, and neither the 10s countdown
// nor the tick that starts a full charge should offer a competing persistent write.
if (decision == QuickFullChargeDecision.IDLE || decision == QuickFullChargeDecision.ARMED) {
builder
.addAction(
R.drawable.ic_launcher_monochrome,
protectActionLabel(context, protectPolicy),
persistentPolicyIntent(context, 8, protectPolicy),
)
.addAction(
R.drawable.ic_launcher_monochrome,
context.getString(R.string.gesture_notification_action_always_full),
persistentPolicyIntent(context, 9, ChargePolicy.Unrestricted),
)
}
return builder.build()
}

/** Mirrors the widget's protect-button naming; other policies are not protective defaults today. */
private fun protectActionLabel(context: Context, policy: ChargePolicy): String = when (policy) {
is ChargePolicy.FixedLimit -> context.getString(
R.string.gesture_notification_action_protect_fixed,
policy.percent,
)
ChargePolicy.Adaptive -> context.getString(R.string.gesture_notification_action_protect_adaptive)
else -> context.getString(R.string.gesture_notification_action_protect)
}

/**
* Both actions share [ChargeSessionService.ACTION_SET_PERSISTENT_POLICY] and differ only in an
* extra, which does NOT factor into PendingIntent equality — hence the distinct [requestCode]
* per action, or the second would overwrite the first's target.
*/
private fun persistentPolicyIntent(
context: Context,
requestCode: Int,
policy: ChargePolicy,
): PendingIntent = PendingIntent.getForegroundService(
context,
requestCode,
Intent(context, ChargeSessionService::class.java)
.setAction(ChargeSessionService.ACTION_SET_PERSISTENT_POLICY)
.putExtra(ChargeSessionService.EXTRA_TARGET_POLICY, policy.stableId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

/**
* Progress while a restore converges on the charging hardware. Deliberately shares
* [SESSION_CHANNEL] with [session] rather than the alerting recovery channel: this is the
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@
<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>
<string name="gesture_notification_waiting_any_level">Waiting for a quick unplug and reconnect at any charge level.</string>
<string name="gesture_notification_disable_hint">You can turn this notification off (long-press it) without stopping the gesture.</string>
<string name="gesture_notification_action_protect_fixed">Limit to %1$d%%</string>
<string name="gesture_notification_action_protect_adaptive">Adaptive charging</string>
<string name="gesture_notification_action_protect">Charge limit</string>
<string name="gesture_notification_action_always_full" formatted="false">Always 100%</string>
<string name="recovery_channel_name">Charge policy recovery</string>
<string name="recovery_channel_description">Alerts you when your protective charge limit could not be restored</string>
<string name="recovery_notification_title">Charge limit needs attention</string>
<string name="recovery_notification_body">Open Amply and restart Shizuku to restore your protective policy.</string>
<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>
<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>
<string name="recovering_notification_title">Restoring charge limit</string>
<string name="recovering_notification_body">Confirming the protective policy with the charging hardware.</string>
<string name="setup_unsupported_title">This device isn\'t supported yet</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package eu.darken.amply.fullcharge.core

import android.app.Application
import android.app.Notification
import android.content.ComponentName
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import eu.darken.amply.R
import eu.darken.amply.charging.core.ChargePolicy
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

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

private val context: Context = ApplicationProvider.getApplicationContext()

private fun notification(
decision: QuickFullChargeDecision,
protectPolicy: ChargePolicy = ChargePolicy.FixedLimit(80),
anyLevel: Boolean = false,
limitPercent: Int? = null,
): Notification = SessionNotifications.gesture(
context,
decision = decision,
protectPolicy = protectPolicy,
anyLevel = anyLevel,
limitPercent = limitPercent,
)

private fun actions(
decision: QuickFullChargeDecision,
protectPolicy: ChargePolicy = ChargePolicy.FixedLimit(80),
): List<Notification.Action> = notification(decision, protectPolicy).actions?.toList().orEmpty()

private fun targetPolicyOf(action: Notification.Action): String? = shadowOf(action.actionIntent)
.savedIntent
.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY)

private fun text(
decision: QuickFullChargeDecision,
anyLevel: Boolean = false,
limitPercent: Int? = null,
): String? = SessionNotifications
.gesture(context, decision = decision, anyLevel = anyLevel, limitPercent = limitPercent)
): String? = notification(decision, anyLevel = anyLevel, limitPercent = limitPercent)
.extras
.getCharSequence(Notification.EXTRA_TEXT)
?.toString()
Expand All @@ -33,8 +58,7 @@ class SessionNotificationsGestureTest {
decision: QuickFullChargeDecision,
anyLevel: Boolean = false,
limitPercent: Int? = null,
): String? = SessionNotifications
.gesture(context, decision = decision, anyLevel = anyLevel, limitPercent = limitPercent)
): String? = notification(decision, anyLevel = anyLevel, limitPercent = limitPercent)
.extras
.getCharSequence(Notification.EXTRA_BIG_TEXT)
?.toString()
Expand Down Expand Up @@ -98,4 +122,63 @@ class SessionNotificationsGestureTest {
bigText(QuickFullChargeDecision.ARMED)!! shouldContain hint
bigText(QuickFullChargeDecision.WAITING_FOR_RECONNECT) shouldBe null
}

@Test
fun `only the standing states offer the mode actions`() {
actions(QuickFullChargeDecision.IDLE).size shouldBe 2
actions(QuickFullChargeDecision.ARMED).size shouldBe 2
actions(QuickFullChargeDecision.WAITING_FOR_RECONNECT).size shouldBe 0
actions(QuickFullChargeDecision.TRIGGER).size shouldBe 0
}

@Test
fun `the protect action is labelled after the adapter's protective default`() {
actions(QuickFullChargeDecision.IDLE, ChargePolicy.FixedLimit(80))[0].title.toString() shouldBe
context.getString(R.string.gesture_notification_action_protect_fixed, 80)
actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)[0].title.toString() shouldBe
context.getString(R.string.gesture_notification_action_protect_adaptive)
actions(QuickFullChargeDecision.IDLE, ChargePolicy.PauseAtFull)[0].title.toString() shouldBe
context.getString(R.string.gesture_notification_action_protect)
}

@Test
fun `the protect action targets the passed policy and the other one always full`() {
targetPolicyOf(actions(QuickFullChargeDecision.IDLE, ChargePolicy.FixedLimit(80))[0]) shouldBe "fixed:80"
targetPolicyOf(actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)[0]) shouldBe "adaptive"
targetPolicyOf(actions(QuickFullChargeDecision.IDLE)[1]) shouldBe "unrestricted"
}

@Test
fun `both actions are distinct foreground-service intents for the session service`() {
val (protect, alwaysFull) = actions(QuickFullChargeDecision.ARMED)

listOf(protect, alwaysFull).forEach { action ->
val shadow = shadowOf(action.actionIntent)
shadow.isForegroundService shouldBe true
shadow.savedIntent.component shouldBe
ComponentName(context, ChargeSessionService::class.java)
shadow.savedIntent.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
}
// Extras don't factor into PendingIntent equality — only the distinct request codes keep
// the second action from overwriting the first one's target.
protect.actionIntent shouldNotBe alwaysFull.actionIntent
}

@Test
fun `sending an action starts the session service with its policy`() {
val application: Application = ApplicationProvider.getApplicationContext()
val (protect, alwaysFull) = actions(QuickFullChargeDecision.IDLE, ChargePolicy.Adaptive)
shadowOf(application).clearStartedServices()

protect.actionIntent.send()
val protectStart = shadowOf(application).nextStartedService
protectStart.component shouldBe ComponentName(context, ChargeSessionService::class.java)
protectStart.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
protectStart.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY) shouldBe "adaptive"

alwaysFull.actionIntent.send()
val alwaysFullStart = shadowOf(application).nextStartedService
alwaysFullStart.action shouldBe ChargeSessionService.ACTION_SET_PERSISTENT_POLICY
alwaysFullStart.getStringExtra(ChargeSessionService.EXTRA_TARGET_POLICY) shouldBe "unrestricted"
}
}