Skip to content

Commit eb26509

Browse files
committed
Merge origin/main into the enforcement-evidence gate
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.
2 parents 8f9dfdf + d67a49b commit eb26509

36 files changed

Lines changed: 5586 additions & 51 deletions

app/src/debug/java/eu/darken/amply/screenshots/ScreenshotContent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
9191
onAlarmEnabledChange = {},
9292
onAlarmTargetChange = {},
9393
onFixNotifications = {},
94+
onOpenConditions = {},
9495
onOpenBatteryHub = {},
9596
onRetryCapture = {},
9697
onStartVerification = {},

app/src/main/AndroidManifest.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1010
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
1111

12+
<!-- Charge conditions: a Bluetooth-device rule needs the connect/disconnect broadcasts and the
13+
bonded-device list. Runtime-requested on API 31+, install-time below that. -->
14+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
15+
<uses-permission
16+
android:name="android.permission.BLUETOOTH"
17+
android:maxSdkVersion="30" />
18+
1219
<queries>
1320
<package android:name="moe.shizuku.privileged.api" />
1421
<package android:name="com.google.android.settings.intelligence" />
@@ -104,6 +111,15 @@
104111
android:resource="@xml/amply_widget_info" />
105112
</receiver>
106113

114+
<receiver
115+
android:name=".rules.core.BluetoothRuleReceiver"
116+
android:exported="true">
117+
<intent-filter>
118+
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
119+
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
120+
</intent-filter>
121+
</receiver>
122+
107123
<receiver
108124
android:name=".fullcharge.core.BootReceiver"
109125
android:directBootAware="false"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package eu.darken.amply.common.compose
2+
3+
import eu.darken.amply.R
4+
import eu.darken.amply.charging.core.ChargePolicy
5+
import eu.darken.amply.common.ca.CaString
6+
import eu.darken.amply.common.ca.toCaString
7+
8+
/**
9+
* How a [ChargePolicy] is named and explained to the user.
10+
*
11+
* Shared rather than per-screen: the dashboard's status card and the charge-condition editor both
12+
* describe the same policies, and two copies would drift — the editor would keep promising "protects
13+
* the battery" for a 100% limit long after the dashboard learned better (see [description]'s
14+
* full-charge special case).
15+
*
16+
* The resource ids keep their original `dashboard_policy_*` names. They are a stored translation
17+
* key, not a location: renaming them for tidiness would orphan every translation of a string whose
18+
* text did not change.
19+
*/
20+
fun ChargePolicy.shortLabel(): CaString = when (this) {
21+
ChargePolicy.Adaptive -> R.string.dashboard_policy_adaptive.toCaString()
22+
ChargePolicy.Unrestricted -> R.string.dashboard_policy_full.toCaString()
23+
ChargePolicy.PauseAtFull -> R.string.dashboard_policy_pause_at_full.toCaString()
24+
is ChargePolicy.FixedLimit -> R.string.dashboard_policy_fixed.toCaString(percent)
25+
}
26+
27+
fun ChargePolicy.description(): CaString = when (this) {
28+
ChargePolicy.Adaptive -> R.string.dashboard_policy_desc_adaptive.toCaString()
29+
ChargePolicy.Unrestricted -> R.string.dashboard_policy_desc_full.toCaString()
30+
ChargePolicy.PauseAtFull -> R.string.dashboard_policy_desc_pause.toCaString()
31+
is ChargePolicy.FixedLimit -> if (percent >= 100) {
32+
// A 100% "limit" is a full charge; the battery-health claim would be wrong.
33+
R.string.dashboard_policy_desc_full.toCaString()
34+
} else {
35+
R.string.dashboard_policy_desc_fixed.toCaString(percent)
36+
}
37+
}

app/src/main/java/eu/darken/amply/fullcharge/core/ChargeSessionManager.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,25 @@ class ChargeSessionManager @Inject constructor(
2222
) {
2323
private val mutex = Mutex()
2424

25+
/**
26+
* [restoreOverride] replaces the observed current policy as the session's restore target. Set by
27+
* the charge-conditions layer when a rule currently owns the policy: what is configured right now
28+
* is the *rule's* temporary override, so restoring to it at the end of the session would make the
29+
* override permanent and lose the user's own baseline. The session record is durable and survives
30+
* process death, so handing it the true baseline makes it the single owner of that restore.
31+
*/
32+
/**
33+
* [afterPersisted] runs in the window between the session record being persisted and the override
34+
* write. That is the only correct place for the charge-conditions handoff: the session durably
35+
* owes the restore from the moment its record exists, and clearing rule ownership any later
36+
* leaves both layers claiming the baseline across a write that can fail, be cancelled, or die
37+
* with the process. It must not throw — it runs inside the session mutex.
38+
*/
2539
suspend fun begin(
2640
nowMillis: Long = System.currentTimeMillis(),
2741
pluggedAtStart: Boolean? = null,
42+
restoreOverride: ChargePolicy? = null,
43+
afterPersisted: (suspend () -> Unit)? = null,
2844
): ApplyResult = mutex.withLock {
2945
sessionStore.currentSession()?.let {
3046
return@withLock ApplyResult(
@@ -78,7 +94,7 @@ class ChargeSessionManager @Inject constructor(
7894
message = "The current OEM charging mode is not recognized; refusing to overwrite it",
7995
)
8096
}
81-
val restorePolicy = (decision as SessionStartDecision.Start).restorePolicy
97+
val restorePolicy = restoreOverride ?: (decision as SessionStartDecision.Start).restorePolicy
8298

8399
// Persist recovery state before removing the limit. Stamp this process's identity so a later
84100
// pickup can tell whether the session survived a process death (interruption detection), and a
@@ -98,6 +114,7 @@ class ChargeSessionManager @Inject constructor(
98114
// that instruction until the replug is observed.
99115
overrideAwaitingReplug = adapter?.policyLatchesAtPlug == true && pluggedAtStart != false,
100116
)
117+
afterPersisted?.invoke()
101118
val result = repository.applyTemporary(overridePolicy)
102119
if (result.success) {
103120
// Reconcile the persist-first conservative flag with the repository's authoritative

app/src/main/java/eu/darken/amply/fullcharge/core/ChargeSessionService.kt

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import eu.darken.amply.common.debug.logging.logTag
2828
import eu.darken.amply.main.core.SurfaceUpdater
2929
import eu.darken.amply.monitor.core.ChargeMonitorTick
3030
import eu.darken.amply.monitor.core.ChargeMonitorWatcher
31+
import eu.darken.amply.rules.core.PlugKind
32+
import eu.darken.amply.rules.core.RuleApplier
3133
import kotlinx.coroutines.CancellationException
3234
import kotlinx.coroutines.CoroutineScope
3335
import 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

Comments
 (0)