Skip to content

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

Merged
d4rken merged 12 commits into
mainfrom
feat/charge-conditions
Aug 16, 2026
Merged

Charging: Add conditional charge rules (Bluetooth and charger type)#72
d4rken merged 12 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.

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
d4rken marked this pull request as ready for review August 16, 2026 21:36
@d4rken
d4rken merged commit e1eb7b0 into main Aug 16, 2026
12 checks passed
@d4rken
d4rken deleted the feat/charge-conditions branch August 16, 2026 21:36
d4rken added a commit that referenced this pull request Aug 16, 2026
…ation

Reverts the 12 commits of PR #72. The feature merged before the planned physical device pass (Bluetooth transitions, reboot, process kill, Shizuku loss, permission revocation, GrapheneOS latch); it returns via a follow-up PR once that verification has run.
@d4rken
d4rken restored the feat/charge-conditions branch August 16, 2026 21:51
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