Skip to content

Charging: Add conditional charge rules (Bluetooth and charger type) - #75

Merged
d4rken merged 26 commits into
mainfrom
feat/charge-conditions
Aug 17, 2026
Merged

Charging: Add conditional charge rules (Bluetooth and charger type)#75
d4rken merged 26 commits into
mainfrom
feat/charge-conditions

Conversation

@d4rken

@d4rken d4rken commented Aug 16, 2026

Copy link
Copy Markdown
Member

What changed

Amply can now switch the charging policy automatically based on conditions. A rule pairs a condition — a specific Bluetooth device being connected, or the charger type (AC/USB/wireless/dock) — with a charging policy: for example "in the car (hands-free connected), hold 80%", or "on the bedside dock, allow a full charge". Rules live in one priority-ordered list; the highest matching rule wins, and its policy is applied temporarily and taken back off when the condition ends.

The dashboard gains a conditions card showing the rules in order with the currently winning one highlighted, and the status card names the rule that set the current policy. Rules are managed on a dedicated screen (reorder, enable, edit; Bluetooth device picker from paired devices), reachable from the card and from Settings. Manual choices stay in charge: picking a policy yourself or via the widget pauses the matching rules until their condition next re-occurs, and a one-time full charge runs to completion before rules resume. Changes made in the OEM's own settings are adopted, never fought. Conditional rules are a Pro feature; on GrapheneOS, charger-type conditions are unavailable because that system only samples the charging policy when a charger is plugged in.

This re-lands #72 (reverted in #74 before it had been tested on hardware); the code is identical to what #72 merged.

Technical Context

  • Rule writes are temporary-class policy writes: the user's persistent baseline journal is never touched by automation, and it is what deactivation restores (freshest readback first, journal as fallback). A readable-but-unrecognized OEM state refuses activation outright — the layer never replaces a state it cannot put back.
  • Crash-safety is write-ahead: every transition persists its intent (phase, target, baseline) before the settings write and finalizes after. Overwrites by other components are detected two ways: configured readback where the adapter supports it, and the shared write journal everywhere else — every internal write path records there, so a newer journal entry naming a different policy proves the rules layer was overwritten even on adapters with no readback.
  • Precedence is ownership handoff, not polling: a starting one-time session takes the rule's saved baseline as its restore target inside the session-start transaction, and an explicit persistent write durably suspends the whole matching rule cohort before the write happens.
  • The evaluation runs as a serialized step of the charge-session service (never inside the budget-bounded watcher ticks); the Bluetooth ACL receiver only persists the connected-set and nudges the service. RuleEngine (pure, JVM-tested) plus the RuleApplier/ChargeSessionService integration points are where review attention pays off most.
  • Draft until the physical device matrix has been run on hardware (Bluetooth connect/disconnect transitions, reboot with an active rule, process kill, Shizuku loss, permission revocation, GrapheneOS plug-latch, session-during-rule handoff) — emulators can exercise none of this.

d4rken added 12 commits August 16, 2026 19:32
…gine

Conditional charge rules let a policy follow a context: a Bluetooth device
being connected, or the phone sitting on a particular charger class. This is
the model and the decision layer; nothing writes a policy yet.

Three deliberate shapes:

- The rule KIND (protection vs charge) is derived from the policy's
  allowsFullCharge, never stored, so a label and its policy cannot disagree.
  Priority is one user-ordered list across both kinds: the first enabled
  matching rule wins.
- RuleRuntimeState is a write-ahead record (phase, target, baseline, the
  suspension cohort) so a process death mid-transition leaves a pending phase
  to reconcile rather than a policy nobody knows to restore. It decodes field
  by field, like ChargingPreferences' policy state, because it carries the
  user's own baseline: one unreadable field must not take that with it. An
  unreadable phase alongside a recorded activation reads as ACTIVE, the only
  direction that cannot lose owed work.
- RuleEngine is pure and holds the entire precedence stack (session > explicit
  persistent write > external change > rules > baseline). Baseline resolution
  prefers a fresh readback over Amply's journal, and refuses activation
  outright on a readable-but-unrecognized native value — a mode Amply cannot
  name is one it cannot put back.

Charger-type conditions are capability-gated off where the ROM samples the
policy at plug time (policyLatchesAtPlug): the write would always land after
the sample, so such a rule could never take effect.
RuleApplier is the rules layer's single serialization point: every store
mutation (editor CRUD, reorder, toggle, the Bluetooth receiver's snapshot) and
every evaluation runs under one mutex. Without that, a rule deleted from the UI
could interleave with an in-flight activation and leave the runtime owning a
policy on behalf of a rule that no longer exists, with nothing left to restore
the baseline. It never takes the session service's dispatch lock, so there is
one lock ordering and no cycle.

Transitions persist the intent before the write and finalize after it. A failed
write keeps the pending phase and raises lastApplyFailed; the ~30s monitor tick
is the retry. Writes are always applyTemporary, so lastPersistentPolicy stays
the user's own choice and remains the fallback baseline.

The entitlement is only resolved when the outcome depends on it: the engine
runs optimistically first, and isProSettled (which can wait out a billing
round-trip) is consulted only if the result would be an activation or switch.

Bluetooth: the ACL receiver is the only live connect signal, so it persists the
snapshot before nudging the service and the nudge is a plain startService that
may be refused from the background. A missing BLUETOOTH_CONNECT reads as
nothing connected rather than as unchanged — holding a rule active on evidence
nothing can observe any more is the unsafe direction. Snapshots are boot-count
scoped, and a service-start evaluation reconciles per address through the A2DP,
HEADSET and GATT proxies to cover broadcasts missed while Amply was not
running.

RulesWatcher is keep-alive only. Rule writes must not run on a watcher tick
budget, so the service evaluates rules directly (next commit).
Rule evaluation is a first-class step of the battery-evaluation path, not
watcher work: watcher ticks are optional and bounded by a per-watcher budget,
while a rule write changes the charging policy and owes a restore. It runs
after the safety-critical session decisions (a restore must never queue behind
it) and before the optional watchers, and a new ACTION_EVALUATE_RULES command
covers rule edits and Bluetooth changes — gated on boot recovery being idle,
like ACTION_MONITOR, and the only path that runs the Bluetooth reconciliation.

Session handoff: begin() gains a restore-target override. When a rule owns the
policy, what is configured right now is the rule's temporary override, so a
session that restored to it would make the override permanent and lose the
user's real baseline. The rules layer hands its baseline over and only drops
ownership once the session record is actually persisted — a refused or failed
start must leave the baseline owed by someone.

An explicit persistent policy write outranks rules, so after a successful one
every currently matching rule is suspended, not just the winner: the runner-up
would otherwise re-apply over the user's fresh choice on the next tick.
Three surfaces for the same ordered list. The rules screen is where conditions
are created, reordered, switched on and off; reordering IS the priority editor,
since the topmost matching condition wins. The dashboard card shows the same
order compressed with the one in effect marked, sits between the policy card it
conditionally overrides and the reconnect gesture, and pitches the feature when
nothing is set up. The hero gains one provenance line naming the condition that
chose the current policy, withheld while a session or a settling write owns the
display — those are the current authors.

The editor hides the charger-type option entirely on devices that latch their
policy at plug time, rather than offering something that could never take
effect, and requires at least one charger type so a rule can't be saved as a
silent no-op. Policies are grouped by their derived kind.

Gating follows the charging-history pattern: the navigation gate resolves
before the editor opens, the backend gate before the write, and the
notification prompt only after the entitlement passed. Switching a condition
off, editing and deleting are never gated — a lapsed entitlement must not trap
a user with a rule they can't stop.

Rows state their own limitations (a condition this device can't act on, a
policy this adapter doesn't offer) instead of hiding them, and a failed write
surfaces on both the list and the dashboard card while the monitor keeps
retrying.
…vice run

The profile-proxy sweep was tied to the explicit ACTION_EVALUATE_RULES command,
so a service brought up any other way — the boot dispatch, the foreground nudge,
a sticky restart — kept trusting a snapshot written before the process went
away. Every ACL broadcast in that gap was missed, so a device that disconnected
meanwhile would still read as connected and hold its rule active.

The sweep now runs on the first evaluation of each service instance whichever
command started it, and once only: after that the receiver keeps the snapshot
current and repeating it would just cost Binder round-trips.
Two Robolectric fixtures still called DashboardScreen without onOpenConditions
and failed to compile. Also switch the settings row to the auto-mirrored Rule
icon, which is what the deprecated one now forwards to.
…ites

Three ordering defects, all of the same shape: a gap in which two layers both
believe they own the charging policy, or neither does.

begin() gains an afterPersisted hook, invoked between persisting the session
record and the override write, and the conditions handoff moves into it. The
old post-begin clear ran after a write that can fail, be cancelled, or die with
the process — and on those paths the session record already existed and already
owed the baseline, while the rules layer still claimed it too. Boot recovery
gets the same treatment: a persisted session there already carries the baseline
as its restore target, so stale rule bookkeeping beside it must go before the
convergence loop starts writing.

The explicit-persistent-policy suspension moves from the success branch to the
persisted-intent step before the write. Suspending after the write left a
window where the chosen policy was already configured and every rule was still
armed to overwrite it on the next tick. Suspending first is safe precisely
because the pending recovery target owns convergence on every failure path, so
the end state is the explicit policy whether or not this write lands.

The rule-evaluation command no longer forces a Bluetooth sweep. The
once-per-service-instance sweep already covers the missed-broadcast case, while
sweeping on every ACL event lets a lagging profile proxy write a
just-disconnected address back over the receiver's fresher snapshot.
The rules layer could only spot an external change when a readback contradicted
it, which left two blind spots.

An unrecognized configured value now counts as divergence. The layer only ever
writes values Amply can name, so an unnameable one is by definition somebody
else's — and treating it as "not different" meant carrying on as though the rule
still owned the policy, which ends in overwriting a mode Amply cannot reproduce.

Second, adapters with no synchronous readback at all had no divergence signal
whatsoever: a session restore, a rollback after a failed override, or a boot
recovery rewrite would silently replace the rule's policy and the rules layer
would keep claiming it. The shared write journal closes that: every write path
records into it after the physical write, so an entry newer than the
activation naming a different policy proves another component wrote past the
rules layer. RuleRuntimeState carries lastWriteAt for the comparison, stamped by
COPYING the journal entry the repository just wrote rather than reading the
clock again — two independent `now`s could differ by milliseconds and make the
layer look overwritten by its own write.

Separately, a rule naming a policy the selected adapter does not offer no longer
matches at all. The write path refuses such a policy, so activating on it parked
the layer in a permanently failing pending phase, retried every 30s. An empty
supported-policy set still matches permissively — that means adapter selection
has not resolved yet, not that the device supports nothing.
…e it

Saving created the rule already switched on and only then ran the entitlement
gate and the notification prompt. A refusal at either step therefore had to
switch off a rule that had briefly owned the charging policy before anything
agreed it may. New conditions are now written off and routed through the same
enable flow every other affordance uses, so a refused gate leaves a visible,
inert rule the user can switch on later. Editing carries the existing switch
through untouched — saving an edit must never turn a rule on or off.

Granting Bluetooth access from inside the editor also updates it in place now,
instead of requiring a back-out and re-entry to populate the device list.
The sweep unioned whatever the profile queries happened to return, and a
timeout, a failed proxy bind or an unreadable connectedDevices list all read as
"nothing connected on that profile". Since the caller REPLACES the
receiver-maintained snapshot with the result, a missing answer would drop a
device that is still connected, deactivate its rule and restore the baseline.

Any failure across GATT, A2DP or HEADSET now yields null, which the applier
already handles by keeping the snapshot the ACL broadcasts built.
The supported-policy check treated an empty set as "adapter not resolved yet"
and matched permissively. That reading has no basis: currentAdapter() resolves
on demand and always answers, and an empty list is a real declaration — it is
exactly what the diagnostics-only lab adapters ship. The loophole let every rule
activate on precisely the devices where no write can ever land.

The check is strict now, and lives in one effective-match predicate used for
both the winner selection and the suspension cohort. The cohort mattered
separately: a suspended rule whose policy the adapter cannot apply can never
re-apply over anything, so keeping the cohort closed on it stranded every rule
that still could act.

Engine fixtures now name the adapter's policy set explicitly instead of leaning
on the permissive empty default.
begin() runs the handoff callback between persisting the session record and
writing the override, and its contract is that the callback does not throw. An
unwrapped DataStore error there would abort the start after the record already
existed, stranding a session whose override write never ran — the exact state
the persist-first ordering exists to avoid. Cancellation still propagates;
anything else is logged, since stale rule bookkeeping is cheap and the next
evaluation clears it against the live session.
@d4rken d4rken added the Shizuku Shizuku / ADB privileged access related label Aug 16, 2026
@github-actions github-actions Bot added the enhancement New feature or request label Aug 16, 2026
d4rken added 13 commits August 17, 2026 11:47
The editor's radio rows carried selection on the row with an inert RadioButton, which brings no interactive minimum of its own — rows collapsed to text height (~32dp), below the 48dp touch-target floor and visibly inconsistent, with the label almost touching the glyph. Rows now enforce the M3 selection-list dimensions themselves: 56dp minimum height, a 16dp control-to-label gap, contiguous rows per group wrapped in selectableGroup() for radio-group accessibility semantics, and an 8dp separation before the second policy-group header.
The 16dp control-to-label gap from the Compose M3 doc sample read oversized on device: the radio glyph carries its own inherent padding, and the in-card idiom everywhere else in the app (AmplyCard headers and rows) is 8dp. The Bluetooth-permission row also shared a line between explanation and Allow action, which squeezes the text at larger font scales; the action now stacks below the full-width text, end-aligned.
The charge-condition editor needs the same per-policy explanations the dashboard
already writes, and a second copy would drift: the dashboard's version knows a
100% "limit" is a full charge and must not claim to protect the battery, and
nothing would keep a duplicate honest about that.

Moved to the shared compose layer beside the other UI-text bridges, with the
dashboard as its first caller and no behavior change. The resource ids keep
their dashboard_ names deliberately — they are translation keys, and renaming a
string whose text did not change would orphan its translations.
The EXTRA_PLUGGED translation lived in the session service's companion, which
made it the service's private business. The rules editor needs the same mapping
to say which charger is connected right now, and a second copy would be free to
disagree about what an unknown value means — the one judgement in there worth
protecting, since an unrecognized charger must never satisfy a rule that names
specific types.
The editor wants to mark which paired devices are connected right now, and the
only fresh source is the same profile-proxy sweep the evaluation path uses. Two
independent sweeps would produce two answers that can disagree, so the applier
grows one entry point that runs the shared path under the shared mutex and
persists into the shared snapshot; the store exposes that snapshot read-only so
the markers stay live as ACL broadcasts land.

It answers whether the reading is actually fresh. A sweep that cannot report
leaves the receiver-built snapshot untouched and says so, because presenting a
possibly-hours-old list as "connected now" is the one thing this feature must
not do. A missing permission is a real answer, not a failure: nothing is
observable, which is exactly what the evaluation path acts on too.

Unlike the evaluation path it never short-circuits on "no rule uses Bluetooth" —
an editor filling in its first Bluetooth condition is precisely the case that
has no such rule yet.
…g at

Five changes to the same screen, all aimed at the same gap: the editor asked
for decisions without showing the state those decisions depend on.

Sections carry an icon and, where the choice is genuinely ambiguous, a line of
supporting text: that a Bluetooth condition applies whether or not you are
plugged in, that a charger condition rides on the class Android reports, and
what each charge policy actually does — the last reusing the dashboard's own
wording rather than a second description that could drift. The name field says
outright that it does not affect matching, which was the obvious wrong guess.

Save moves into the top bar and Delete into an overflow behind a confirmation:
a bare delete icon sat one mis-tap from an action with no undo, next to the
action people reach for constantly. Backing out of a modified draft now asks
before discarding, measured against the draft as it was when the editor opened —
and deliberately only against the fields a user edits, since the bonded list and
the connection state refresh underneath them and must not raise a dialog nobody
earned. Saving is never blocked by it.

The device list is live while the editor is open: Bluetooth switching on or off
and devices being paired or unpaired refresh it, refreshes are last-write-wins
so a slower older answer cannot replace a newer one, and a device the rule
points at that is no longer paired stays visible, selected and marked rather
than silently dropped — what Save would keep has to be on screen. Devices
connected right now are marked, but only while the reading is actually fresh;
when the sweep cannot answer the section says so instead of letting an absent
marker mean two different things.

The charger card shows which charger is connected as a caption, never by
pre-selecting a chip: a selected chip means "this rule covers this charger", and
detection is not a choice the user made.
The plain filled variant is the deprecated one now that an auto-mirrored
counterpart exists, and it lays out wrong in RTL besides.
The row now carries an explanation and a status line as well as its label, and
the screen previews only ever show it in one or two of those shapes. This
renders all of them, including at a large font scale — where the supporting
sentence has to wrap and grow the row instead of truncating the words that make
the option understandable.
All three share a shape: async work that finishes late and acts on editor state
that has moved on since it started.

Save was re-entrant. The write goes through the applier's mutex, which a
Bluetooth sweep can hold for seconds — long enough to tap Save twice and get two
rules under two ids, or to back out and "discard" a draft that was already being
written. Accepting a save now flips a saving flag synchronously, before the
coroutine starts: Save goes inert, and backing out waits for the write's own
close instead of offering to throw away something already on its way to disk. A
failed write clears the flag and keeps the draft rather than losing the user's
work.

The list's delete and the editor's delete were one method that always closed the
editor, so a list delete completing after the user opened another rule cleared
that draft and navigated away from it. They are separate now: the list's never
touches editor state, and the editor's closes only its own session.

Both are anchored on an editing-session token captured when work starts and
re-checked before touching state, so nothing from a finished session can act on
the next one.

The connection refresh had two ordering holes. Cancelling a job is not
instantaneous — a coroutine past its last suspension point still runs to
completion — so refreshes now claim a generation synchronously and only the
current generation's answer may land. And because the sweep reported only
success, freshness could be set before the store's flow delivered the new
addresses, briefly presenting the PREVIOUS set as "connected now". The sweep
returns its resolved snapshot instead, so the set and the freshness are applied
together, and store emissions are held back while a newer reading is in flight.
Freezing the snapshot flow while a sweep was in flight discarded, permanently,
any ACL-receiver write that landed between the sweep's own store write and the
freeze being lifted — the store emits each write once, so the editor then showed
the sweep's older set as "Connected now" until the next Bluetooth event.

The freeze is gone; every emission is applied. Nothing needed protecting: the
markers render only under FRESH, and freshness stays UNKNOWN for as long as a
sweep is in flight, so a mid-sweep set is invisible until the sweep's own update
declares one. Emissions arrive in store-write order, so the set only moves
forward.

The sweep's result is now published solely through the store, and the editor
reads the committed value once the sweep returns — that read is by definition at
least as new as the sweep's own write, so FRESH is always paired with the newest
committed set, still under the generation check. reconcileBluetoothForUi goes
back to reporting success only, since its returned set was exactly the slightly
older answer that caused the problem.
Two routes deliver the connected set to the editor — the store's flow and the
point read a sweep performs when it finishes — and nothing ordered them against
each other. A sweep could read revision N, an ACL broadcast could publish N+1
while that coroutine was still suspended, and the sweep would then resume and
overwrite the newer set with its older one for good, until the next Bluetooth
event happened to correct it.

Snapshots now carry a monotonic revision, stamped by the store rather than by
its callers so no write site can forget it and quietly break the ordering
readers depend on. A timestamp could not do this job: two writes in the same
millisecond are indistinguishable and a wall clock can move backwards.

A write that changes nothing keeps its revision. That is not just tidiness —
the evaluation path resolves this snapshot every ~30s, and an unconditional bump
would make each tick a disk write that re-emits the shared store to every
collector in the app. It also keeps the ordering honest: the same set arriving
twice is one write, not two.

The editor applies a set only when its revision beats the one already displayed,
with the comparison and the write as a single expression inside the state update
— split apart, a second writer could land between them and be silently undone.
The sweep still declares FRESH when it loses that race, which is correct: the
addresses on screen are then the newer ones, and the reading it describes is at
least as current.
A snapshot persisted before the revision field existed decodes as revision 0 and keeps it while its content is unchanged. The editor's applied-revision tracker also started at 0, so with the strict greater-than gate neither delivery path could ever adopt such a snapshot: connected devices on an upgraded install would show no marker despite a successful sweep. The tracker now starts at -1, below every valid stored revision.
The rule cards had grown by accretion: an "Active now" label floating between body lines, condition and effect on separate rows, a full text-button action row, and bare helper strings above and below the list. The card now reads as one anatomy: status as an eyebrow above the title (only the active card pays the height), one condition-and-effect summary line under the title, and icon-only actions. List deletion gets the same confirmation the editor's delete already had. The priority and notification notes lead with an icon and align with the card interiors so they read as part of the list. Cards lose roughly a text row each; the kind label helper lost its last consumer and is gone.
@d4rken
d4rken marked this pull request as ready for review August 17, 2026 17:04
@d4rken
d4rken merged commit d67a49b into main Aug 17, 2026
12 checks passed
@d4rken
d4rken deleted the feat/charge-conditions branch August 17, 2026 17:14
d4rken added a commit that referenced this pull request Aug 17, 2026
Second merge round: main advanced again while this branch was validating (PR #75, the charge-conditions feature and its follow-ups).

Both conflicts were additive rather than competing. ChargeSessionService: main added a rules-layer suspension in the same persisted-intent step, this branch added the USER_REQUEST recovery origin to the same call - both kept. ChargeMonitorWatcherGraphTest: each side added a watcher-binding test and an import, both kept as separate cases.

As in the first round the conflict markers understated the work: main's new onOpenConditions parameter was not passed by this branch's three enforcement previews, which only the compiler surfaced.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Shizuku Shizuku / ADB privileged access related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant