Charging: Add conditional charge rules (Bluetooth and charger type) - #72
Merged
Conversation
…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
marked this pull request as ready for review
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
RuleEngine(pure, JVM-tested) plus theRuleApplier/ChargeSessionServiceintegration points are where review attention pays off most.