@@ -28,6 +28,8 @@ import eu.darken.amply.common.debug.logging.logTag
2828import eu.darken.amply.main.core.SurfaceUpdater
2929import eu.darken.amply.monitor.core.ChargeMonitorTick
3030import eu.darken.amply.monitor.core.ChargeMonitorWatcher
31+ import eu.darken.amply.rules.core.PlugKind
32+ import eu.darken.amply.rules.core.RuleApplier
3133import kotlinx.coroutines.CancellationException
3234import kotlinx.coroutines.CoroutineScope
3335import kotlinx.coroutines.Dispatchers
@@ -51,6 +53,7 @@ class ChargeSessionService : Service() {
5153 @Inject lateinit var interruptionAssessor: InterruptionAssessor
5254 @Inject lateinit var processIdentity: ProcessIdentity
5355 @Inject lateinit var bootCountProvider: BootCountProvider
56+ @Inject lateinit var ruleApplier: RuleApplier
5457
5558 // Optional, permission-free battery observers (charge alarm, …), contributed via @IntoSet.
5659 @Inject lateinit var watchers: Set < @JvmSuppressWildcards ChargeMonitorWatcher >
@@ -75,6 +78,8 @@ class ChargeSessionService : Service() {
7578 // Written under the dispatch lock, but read by the battery receiver/monitor loop outside it.
7679 @Volatile private var recoveryJob: Job ? = null
7780 private var settingObserverRegistered = false
81+ // Whether this service instance has already swept the Bluetooth profile proxies (see evaluateRules).
82+ private var bluetoothReconciled = false
7883 @Volatile private var restoring = false
7984 // One-shot interruption assessment for a freshly resumed persisted session: set when
8085 // beginOrResume picks up an existing session, consumed by the first battery evaluation, and
@@ -205,7 +210,33 @@ class ChargeSessionService : Service() {
205210 log(TAG ) { " Starting a one-time full-charge session" }
206211 // A brand-new session in this process is not an interruption; drop any stale assessment.
207212 pendingSessionAssessment = null
208- val result = manager.begin(pluggedAtStart = currentPlugged())
213+ // A conditional rule may currently own the policy. Hand its baseline to the session: what
214+ // is configured right now is the rule's temporary override, so the session must restore
215+ // the user's real policy, not the override.
216+ val ruleBaseline = ruleApplier.readActiveBaseline()
217+ val result = manager.begin(
218+ pluggedAtStart = currentPlugged(),
219+ restoreOverride = ruleBaseline,
220+ // Handed over inside begin(), in the window between the session record being
221+ // persisted and the override write: from that moment the session owes the restore,
222+ // and clearing any later would leave both layers claiming the baseline across a
223+ // write that can fail or die with the process.
224+ //
225+ // Contained, because begin() runs this between persisting the session and writing
226+ // the override: letting a DataStore failure escape would abort the start after the
227+ // record exists, stranding a session whose override write never ran. Stale rule
228+ // bookkeeping is the far cheaper failure — the next evaluation clears it against the
229+ // live session anyway.
230+ afterPersisted = {
231+ try {
232+ ruleApplier.clearActiveAfterSessionPersist()
233+ } catch (e: CancellationException ) {
234+ throw e
235+ } catch (e: Exception ) {
236+ log(TAG , Logging .Priority .WARN ) { " Rule ownership handoff failed: ${e.message} " }
237+ }
238+ },
239+ )
209240 if (! result.success) {
210241 log(TAG , Logging .Priority .WARN ) { " Unable to start full-charge session: ${result.message} " }
211242 if (fullChargeStore.currentSession() != null ) SessionNotifications .showRecovery(this )
@@ -273,6 +304,51 @@ class ChargeSessionService : Service() {
273304 }
274305 }
275306
307+ /* *
308+ * Run one conditional-charge-rule evaluation.
309+ *
310+ * A first-class step of the evaluation path, NOT watcher work: watcher ticks are optional and
311+ * bounded by a per-watcher budget, while a rule write changes the charging policy and owes a
312+ * restore — it must never be cut short. It runs *after* the safety-critical session decisions
313+ * above (a restore must never queue behind it) and before the optional watchers.
314+ *
315+ * Failure is contained the same way a watcher's is: the rules layer must not be able to stop a
316+ * battery evaluation.
317+ */
318+ private suspend fun evaluateRules (
319+ plugged : Boolean ,
320+ plugKind : PlugKind ? ,
321+ sessionActive : Boolean ,
322+ reconcileBluetooth : Boolean = false,
323+ ) {
324+ try {
325+ ruleApplier.evaluate(
326+ plugged = plugged,
327+ plugKind = plugKind,
328+ sessionActive = sessionActive,
329+ // Always on this instance's first pass, whichever command brought the service up: a
330+ // process that was not running missed every ACL broadcast in the meantime, and the
331+ // stored snapshot is only as good as the last one it received. After that the
332+ // receiver keeps it current and the sweep would just cost Binder round-trips.
333+ reconcileBluetooth = reconcileBluetooth || ! bluetoothReconciled,
334+ )
335+ bluetoothReconciled = true
336+ } catch (e: CancellationException ) {
337+ throw e
338+ } catch (e: Exception ) {
339+ log(TAG , Logging .Priority .ERROR ) { " Rule evaluation failed: ${e.message} " }
340+ }
341+ }
342+
343+ /* * Current plug state and charger class from the sticky broadcast, for a command-driven pass. */
344+ private fun currentPlug (): Pair <Boolean , PlugKind ?> {
345+ val raw = runCatching {
346+ registerReceiver(null , IntentFilter (Intent .ACTION_BATTERY_CHANGED ))
347+ ?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 )
348+ }.getOrNull() ? : 0
349+ return (raw != 0 ) to PlugKind .fromExtraPlugged(raw)
350+ }
351+
276352 /* *
277353 * Deliver a battery tick to every optional watcher. Evaluations are already serialized (single
278354 * evaluation consumer under the [coordinator]'s lock), so no extra lock is needed. Each watcher is bounded
@@ -357,7 +433,9 @@ class ChargeSessionService : Service() {
357433 // An in-flight evaluation can outlive the quiesce in startRecovery; never race recovery.
358434 if (recoveryJob?.isActive == true ) return
359435 val battery = intent ? : registerReceiver(null , IntentFilter (Intent .ACTION_BATTERY_CHANGED ))
360- val plugged = (battery?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 ) ? : 0 ) != 0
436+ val pluggedRaw = battery?.getIntExtra(BatteryManager .EXTRA_PLUGGED , 0 ) ? : 0
437+ val plugged = pluggedRaw != 0
438+ val plugKind = PlugKind .fromExtraPlugged(pluggedRaw)
361439 val status = battery?.getIntExtra(
362440 BatteryManager .EXTRA_STATUS ,
363441 BatteryManager .BATTERY_STATUS_UNKNOWN ,
@@ -449,6 +527,8 @@ class ChargeSessionService : Service() {
449527 )
450528 }
451529 }
530+ // A live session outranks the rules layer; this pass only reconciles its bookkeeping.
531+ evaluateRules(plugged, plugKind, sessionActive = true )
452532 // Non-restore session tick: let watchers observe it (the alarm claims the cycle here).
453533 dispatchWatchers(plugged, percent, status, sessionOwned = true , battery, observedAtElapsed)
454534 return
@@ -463,6 +543,7 @@ class ChargeSessionService : Service() {
463543 val gestureEnabled = fullChargeStore.isQuickFullChargeEnabled()
464544 val adapter = if (gestureEnabled) capabilityAdapter() else null
465545 if (! gestureEnabled || adapter?.reconnectGestureSupported != true ) {
546+ evaluateRules(plugged, plugKind, sessionActive = false )
466547 dispatchWatchers(plugged, percent, status, sessionOwned = false , battery, observedAtElapsed)
467548 // Gesture inactive: keep running only if a watcher still wants the service, showing the
468549 // quiet monitoring notification instead of the gesture cue.
@@ -521,6 +602,7 @@ class ChargeSessionService : Service() {
521602 " plugged=$plugged percent=$percent status=$status "
522603 }
523604 }
605+ evaluateRules(plugged, plugKind, sessionActive = false )
524606 // A triggering tick is a deliberate full charge about to begin, so the alarm must treat it
525607 // as session-owned and NOT fire "unplug now" on the very reconnect that started the charge.
526608 dispatchWatchers(
@@ -589,6 +671,20 @@ class ChargeSessionService : Service() {
589671 when (action) {
590672 ACTION_RESTORE -> if (recoveryJob?.isActive != true ) restoreAndContinue()
591673 ACTION_MONITOR -> if (recoveryJob?.isActive != true ) continueGestureOrStop()
674+ // A rule edit or a Bluetooth connection change. Gated on recovery like ACTION_MONITOR: a
675+ // rule write must never race the boot-recovery convergence loop. No forced Bluetooth
676+ // sweep here — the once-per-service-instance one in evaluateRules already covers the
677+ // missed-broadcast case, and sweeping on every ACL event risks a lagging profile proxy
678+ // writing a just-disconnected address back over the receiver's fresher snapshot.
679+ ACTION_EVALUATE_RULES -> if (recoveryJob?.isActive != true ) {
680+ val (plugged, plugKind) = currentPlug()
681+ evaluateRules(
682+ plugged = plugged,
683+ plugKind = plugKind,
684+ sessionActive = fullChargeStore.currentSession() != null ,
685+ )
686+ continueGestureOrStop()
687+ }
592688 ACTION_START -> {
593689 // A user-initiated session supersedes boot recovery; the new session
594690 // overwrites the policy anyway. Join so a cancelled re-write cannot
@@ -646,6 +742,19 @@ class ChargeSessionService : Service() {
646742 // Assess whether this recovery is picking up work a dead process left behind, BEFORE the
647743 // flow mutates the pending target.
648744 val pickup = interruptionAssessor.captureRecoveryPickup()
745+ // A persisted session already carries the baseline as its restore target, so rule
746+ // bookkeeping left ACTIVE beside it is stale: recovery is about to write policies, and
747+ // the rules layer must not come back afterwards claiming to own the result.
748+ if (fullChargeStore.currentSession() != null ) {
749+ try {
750+ ruleApplier.clearActiveAfterSessionPersist()
751+ } catch (e: CancellationException ) {
752+ // A cancelled recovery job must actually stop here, not carry on into the flow.
753+ throw e
754+ } catch (e: Exception ) {
755+ log(TAG , Logging .Priority .WARN ) { " Rule ownership clear failed: ${e.message} " }
756+ }
757+ }
649758 val result = BootRecoveryFlow (recoveryHooks).run ()
650759 log(TAG ) { " Boot recovery outcome: ${result.outcome} " }
651760 // A converged recovery restored the protective policy, so clear any lingering alarm.
@@ -795,6 +904,17 @@ class ChargeSessionService : Service() {
795904 provenance = currentWorkProvenance(),
796905 origin = RecoveryOrigin .USER_REQUEST ,
797906 )
907+ // Suspend the rules layer here, in the same persisted-intent step and BEFORE the write: a
908+ // process death between the write and a post-success suspension would leave the explicit
909+ // policy configured with every rule still armed to overwrite it on the next tick.
910+ val (pluggedNow, plugKindNow) = currentPlug()
911+ try {
912+ ruleApplier.suspendMatchingCohort(pluggedNow, plugKindNow)
913+ } catch (e: CancellationException ) {
914+ throw e
915+ } catch (e: Exception ) {
916+ log(TAG , Logging .Priority .WARN ) { " Rule suspension failed: ${e.message} " }
917+ }
798918 restoring = true
799919 coordinator.close()
800920 try {
@@ -915,6 +1035,7 @@ class ChargeSessionService : Service() {
9151035 const val ACTION_RECOVER = " eu.darken.amply.action.RECOVER_CHARGE_LIMIT"
9161036 const val ACTION_CHECK = " eu.darken.amply.action.CHECK_CHARGE_STATE"
9171037 const val ACTION_SET_PERSISTENT_POLICY = " eu.darken.amply.action.SET_PERSISTENT_POLICY"
1038+ const val ACTION_EVALUATE_RULES = " eu.darken.amply.action.EVALUATE_CHARGE_RULES"
9181039 const val EXTRA_TARGET_POLICY = " eu.darken.amply.extra.TARGET_POLICY"
9191040 }
9201041}
0 commit comments