Charging: Add conditional charge rules (Bluetooth and charger type) - #75
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.
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
marked this pull request as ready for review
August 17, 2026 17:04
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.
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.
This re-lands #72 (reverted in #74 before it had been tested on hardware); the code is identical to what #72 merged.
Technical Context
RuleEngine(pure, JVM-tested) plus theRuleApplier/ChargeSessionServiceintegration points are where review attention pays off most.