Skip to content

Widget: Make the quick charge-mode buttons configurable - #77

Merged
d4rken merged 9 commits into
mainfrom
feat/quick-action-buttons
Aug 17, 2026
Merged

Widget: Make the quick charge-mode buttons configurable#77
d4rken merged 9 commits into
mainfrom
feat/quick-action-buttons

Conversation

@d4rken

@d4rken d4rken commented Aug 17, 2026

Copy link
Copy Markdown
Member

What changed

  • The home-screen widget's two "always on" charge-mode buttons are now configurable per widget: when placing a widget (or long-pressing it and choosing edit on Android 12+), you can pick one to three of the charge modes your device actually supports — for example One UI 8's 80/85/90/95% limits and pause-at-full, or LineageOS's limit steps. The one-time "1×100%" button is always there.
  • The reconnect-gesture notification's buttons can be picked too, under Settings → Charging → Notification buttons (shown on devices that support the gesture and offer more than two charge modes).
  • Devices where Amply can't control charging are unchanged: the widget keeps its current buttons, and the configuration screen explains why there is nothing to pick while still letting the widget be placed.

Technical Context

  • Both surfaces share one resolver whose invariant is that no rendered button ever carries a policy outside the current adapter's supportedPolicies; setPersistentPolicy additionally refuses out-of-support targets before persisting, because a stale button (built before an app/ROM update changed the adapter) would otherwise strand a recovery target the repository rejects on every convergence attempt.
  • The config activity implements the AppWidget host contract for minSdk 26: configuration_optional only exists on API 31+, so on 26–30 every state — locked, loading, error, unsupported — offers an explicit "Add widget" exit returning RESULT_OK (a CANCELED exit discards the widget there). The activity is exported by necessity and validates the passed id belongs to Amply's provider before touching state; back is held while a save/update completion is still owed its result.
  • Per-widget selections live in one map record keyed by appWidgetId (cleaned up in Glance's onDelete); the notification selection is a separate global value; both toggle paths are single DataStore transactions so racing taps can't write out-of-bounds selections.
  • The widget keeps its legacy tap-time-resolved buttons (and the old ProtectAction/AlwaysFullAction callbacks, which stale launcher views may still dispatch by class name) whenever no adapter is resolved or the device offers fewer than two policies.
  • On-device coverage CI cannot provide is still outstanding: the placement/reconfigure flow on API 26–30 vs 31+, and 4-button rendering at the widget's 250dp minimum width with enlarged fonts.

d4rken added 6 commits August 17, 2026 14:26
The persistent-policy buttons on the reconnect notification and the widget
were a fixed pair. Both now resolve their policies through one pure unit,
which keeps the invariant those buttons need: no rendered policy is ever
outside the adapter's supported list. An unsupported target would persist a
recovery goal and cancel a running session before ChargingRepository rejects
the write, leaving the device converging on something it can never apply.

A device with two or fewer policies has nothing to choose between, so a
stored selection is ignored there rather than allowed to hide one of them.

The selections themselves live in FullChargeStore: one list for the
notification, one per-widget-id map for the widget, each a single JSON
record. The notification toggle applies the 1-3 bounds and the membership
rule inside one DataStore transaction, so two toggles racing from the same
rendered picker state cannot write an empty or oversized selection.
…ction

The gesture notification hardcoded two actions: the adapter's protective
default and "Always 100%". It now renders one action per resolved policy,
with a per-slot request code (extras don't factor into PendingIntent
equality, so a shared code would let a later action overwrite an earlier
one's target) and a label per policy, including the new pause-at-full one.

setPersistentPolicy() gains the matching refusal: alongside the existing
"nothing can write" guard it now refuses a target the current adapter does
not support. A notification action outlives the render that produced it, so
a button built for a previous adapter selection must not persist a recovery
target that never converges.
Adds a "Notification buttons" section to Settings > Charging, built on a
reusable QuickActionPolicyPicker: one row per charge mode the adapter
supports, one to three of them selectable. The bounds are expressed by
disabling rows (the last selected one cannot be unchecked, unchecked ones
go inert at three), which is presentation only - the store's transaction
remains the guard.

The section appears only where there is something to pick (more than two
policies) on a device that can use the gesture, and its rows follow the
gesture's own enabled state, since that notification is where the buttons
live. The dashboard state carries the available policies and the resolved
selection, computed only once adapter selection has actually run: the
capability defaults are permissive, so acting on them earlier would briefly
offer a choice the resolved adapter forbids.

SettingsSwitchItem takes a nullable subtitle, for rows whose title already
says everything.
Each widget instance can now carry its own set of charge-mode buttons,
picked in a new AppWidget configuration activity (reconfigurable, and
optional where the platform supports that). The 1x100% button stays fixed
and always last.

The rendering branches on adapterResolved, never on how many policies are
in the list: before selection has run the widget keeps today's two buttons,
whose actions resolve their policy at tap time. ProtectAction and
AlwaysFullAction are kept unchanged for exactly that path, and because
stale launcher RemoteViews dispatch them by class name across app updates.
The configured buttons carry their target as a stable id, since the
composition that rendered them can be minutes old.

The configuration activity is the one deliberate exception to the
single-Activity rule: the AppWidget host launches a configuration by
component name. It is exported, so it validates that the id belongs to
Amply's own provider before reading or writing anything, refreshes the
charging state itself (nothing else has during a launcher-initiated cold
start), and offers a confirming exit in every state - on API 26-30 a
cancelled configuration discards the widget being placed, so the upgrade
link must never be the only way out. A failed save keeps the result
cancelled; a failed render does not, since the next broadcast re-renders
anyway. Deleting a widget drops its stored selection so a recycled id
cannot inherit one.
A lab adapter resolves with an empty supported-policy list, so the
resolved branch produced an empty action list and the widget rendered
neither persistent-policy button - on every non-gated device. Those
states now fall back to the legacy rendering, whose buttons resolve
their policy at tap time and are guarded by the canApply pre-check in
setPersistentOrOpen and the service's supported-policy refusal.

Fixes review finding F4.
…result

Back during a confirming action finished the activity with the initial
RESULT_CANCELED while the save (and the best-effort widget render) was
still running - on API 26-30 the host then discards the widget the user
had just placed, even though its configuration was already stored.

The ViewModel now exposes completionInFlight, set synchronously before
either completion path launches its coroutine, which makes a second
completion a no-op and is cleared only where complete() stays on the
picker for a retry. The activity installs an inert BackHandler for that
window and the screen renders every control disabled from the same flag,
which replaces the Ready-only `saving` field.

Fixes review finding F1.
@github-actions github-actions Bot added the enhancement New feature or request label Aug 17, 2026
The provider declared no android:minResize*, so the launcher used
minHeight (110dp, the classic 2-cell height) as the resize floor and the
widget could never shrink below two rows. Its content is only ~90dp tall
and centered, so a 2-cell instance shows large empty bands.

minResizeHeight=40dp / minResizeWidth=110dp set the floors explicitly;
minHeight and the target cells are unchanged, so default placement stays
4x2. Below 80dp of height the widget now renders with reduced paddings
(outer 12dp -> 6dp, button row top 10dp -> 4dp) so the buttons are not
pushed out of a 1-cell instance. The status line stays in compact mode:
dropping information reads worse than slight clipping on an unusually
tight grid.
@d4rken d4rken closed this Aug 17, 2026
@d4rken d4rken reopened this Aug 17, 2026
d4rken added 2 commits August 17, 2026 20:03
…tons

# Conflicts:
#	app/src/main/java/eu/darken/amply/fullcharge/core/ChargeSessionService.kt
#	app/src/main/res/values/strings.xml
The charge-conditions merge widened the smart cast on the resolved adapter default, so the assertion is now dead weight and warns at compile time.
@d4rken
d4rken merged commit f823b7a into main Aug 17, 2026
12 checks passed
@d4rken
d4rken deleted the feat/quick-action-buttons branch August 17, 2026 18:17
d4rken added a commit that referenced this pull request Aug 17, 2026
Main landed the observed-enforcement gate (#79) and the quick-action
buttons (#77) while this branch was waiting on CI.

Two textual conflicts, both the same shape and both in test setup where
main added ChargingRepository's evidenceStore/buildIdentity arguments on
the exact line this branch added BatteryReader's unitCalibration
argument. Both sides are wanted, so both were kept.

A third break was semantic rather than textual, so git merged it cleanly
and the compiler caught it: ChargingRepositoryRestoreGateTest arrived
with main as a new file and constructs BatteryReader without the
calibration argument this branch added. Same fix as the other five call
sites.

Verified after resolution: 1345 tests across both flavors. Checked
specifically that main's adapter-gating rework did not invalidate this
branch's UnmappedDeviceSettingsIntentTest, which asserts a device matches
no adapter at all; LineageOS now matches on the provider rather than a
codename allowlist, but a bare Robolectric device still selects nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant