@@ -26,6 +26,8 @@ import eu.darken.amply.common.debug.logging.logTag
2626import eu.darken.amply.main.core.SurfaceUpdater
2727import eu.darken.amply.monitor.core.ChargeMonitorTick
2828import eu.darken.amply.monitor.core.ChargeMonitorWatcher
29+ import eu.darken.amply.rules.core.PlugKind
30+ import eu.darken.amply.rules.core.RuleApplier
2931import kotlinx.coroutines.CancellationException
3032import kotlinx.coroutines.CoroutineScope
3133import kotlinx.coroutines.Dispatchers
@@ -49,6 +51,7 @@ class ChargeSessionService : Service() {
4951 @Inject lateinit var interruptionAssessor: InterruptionAssessor
5052 @Inject lateinit var processIdentity: ProcessIdentity
5153 @Inject lateinit var bootCountProvider: BootCountProvider
54+ @Inject lateinit var ruleApplier: RuleApplier
5255
5356 // Optional, permission-free battery observers (charge alarm, …), contributed via @IntoSet.
5457 @Inject lateinit var watchers: Set < @JvmSuppressWildcards ChargeMonitorWatcher >
@@ -73,6 +76,8 @@ class ChargeSessionService : Service() {
7376 // Written under the dispatch lock, but read by the battery receiver/monitor loop outside it.
7477 @Volatile private var recoveryJob: Job ? = null
7578 private var settingObserverRegistered = false
79+ // Whether this service instance has already swept the Bluetooth profile proxies (see evaluateRules).
80+ private var bluetoothReconciled = false
7681 @Volatile private var restoring = false
7782 // One-shot interruption assessment for a freshly resumed persisted session: set when
7883 // beginOrResume picks up an existing session, consumed by the first battery evaluation, and
@@ -203,7 +208,33 @@ class ChargeSessionService : Service() {
203208 log(TAG ) { " Starting a one-time full-charge session" }
204209 // A brand-new session in this process is not an interruption; drop any stale assessment.
205210 pendingSessionAssessment = null
206- val result = manager.begin(pluggedAtStart = currentPlugged())
211+ // A conditional rule may currently own the policy. Hand its baseline to the session: what
212+ // is configured right now is the rule's temporary override, so the session must restore
213+ // the user's real policy, not the override.
214+ val ruleBaseline = ruleApplier.readActiveBaseline()
215+ val result = manager.begin(
216+ pluggedAtStart = currentPlugged(),
217+ restoreOverride = ruleBaseline,
218+ // Handed over inside begin(), in the window between the session record being
219+ // persisted and the override write: from that moment the session owes the restore,
220+ // and clearing any later would leave both layers claiming the baseline across a
221+ // write that can fail or die with the process.
222+ //
223+ // Contained, because begin() runs this between persisting the session and writing
224+ // the override: letting a DataStore failure escape would abort the start after the
225+ // record exists, stranding a session whose override write never ran. Stale rule
226+ // bookkeeping is the far cheaper failure — the next evaluation clears it against the
227+ // live session anyway.
228+ afterPersisted = {
229+ try {
230+ ruleApplier.clearActiveAfterSessionPersist()
231+ } catch (e: CancellationException ) {
232+ throw e
233+ } catch (e: Exception ) {
234+ log(TAG , Logging .Priority .WARN ) { " Rule ownership handoff failed: ${e.message} " }
235+ }
236+ },
237+ )
207238 if (! result.success) {
208239 log(TAG , Logging .Priority .WARN ) { " Unable to start full-charge session: ${result.message} " }
209240 if (fullChargeStore.currentSession() != null ) SessionNotifications .showRecovery(this )
@@ -271,6 +302,51 @@ class ChargeSessionService : Service() {
271302 }
272303 }
273304
305+ /* *
306+ * Run one conditional-charge-rule evaluation.
307+ *
308+ * A first-class step of the evaluation path, NOT watcher work: watcher ticks are optional and
309+ * bounded by a per-watcher budget, while a rule write changes the charging policy and owes a
310+ * restore — it must never be cut short. It runs *after* the safety-critical session decisions
311+ * above (a restore must never queue behind it) and before the optional watchers.
312+ *
313+ * Failure is contained the same way a watcher's is: the rules layer must not be able to stop a
314+ * battery evaluation.
315+ */
316+ private suspend fun evaluateRules (
317+ plugged : Boolean ,
318+ plugKind : PlugKind ? ,
319+ sessionActive : Boolean ,
320+ reconcileBluetooth : Boolean = false,
321+ ) {
322+ try {
323+ ruleApplier.evaluate(
324+ plugged = plugged,
325+ plugKind = plugKind,
326+ sessionActive = sessionActive,
327+ // Always on this instance's first pass, whichever command brought the service up: a
328+ // process that was not running missed every ACL broadcast in the meantime, and the
329+ // stored snapshot is only as good as the last one it received. After that the
330+ // receiver keeps it current and the sweep would just cost Binder round-trips.
331+ reconcileBluetooth = reconcileBluetooth || ! bluetoothReconciled,
332+ )
333+ bluetoothReconciled = true
334+ } catch (e: CancellationException ) {
335+ throw e
336+ } catch (e: Exception ) {
337+ log(TAG , Logging .Priority .ERROR ) { " Rule evaluation failed: ${e.message} " }
338+ }
339+ }
340+
341+ /* * Current plug state and charger class from the sticky broadcast, for a command-driven pass. */
342+ private fun currentPlug (): Pair <Boolean , PlugKind ?> {
343+ val raw = runCatching {
344+ registerReceiver(null , IntentFilter (Intent .ACTION_BATTERY_CHANGED ))
345+ ?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 )
346+ }.getOrNull() ? : 0
347+ return (raw != 0 ) to PlugKind .fromExtraPlugged(raw)
348+ }
349+
274350 /* *
275351 * Deliver a battery tick to every optional watcher. Evaluations are already serialized (single
276352 * evaluation consumer under the [coordinator]'s lock), so no extra lock is needed. Each watcher is bounded
@@ -355,7 +431,9 @@ class ChargeSessionService : Service() {
355431 // An in-flight evaluation can outlive the quiesce in startRecovery; never race recovery.
356432 if (recoveryJob?.isActive == true ) return
357433 val battery = intent ? : registerReceiver(null , IntentFilter (Intent .ACTION_BATTERY_CHANGED ))
358- val plugged = (battery?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 ) ? : 0 ) != 0
434+ val pluggedRaw = battery?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 ) ? : 0
435+ val plugged = pluggedRaw != 0
436+ val plugKind = PlugKind .fromExtraPlugged(pluggedRaw)
359437 val status = battery?.getIntExtra(
360438 BatteryManager .EXTRA_STATUS ,
361439 BatteryManager .BATTERY_STATUS_UNKNOWN ,
@@ -447,6 +525,8 @@ class ChargeSessionService : Service() {
447525 )
448526 }
449527 }
528+ // A live session outranks the rules layer; this pass only reconciles its bookkeeping.
529+ evaluateRules(plugged, plugKind, sessionActive = true )
450530 // Non-restore session tick: let watchers observe it (the alarm claims the cycle here).
451531 dispatchWatchers(plugged, percent, status, sessionOwned = true , battery, observedAtElapsed)
452532 return
@@ -461,6 +541,7 @@ class ChargeSessionService : Service() {
461541 val gestureEnabled = fullChargeStore.isQuickFullChargeEnabled()
462542 val adapter = if (gestureEnabled) adapterRegistry.select().adapter else null
463543 if (! gestureEnabled || adapter?.reconnectGestureSupported != true ) {
544+ evaluateRules(plugged, plugKind, sessionActive = false )
464545 dispatchWatchers(plugged, percent, status, sessionOwned = false , battery, observedAtElapsed)
465546 // Gesture inactive: keep running only if a watcher still wants the service, showing the
466547 // quiet monitoring notification instead of the gesture cue.
@@ -519,6 +600,7 @@ class ChargeSessionService : Service() {
519600 " plugged=$plugged percent=$percent status=$status "
520601 }
521602 }
603+ evaluateRules(plugged, plugKind, sessionActive = false )
522604 // A triggering tick is a deliberate full charge about to begin, so the alarm must treat it
523605 // as session-owned and NOT fire "unplug now" on the very reconnect that started the charge.
524606 dispatchWatchers(
@@ -587,6 +669,20 @@ class ChargeSessionService : Service() {
587669 when (action) {
588670 ACTION_RESTORE -> if (recoveryJob?.isActive != true ) restoreAndContinue()
589671 ACTION_MONITOR -> if (recoveryJob?.isActive != true ) continueGestureOrStop()
672+ // A rule edit or a Bluetooth connection change. Gated on recovery like ACTION_MONITOR: a
673+ // rule write must never race the boot-recovery convergence loop. No forced Bluetooth
674+ // sweep here — the once-per-service-instance one in evaluateRules already covers the
675+ // missed-broadcast case, and sweeping on every ACL event risks a lagging profile proxy
676+ // writing a just-disconnected address back over the receiver's fresher snapshot.
677+ ACTION_EVALUATE_RULES -> if (recoveryJob?.isActive != true ) {
678+ val (plugged, plugKind) = currentPlug()
679+ evaluateRules(
680+ plugged = plugged,
681+ plugKind = plugKind,
682+ sessionActive = fullChargeStore.currentSession() != null ,
683+ )
684+ continueGestureOrStop()
685+ }
590686 ACTION_START -> {
591687 // A user-initiated session supersedes boot recovery; the new session
592688 // overwrites the policy anyway. Join so a cancelled re-write cannot
@@ -644,6 +740,19 @@ class ChargeSessionService : Service() {
644740 // Assess whether this recovery is picking up work a dead process left behind, BEFORE the
645741 // flow mutates the pending target.
646742 val pickup = interruptionAssessor.captureRecoveryPickup()
743+ // A persisted session already carries the baseline as its restore target, so rule
744+ // bookkeeping left ACTIVE beside it is stale: recovery is about to write policies, and
745+ // the rules layer must not come back afterwards claiming to own the result.
746+ if (fullChargeStore.currentSession() != null ) {
747+ try {
748+ ruleApplier.clearActiveAfterSessionPersist()
749+ } catch (e: CancellationException ) {
750+ // A cancelled recovery job must actually stop here, not carry on into the flow.
751+ throw e
752+ } catch (e: Exception ) {
753+ log(TAG , Logging .Priority .WARN ) { " Rule ownership clear failed: ${e.message} " }
754+ }
755+ }
647756 val result = BootRecoveryFlow (recoveryHooks).run ()
648757 log(TAG ) { " Boot recovery outcome: ${result.outcome} " }
649758 // A converged recovery restored the protective policy, so clear any lingering alarm.
@@ -772,6 +881,17 @@ class ChargeSessionService : Service() {
772881 // instead of leaving charging in whatever transient state the session had. An explicit persistent
773882 // choice is new owed work, so it gets a fresh work id.
774883 fullChargeStore.setPendingRecoveryTarget(policy, UUID .randomUUID().toString(), currentWorkProvenance())
884+ // Suspend the rules layer here, in the same persisted-intent step and BEFORE the write: a
885+ // process death between the write and a post-success suspension would leave the explicit
886+ // policy configured with every rule still armed to overwrite it on the next tick.
887+ val (pluggedNow, plugKindNow) = currentPlug()
888+ try {
889+ ruleApplier.suspendMatchingCohort(pluggedNow, plugKindNow)
890+ } catch (e: CancellationException ) {
891+ throw e
892+ } catch (e: Exception ) {
893+ log(TAG , Logging .Priority .WARN ) { " Rule suspension failed: ${e.message} " }
894+ }
775895 restoring = true
776896 coordinator.close()
777897 try {
@@ -882,6 +1002,7 @@ class ChargeSessionService : Service() {
8821002 const val ACTION_RECOVER = " eu.darken.amply.action.RECOVER_CHARGE_LIMIT"
8831003 const val ACTION_CHECK = " eu.darken.amply.action.CHECK_CHARGE_STATE"
8841004 const val ACTION_SET_PERSISTENT_POLICY = " eu.darken.amply.action.SET_PERSISTENT_POLICY"
1005+ const val ACTION_EVALUATE_RULES = " eu.darken.amply.action.EVALUATE_CHARGE_RULES"
8851006 const val EXTRA_TARGET_POLICY = " eu.darken.amply.extra.TARGET_POLICY"
8861007 }
8871008}
0 commit comments