Skip to content

Commit f823b7a

Browse files
authored
Merge pull request #77 from d4rken-org/feat/quick-action-buttons
Widget: Make the quick charge-mode buttons configurable
2 parents e79dbae + d9a41bf commit f823b7a

25 files changed

Lines changed: 1841 additions & 134 deletions

app/src/debug/java/eu/darken/amply/screenshots/ScreenshotContent.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ internal fun ReconnectGestureContent() = PreviewWrapper {
6767
gestureEnabled = true,
6868
anyLevelEnabled = false,
6969
canEnableGesture = true,
70+
// A two-policy device, so the shot stays on the gesture options themselves.
71+
availablePolicies = listOf(ChargePolicy.FixedLimit(80), ChargePolicy.Unrestricted),
72+
selectedPolicyIds = listOf(ChargePolicy.FixedLimit(80).stableId, ChargePolicy.Unrestricted.stableId),
7073
onBack = {},
7174
onGestureEnabledChange = {},
7275
onAnyLevelChange = {},
76+
onNotificationPolicyToggle = { _, _ -> },
7377
)
7478
}
7579

app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@
7777
</intent-filter>
7878
</activity>
7979

80+
<!-- The AppWidget host launches a configuration activity by component name, so this one
81+
cannot live inside MainActivity. It hosts a single Compose screen, no navigation. -->
82+
<activity
83+
android:name=".main.ui.widget.WidgetConfigActivity"
84+
android:excludeFromRecents="true"
85+
android:exported="true">
86+
<intent-filter>
87+
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
88+
</intent-filter>
89+
</activity>
90+
8091
<service
8192
android:name=".fullcharge.core.ChargeSessionService"
8293
android:exported="false"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package eu.darken.amply.common.settings
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.runtime.Composable
5+
import eu.darken.amply.charging.core.ChargePolicy
6+
import eu.darken.amply.common.compose.AmplyPreview
7+
import eu.darken.amply.common.compose.PreviewWrapper
8+
import eu.darken.amply.common.compose.asComposable
9+
import eu.darken.amply.fullcharge.core.MAX_QUICK_ACTION_POLICIES
10+
11+
/**
12+
* Picks which charge modes a surface offers as persistent-policy buttons (the reconnect notification,
13+
* a widget instance). One to [MAX_QUICK_ACTION_POLICIES] may be selected, enforced by disabling the
14+
* rows that would break the bound: the last selected row cannot be unchecked, and unchecked rows go
15+
* inert once the maximum is reached. That is presentation only — the writing side re-checks both
16+
* bounds and adapter membership.
17+
*
18+
* [rowsEnabled] switches the whole picker off (e.g. while the feature owning the buttons is disabled)
19+
* without hiding it, so the user can see what they would be configuring.
20+
*/
21+
@Composable
22+
fun QuickActionPolicyPicker(
23+
availablePolicies: List<ChargePolicy>,
24+
selectedPolicyIds: List<String>,
25+
onToggle: (ChargePolicy, Boolean) -> Unit,
26+
rowsEnabled: Boolean = true,
27+
) {
28+
Column {
29+
availablePolicies.forEach { policy ->
30+
val checked = policy.stableId in selectedPolicyIds
31+
val boundAllows = if (checked) {
32+
selectedPolicyIds.size > 1
33+
} else {
34+
selectedPolicyIds.size < MAX_QUICK_ACTION_POLICIES
35+
}
36+
SettingsSwitchItem(
37+
title = policy.label.asComposable(),
38+
subtitle = null,
39+
checked = checked,
40+
onCheckedChange = { onToggle(policy, it) },
41+
enabled = rowsEnabled && boundAllows,
42+
)
43+
}
44+
}
45+
}
46+
47+
@AmplyPreview
48+
@Composable
49+
private fun QuickActionPolicyPickerPreview() = PreviewWrapper {
50+
QuickActionPolicyPicker(
51+
availablePolicies = listOf(
52+
ChargePolicy.FixedLimit(80),
53+
ChargePolicy.FixedLimit(90),
54+
ChargePolicy.Adaptive,
55+
ChargePolicy.Unrestricted,
56+
),
57+
selectedPolicyIds = listOf("fixed:80", "adaptive", "unrestricted"),
58+
onToggle = { _, _ -> },
59+
)
60+
}

app/src/main/java/eu/darken/amply/common/settings/SettingsComponents.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ fun SettingsPreferenceItem(
106106
@Composable
107107
fun SettingsSwitchItem(
108108
title: String,
109-
subtitle: String,
109+
// Nullable: a row whose title already says everything (a charge-mode pick) has no second line.
110+
subtitle: String?,
110111
checked: Boolean,
111112
onCheckedChange: (Boolean) -> Unit,
112113
icon: ImageVector? = null,

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import eu.darken.amply.charging.core.ChargingRepository
2222
import eu.darken.amply.charging.core.adapter.AdapterRegistry
2323
import eu.darken.amply.charging.core.adapter.ChargingAdapter
2424
import eu.darken.amply.charging.core.enforcement.EnforcementEvidenceState
25+
import eu.darken.amply.common.datastore.value
2526
import eu.darken.amply.common.debug.logging.Logging
2627
import eu.darken.amply.common.debug.logging.log
2728
import eu.darken.amply.common.debug.logging.logTag
@@ -622,9 +623,14 @@ class ChargeSessionService : Service() {
622623
SessionNotifications.gesture(
623624
this,
624625
decision = decision,
625-
// The notification's mode actions write persistently, so they must offer this
626-
// adapter's declared protective default, not a hardcoded limit.
627-
protectPolicy = adapter.defaultProtectivePolicy,
626+
// The notification's mode actions write persistently, so they must offer
627+
// policies this adapter actually supports — the user's picked set where one
628+
// exists, this adapter's default pair otherwise.
629+
actionPolicies = resolveQuickActionPolicies(
630+
fullChargeStore.gestureNotificationPolicies.value(),
631+
adapter.supportedPolicies,
632+
adapter.defaultProtectivePolicy,
633+
),
628634
anyLevel = when (decision) {
629635
// The armed copy states the condition the gesture will fire under. The
630636
// latched basis is not that condition: at the limit, LIMIT_HOLD wins the
@@ -882,7 +888,8 @@ class ChargeSessionService : Service() {
882888
// Central guard: if no backend can write (e.g. a Shizuku-only adapter with Shizuku not
883889
// connected), refuse before persisting a recovery target — otherwise a widget/tile tap
884890
// would strand a pending target that never converges. The app's controls guide setup.
885-
if (!repository.refresh().canApply) {
891+
val state = repository.refresh()
892+
if (!state.canApply) {
886893
log(TAG, Logging.Priority.WARN) { "Persistent policy skipped: charging control not writable" }
887894
// Tell the user why nothing happened. The widget/tile pre-check writability and open the
888895
// app instead of dispatching, but a notification action cannot pre-check, so without this
@@ -892,6 +899,19 @@ class ChargeSessionService : Service() {
892899
continueGestureOrStop()
893900
return
894901
}
902+
// Same refusal for a target this adapter cannot apply: a notification action (or a widget
903+
// button) built for a previous adapter selection outlives the render that produced it, and
904+
// persisting its recovery target would leave the device converging on something the
905+
// repository rejects on every attempt.
906+
if (policy !in state.supportedPolicies) {
907+
log(TAG, Logging.Priority.WARN) {
908+
"Persistent policy skipped: ${policy.stableId} is not supported by the current adapter"
909+
}
910+
SessionNotifications.showRecovery(this, R.string.recovery_notification_body_unavailable)
911+
SurfaceUpdater.updateNow(this)
912+
continueGestureOrStop()
913+
return
914+
}
895915
// Persist the intended end state as the recovery target BEFORE the risky write and before dropping
896916
// the session, so a failed write or a mid-write process death still converges here on next boot
897917
// instead of leaving charging in whatever transient state the session had. An explicit persistent

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,30 @@ class FullChargeStore @Inject constructor(
133133
val quickFullChargeEnabled = dataStore.createValue("fullcharge.quick_replug_enabled.v2", false)
134134
val quickFullChargeAnyLevel = dataStore.createValue("fullcharge.quick_replug_any_level.v2", false)
135135

136+
/**
137+
* The persistent-policy buttons the reconnect notification offers, as [ChargePolicy.stableId]s.
138+
* Null means never configured, which [resolveQuickActionPolicies] reads as the default pair;
139+
* a corrupt record decodes to null for the same reason (the selection is cosmetic, and losing it
140+
* costs the user a re-pick rather than a charge policy).
141+
*/
142+
val gestureNotificationPolicies = dataStore.createValue<List<String>?>(
143+
key = "fullcharge.gesture_notification_policies.v1",
144+
defaultValue = null,
145+
json = json,
146+
fallbackToDefault = true,
147+
)
148+
149+
/**
150+
* Per-widget-instance button selection, keyed by AppWidget id — one record under one key, so a
151+
* widget being configured while another is deleted cannot write a half-updated map.
152+
*/
153+
val widgetQuickActions = dataStore.createValue<Map<Int, List<String>>?>(
154+
key = "widget.quick_actions.v1",
155+
defaultValue = null,
156+
json = json,
157+
fallbackToDefault = true,
158+
)
159+
136160
suspend fun currentSession(): ChargeSessionRecord? = sessionValue.value()?.normalized()
137161

138162
suspend fun startSession(
@@ -245,6 +269,35 @@ class FullChargeStore @Inject constructor(
245269
suspend fun setQuickFullChargeAnyLevel(enabled: Boolean) {
246270
quickFullChargeAnyLevel.value(enabled)
247271
}
272+
273+
/**
274+
* Flip one notification button on or off. The whole read-modify-write happens inside a single
275+
* DataStore transaction, and the membership/bounds rules are enforced *there*, not in the UI:
276+
* the picker's disabled rows are presentation, and two toggles racing from the same rendered
277+
* state must not be able to write an empty (or oversized, or unsupported) selection.
278+
*/
279+
suspend fun toggleGestureNotificationPolicy(
280+
policy: ChargePolicy,
281+
selected: Boolean,
282+
supported: List<ChargePolicy>,
283+
defaultProtective: ChargePolicy,
284+
) {
285+
gestureNotificationPolicies.update { stored ->
286+
val current = resolveQuickActionPolicies(stored, supported, defaultProtective)
287+
toggleQuickActionPolicy(current, policy, selected, supported).map { it.stableId }
288+
}
289+
}
290+
291+
suspend fun setWidgetQuickActions(appWidgetId: Int, ids: List<String>) {
292+
widgetQuickActions.update { current -> current.orEmpty() + (appWidgetId to ids) }
293+
}
294+
295+
/** Drop the selections of deleted widgets; an emptied map clears the key entirely. */
296+
suspend fun removeWidgetQuickActions(appWidgetIds: Collection<Int>) {
297+
widgetQuickActions.update { current ->
298+
current?.minus(appWidgetIds.toSet())?.takeIf { it.isNotEmpty() }
299+
}
300+
}
248301
}
249302

250303
/**
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package eu.darken.amply.fullcharge.core
2+
3+
import eu.darken.amply.charging.core.ChargePolicy
4+
5+
/** How many persistent-policy buttons a surface may show; more do not fit a notification row. */
6+
const val MAX_QUICK_ACTION_POLICIES = 3
7+
8+
/**
9+
* Which persistent-policy buttons a surface (reconnect notification, widget) shows, from the stored
10+
* [storedIds] selection and the adapter's capabilities.
11+
*
12+
* **Invariant: every returned policy is a member of [supported]**, in every branch including the
13+
* fallbacks. The buttons dispatch [ChargeSessionService.ACTION_SET_PERSISTENT_POLICY], which persists
14+
* a recovery target and cancels any running session *before* [eu.darken.amply.charging.core.ChargingRepository]
15+
* rejects the write — an unsupported policy would therefore leave the device converging on a target
16+
* that can never be applied.
17+
*
18+
* A device with two or fewer policies has nothing to choose between, so a stored selection is ignored
19+
* there rather than allowed to hide one of the two.
20+
*/
21+
fun resolveQuickActionPolicies(
22+
storedIds: List<String>?,
23+
supported: List<ChargePolicy>,
24+
defaultProtective: ChargePolicy,
25+
): List<ChargePolicy> {
26+
// Today's fixed pair, filtered: an adapter that supports neither (or a defaultProtective that
27+
// isn't in its own supported list) yields fewer buttons rather than an unusable one.
28+
val fallback = listOf(defaultProtective, ChargePolicy.Unrestricted)
29+
.distinct()
30+
.filter { it in supported }
31+
if (supported.size <= 2) return fallback
32+
val stored = storedIds.orEmpty()
33+
.mapNotNull { ChargePolicy.fromStableId(it) }
34+
.filter { it in supported }
35+
.distinct()
36+
.sortedBy { supported.indexOf(it) }
37+
.take(MAX_QUICK_ACTION_POLICIES)
38+
return stored.ifEmpty { fallback }
39+
}
40+
41+
/**
42+
* Apply one picker row's toggle to [current], keeping the 1–[MAX_QUICK_ACTION_POLICIES] bounds and
43+
* the membership invariant above. Out-of-bounds toggles return [current] unchanged, so a stale UI
44+
* (or a caller that skipped the row's `enabled` state) can neither empty the selection nor grow it
45+
* past what a surface renders.
46+
*/
47+
fun toggleQuickActionPolicy(
48+
current: List<ChargePolicy>,
49+
policy: ChargePolicy,
50+
selected: Boolean,
51+
supported: List<ChargePolicy>,
52+
): List<ChargePolicy> {
53+
val sanitized = current.filter { it in supported }.distinct()
54+
val next = when {
55+
selected -> when {
56+
policy !in supported || policy in sanitized -> sanitized
57+
sanitized.size >= MAX_QUICK_ACTION_POLICIES -> sanitized
58+
else -> sanitized + policy
59+
}
60+
sanitized.size <= 1 -> sanitized
61+
else -> sanitized - policy
62+
}
63+
return next.sortedBy { supported.indexOf(it) }
64+
}

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

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import eu.darken.amply.main.ui.MainActivity
1919
object SessionNotifications {
2020
const val SESSION_ID = 4101
2121
private const val RECOVERY_ID = 4102
22+
private const val ACTION_REQUEST_CODE_BASE = 8
2223
// Channel ids are invisible to the user and permanent — changing one resets that channel's
2324
// settings — so this keeps its original id while its display name has moved on.
2425
private const val SESSION_CHANNEL = "temporary_full_charge"
@@ -164,14 +165,15 @@ object SessionNotifications {
164165
}
165166

166167
/**
167-
* [protectPolicy] is the adapter's declared protective default and has no default value on
168-
* purpose: the action writes it persistently, so the capability has to be handed in by the
169-
* caller that resolved the adapter rather than guessed here.
168+
* [actionPolicies] are the persistent-policy buttons to offer, already resolved and validated
169+
* against the adapter by the caller (see [resolveQuickActionPolicies]) — no default value on
170+
* purpose: these actions write persistently, so the capability has to be handed in rather than
171+
* guessed here.
170172
*/
171173
fun gesture(
172174
context: Context,
173175
decision: QuickFullChargeDecision,
174-
protectPolicy: ChargePolicy,
176+
actionPolicies: List<ChargePolicy>,
175177
anyLevel: Boolean = false,
176178
limitPercent: Int? = null,
177179
): Notification {
@@ -235,35 +237,34 @@ object SessionNotifications {
235237
// WAITING_FOR_RECONNECT": TRIGGER also reaches this builder, and neither the 10s countdown
236238
// nor the tick that starts a full charge should offer a competing persistent write.
237239
if (decision == QuickFullChargeDecision.IDLE || decision == QuickFullChargeDecision.ARMED) {
238-
builder
239-
.addAction(
240+
actionPolicies.forEachIndexed { index, policy ->
241+
builder.addAction(
240242
R.drawable.ic_launcher_monochrome,
241-
protectActionLabel(context, protectPolicy),
242-
persistentPolicyIntent(context, 8, protectPolicy),
243-
)
244-
.addAction(
245-
R.drawable.ic_launcher_monochrome,
246-
context.getString(R.string.gesture_notification_action_always_full),
247-
persistentPolicyIntent(context, 9, ChargePolicy.Unrestricted),
243+
policyActionLabel(context, policy),
244+
persistentPolicyIntent(context, ACTION_REQUEST_CODE_BASE + index, policy),
248245
)
246+
}
249247
}
250248
return builder.build()
251249
}
252250

253-
/** Mirrors the widget's protect-button naming; other policies are not protective defaults today. */
254-
private fun protectActionLabel(context: Context, policy: ChargePolicy): String = when (policy) {
251+
/** Mirrors the widget's button naming. */
252+
private fun policyActionLabel(context: Context, policy: ChargePolicy): String = when (policy) {
255253
is ChargePolicy.FixedLimit -> context.getString(
256254
R.string.gesture_notification_action_protect_fixed,
257255
policy.percent,
258256
)
259257
ChargePolicy.Adaptive -> context.getString(R.string.gesture_notification_action_protect_adaptive)
258+
ChargePolicy.Unrestricted -> context.getString(R.string.gesture_notification_action_always_full)
259+
ChargePolicy.PauseAtFull -> context.getString(R.string.gesture_notification_action_pause_at_full)
260260
else -> context.getString(R.string.gesture_notification_action_protect)
261261
}
262262

263263
/**
264-
* Both actions share [ChargeSessionService.ACTION_SET_PERSISTENT_POLICY] and differ only in an
264+
* All actions share [ChargeSessionService.ACTION_SET_PERSISTENT_POLICY] and differ only in an
265265
* extra, which does NOT factor into PendingIntent equality — hence the distinct [requestCode]
266-
* per action, or the second would overwrite the first's target.
266+
* per slot, or a later action would overwrite an earlier one's target. The base leaves the
267+
* request codes this file already uses (1–5, 7) alone.
267268
*/
268269
private fun persistentPolicyIntent(
269270
context: Context,

app/src/main/java/eu/darken/amply/main/ui/MainActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,8 @@ class MainActivity : ComponentActivity() {
569569
// Exactly the dashboard card's gate, so settings can never switch the
570570
// gesture on where the card correctly forbids it.
571571
canEnableGesture = state.charging.reconnectSupported && state.charging.canApply,
572+
availablePolicies = state.notificationActionPolicies,
573+
selectedPolicyIds = state.notificationActionSelection,
572574
onBack = { destination = SettingsDestination.SETTINGS },
573575
onGestureEnabledChange = { enabled ->
574576
if (enabled) {
@@ -578,6 +580,7 @@ class MainActivity : ComponentActivity() {
578580
}
579581
},
580582
onAnyLevelChange = viewModel::setQuickFullChargeAnyLevel,
583+
onNotificationPolicyToggle = viewModel::toggleGestureNotificationPolicy,
581584
)
582585
SettingsDestination.CHARGE_RULES -> ChargeRulesScreen(
583586
state = rulesState,

0 commit comments

Comments
 (0)