diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c46738c9..3963850c9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1546,6 +1546,10 @@ + + diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt new file mode 100644 index 000000000..81c093240 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: Comparison.kt + * Description: Comparison operators and threshold evaluations with hysteresis. + */ + +package com.sameerasw.essentials.domain.diy + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import kotlin.math.abs + +@Keep +data class Comparison( + @SerializedName("operator") val operator: Operator = Operator.LTE, + @SerializedName("value") val value: Double = 20.0, + @SerializedName("upper") val upper: Double? = null, + @SerializedName("holdSeconds") val holdSeconds: Int = 0, + @SerializedName("hysteresis") val hysteresis: Double? = null, +) { + @Keep + enum class Operator( + val forStates: Boolean, + val isDelta: Boolean, + val isRange: Boolean, + ) { + @SerializedName("GT") + GT(true, false, false), + + @SerializedName("GTE") + GTE(true, false, false), + + @SerializedName("LT") + LT(true, false, false), + + @SerializedName("LTE") + LTE(true, false, false), + + @SerializedName("EQ") + EQ(true, false, false), + + @SerializedName("NEQ") + NEQ(true, false, false), + + @SerializedName("BETWEEN") + BETWEEN(true, false, true), + + @SerializedName("OUTSIDE") + OUTSIDE(true, false, true), + + @SerializedName("RISES_ABOVE") + RISES_ABOVE(false, false, false), + + @SerializedName("FALLS_BELOW") + FALLS_BELOW(false, false, false), + + @SerializedName("CHANGES_BY") + CHANGES_BY(false, true, false), + + @SerializedName("INCREASES_BY") + INCREASES_BY(false, true, false), + + @SerializedName("DECREASES_BY") + DECREASES_BY(false, true, false), + } + + fun isActive( + v: Double, + wasActive: Boolean, + metric: Metric, + ): Boolean { + val a = value + val b = upper ?: value + val h = hysteresis ?: metric.defaultHysteresis + val e = metric.step / 2.0 + + return if (!wasActive) { + when (operator) { + Operator.GT, Operator.RISES_ABOVE -> v > a + Operator.GTE -> v >= a + Operator.LT, Operator.FALLS_BELOW -> v < a + Operator.LTE -> v <= a + Operator.EQ -> abs(v - a) <= e + Operator.NEQ -> abs(v - a) > e + Operator.BETWEEN -> v in a..b + Operator.OUTSIDE -> v < a || v > b + Operator.CHANGES_BY, Operator.INCREASES_BY, Operator.DECREASES_BY -> false + } + } else { + when (operator) { + Operator.GT, Operator.RISES_ABOVE -> v > a - h + Operator.GTE -> v >= a - h + Operator.LT, Operator.FALLS_BELOW -> v < a + h + Operator.LTE -> v <= a + h + Operator.EQ -> abs(v - a) <= e + h + Operator.NEQ -> abs(v - a) > e + Operator.BETWEEN -> v >= a - h && v <= b + h + Operator.OUTSIDE -> { + val effH = if (b - a <= 2.0 * h) 0.0 else h + v < a + effH || v > b - effH + } + Operator.CHANGES_BY, Operator.INCREASES_BY, Operator.DECREASES_BY -> false + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt index 8fdb2a0e9..6423f4837 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt @@ -76,6 +76,7 @@ object DIYRepository { private var appContext: Context? = null fun init(context: Context) { + DIYRuntimeStore.init(context) appContext = context.applicationContext if (prefs != null) return prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -113,6 +114,7 @@ object DIYRepository { } fun updateAutomation(automation: Automation) { + DIYRuntimeStore.setPaused(automation.id, false) val current = _automations.value.toMutableList() val index = current.indexOfFirst { it.id == automation.id } if (index != -1) { @@ -124,6 +126,7 @@ object DIYRepository { } fun removeAutomation(id: String) { + DIYRuntimeStore.setPaused(id, false) val current = _automations.value.toMutableList() current.removeAll { it.id == id } _automations.value = current @@ -131,6 +134,18 @@ object DIYRepository { updateActionShortcutLauncherState() } + fun pauseAutomation(id: String) { + val current = _automations.value.toMutableList() + val index = current.indexOfFirst { it.id == id } + if (index != -1) { + current[index] = current[index].copy(isEnabled = false) + _automations.value = current + saveToPrefs() + updateActionShortcutLauncherState() + DIYRuntimeStore.setPaused(id, true) + } + } + fun getAutomation(id: String): Automation? = _automations.value.find { it.id == id } private fun updateActionShortcutLauncherState() { diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt new file mode 100644 index 000000000..f3b7e7868 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: DIYRuntimeStore.kt + * Description: Storage for device-local DIY runtime state. + */ + +package com.sameerasw.essentials.domain.diy + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +// Holds device-local runtime state and is deliberately left out of settings export. +object DIYRuntimeStore { + private const val PREFS_NAME = "diy_runtime_prefs" + private const val KEY_PAUSED_IDS = "loop_paused_ids" + private const val KEY_OPEN_STATES_PREFIX = "open_states_" + + private var prefs: SharedPreferences? = null + private val _pausedIds = MutableStateFlow>(emptySet()) + val pausedIds: StateFlow> = _pausedIds.asStateFlow() + + fun init(context: Context) { + if (prefs != null) return + val sharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs = sharedPreferences + _pausedIds.value = sharedPreferences.getStringSet(KEY_PAUSED_IDS, emptySet())?.toSet() ?: emptySet() + } + + fun setPaused( + id: String, + paused: Boolean, + ) { + val current = _pausedIds.value.toMutableSet() + val changed = if (paused) current.add(id) else current.remove(id) + if (changed) { + val newSet = current.toSet() + _pausedIds.value = newSet + prefs?.edit()?.putStringSet(KEY_PAUSED_IDS, newSet)?.apply() + } + } + + fun openStateIds(moduleId: String): Set = + prefs?.getStringSet(KEY_OPEN_STATES_PREFIX + moduleId, emptySet())?.toSet() ?: emptySet() + + fun setOpenStateIds( + moduleId: String, + ids: Set, + ) { + prefs?.edit()?.putStringSet(KEY_OPEN_STATES_PREFIX + moduleId, ids.toSet())?.apply() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt new file mode 100644 index 000000000..5b2589540 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: Metric.kt + * Description: Measurable numeric values for value conditions. + */ + +package com.sameerasw.essentials.domain.diy + +import androidx.annotation.DrawableRes +import androidx.annotation.Keep +import androidx.annotation.StringRes +import com.sameerasw.essentials.R + +@Keep +enum class Metric( + @StringRes val title: Int, + @DrawableRes val icon: Int, + val min: Double, + val max: Double, + val step: Double, + val defaultHysteresis: Double, +) { + BATTERY_LEVEL( + R.string.diy_metric_battery_level, + R.drawable.rounded_battery_android_frame_3_24, + 0.0, + 100.0, + 1.0, + 2.0, + ), +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt index dd62773b8..d6610361a 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt @@ -74,4 +74,13 @@ sealed interface State { override val title: Int get() = R.string.diy_state_calendar_event override val icon: Int get() = R.drawable.rounded_calendar_today_24 } + + @Keep + data class SystemMetric( + @SerializedName("metric") val metric: Metric = Metric.BATTERY_LEVEL, + @SerializedName("comparison") val comparison: Comparison = Comparison(), + ) : State { + override val title: Int get() = metric.title + override val icon: Int get() = metric.icon + } } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt new file mode 100644 index 000000000..cab07f84b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: SystemMetricText.kt + * Description: Formatting functions for system metric conditions. + */ + +package com.sameerasw.essentials.domain.diy + +import android.content.Context +import com.sameerasw.essentials.R +import kotlin.math.roundToInt + +fun systemMetricText( + context: Context, + metric: Metric, + comparison: Comparison, +): String { + val metricTitle = context.getString(metric.title) + val formattedValue = context.getString(R.string.diy_metric_percent, comparison.value.roundToInt()) + val formattedUpper = + comparison.upper?.let { + context.getString(R.string.diy_metric_percent, it.roundToInt()) + } ?: formattedValue + + val formatRes = + when (comparison.operator) { + Comparison.Operator.GT -> R.string.diy_metric_summary_gt + Comparison.Operator.GTE -> R.string.diy_metric_summary_gte + Comparison.Operator.LT -> R.string.diy_metric_summary_lt + Comparison.Operator.LTE -> R.string.diy_metric_summary_lte + Comparison.Operator.EQ -> R.string.diy_metric_summary_eq + Comparison.Operator.NEQ -> R.string.diy_metric_summary_neq + Comparison.Operator.BETWEEN -> R.string.diy_metric_summary_between + Comparison.Operator.OUTSIDE -> R.string.diy_metric_summary_outside + Comparison.Operator.RISES_ABOVE -> R.string.diy_metric_summary_rises_above + Comparison.Operator.FALLS_BELOW -> R.string.diy_metric_summary_falls_below + Comparison.Operator.CHANGES_BY -> R.string.diy_metric_summary_changes_by + Comparison.Operator.INCREASES_BY -> R.string.diy_metric_summary_increases_by + Comparison.Operator.DECREASES_BY -> R.string.diy_metric_summary_decreases_by + } + return context.getString(formatRes, metricTitle, formattedValue, formattedUpper) +} + +fun conditionText( + context: Context, + automation: Automation, +): String = + when (automation.type) { + Automation.Type.TRIGGER -> + when (val trigger = automation.trigger) { + is Trigger.SystemMetric -> systemMetricText(context, trigger.metric, trigger.comparison) + else -> "" + } + Automation.Type.STATE -> + when (val state = automation.state) { + is State.SystemMetric -> systemMetricText(context, state.metric, state.comparison) + is State.BatteryLevel -> context.getString(R.string.diy_battery_level_range, state.minLevel, state.maxLevel) + else -> "" + } + else -> "" + } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt new file mode 100644 index 000000000..962f5c449 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: SystemMetricTracker.kt + * Description: Pure Kotlin tracker for numeric value triggers, states, holds, and loop breaking. + */ + +package com.sameerasw.essentials.domain.diy + +import kotlin.math.abs + +class SystemMetricTracker( + private val clock: () -> Long, + private val scheduler: HoldScheduler, + private val openStore: OpenStateStore, + private val listener: Listener, +) { + fun interface HoldScheduler { + fun schedule( + delayMs: Long, + block: () -> Unit, + ): () -> Unit + } + + interface OpenStateStore { + fun load(): Set + + fun save(ids: Set) + } + + interface Listener { + fun onFire(automation: Automation) + + fun onEnter(automation: Automation) + + fun onExit(automation: Automation) + + fun onLoopBreak(automation: Automation) + } + + private data class ConditionKey( + val metric: Metric, + val comparison: Comparison, + val cooldownSeconds: Int, + ) + + private class AutomationRecord( + var automation: Automation, + val metric: Metric, + val comparison: Comparison, + val cooldownSeconds: Int, + ) { + var hasBaseline: Boolean = false + var active: Boolean = false + var open: Boolean = false + var deltaRef: Double? = null + var lastFireTime: Long? = null + var cancelHold: (() -> Unit)? = null + val recentTimestamps = mutableListOf() + var tripped: Boolean = false + + fun conditionKey(): ConditionKey = ConditionKey(metric, comparison, cooldownSeconds) + } + + private val records = mutableMapOf() + private val latestReadings = mutableMapOf() + private val restoredOpenIds = openStore.load().toMutableSet() + private var firstUpdate = true + + fun updateAutomations(automations: List) { + val relevantAutomations = automations.filter { extractConfig(it) != null } + + if (firstUpdate) { + firstUpdate = false + val relevantIds = relevantAutomations.map { it.id }.toSet() + val dropped = restoredOpenIds.filter { it !in relevantIds } + if (dropped.isNotEmpty()) { + restoredOpenIds.removeAll(dropped.toSet()) + saveOpenState() + } + } + + val newRecords = mutableMapOf() + val newIds = relevantAutomations.map { it.id }.toSet() + + for ((id, oldRecord) in records) { + if (id !in newIds) { + oldRecord.cancelHold?.invoke() + oldRecord.cancelHold = null + if (oldRecord.open) { + oldRecord.open = false + saveOpenState() + listener.onExit(oldRecord.automation) + } else if (!oldRecord.hasBaseline && id in restoredOpenIds) { + restoredOpenIds.remove(id) + saveOpenState() + listener.onExit(oldRecord.automation) + } + } + } + + for (automation in relevantAutomations) { + val (metric, comparison) = extractConfig(automation) ?: continue + val cooldown = (automation.trigger as? Trigger.SystemMetric)?.cooldownSeconds ?: 0 + val newKey = ConditionKey(metric, comparison, cooldown) + + val existing = records[automation.id] + if (existing != null) { + if (existing.conditionKey() == newKey) { + existing.automation = automation + newRecords[automation.id] = existing + } else { + // Condition changed: treat as remove + add + existing.cancelHold?.invoke() + existing.cancelHold = null + if (existing.open) { + existing.open = false + saveOpenState() + listener.onExit(existing.automation) + } + val newRecord = AutomationRecord(automation, metric, comparison, cooldown) + newRecords[automation.id] = newRecord + val currentVal = latestReadings[metric] + if (currentVal != null) { + baselineRecord(newRecord, currentVal) + } + } + } else { + val newRecord = AutomationRecord(automation, metric, comparison, cooldown) + newRecords[automation.id] = newRecord + val currentVal = latestReadings[metric] + if (currentVal != null) { + baselineRecord(newRecord, currentVal) + } + } + } + + records.clear() + records.putAll(newRecords) + saveOpenState() + } + + fun onReading( + metric: Metric, + value: Double, + ) { + if (latestReadings[metric] == value) return + latestReadings[metric] = value + + for (record in records.values) { + if (record.metric != metric || record.tripped) continue + + if (!record.hasBaseline) { + baselineRecord(record, value) + continue + } + + if (record.comparison.operator.isDelta) { + evaluateDeltaTrigger(record, value) + } else { + evaluateLevelCondition(record, value) + } + } + } + + fun closeAll() { + for (record in records.values) { + record.cancelHold?.invoke() + record.cancelHold = null + if (record.open) { + record.open = false + listener.onExit(record.automation) + } else if (!record.hasBaseline && record.automation.id in restoredOpenIds) { + restoredOpenIds.remove(record.automation.id) + listener.onExit(record.automation) + } + } + records.clear() + restoredOpenIds.clear() + openStore.save(emptySet()) + } + + fun currentValue(metric: Metric): Double? = latestReadings[metric] + + private fun extractConfig(automation: Automation): Pair? { + return when (automation.type) { + Automation.Type.TRIGGER -> { + val trigger = automation.trigger as? Trigger.SystemMetric ?: return null + Pair(trigger.metric, trigger.comparison) + } + Automation.Type.STATE -> { + when (val state = automation.state) { + is State.SystemMetric -> Pair(state.metric, state.comparison) + is State.BatteryLevel -> + Pair( + Metric.BATTERY_LEVEL, + Comparison( + operator = Comparison.Operator.BETWEEN, + value = state.minLevel.toDouble(), + upper = state.maxLevel.toDouble(), + holdSeconds = 0, + hysteresis = 0.0, + ), + ) + else -> null + } + } + else -> null + } + } + + private fun baselineRecord( + record: AutomationRecord, + v: Double, + ) { + record.hasBaseline = true + val isRestored = restoredOpenIds.remove(record.automation.id) + + if (record.comparison.operator.isDelta) { + record.deltaRef = v + return + } + + val isActive = record.comparison.isActive(v, wasActive = false, metric = record.metric) + record.active = isActive + + if (record.automation.type == Automation.Type.STATE) { + if (isRestored) { + if (isActive) { + record.open = true + saveOpenState() + } else { + record.open = false + saveOpenState() + listener.onExit(record.automation) + } + } else { + if (isActive) { + if (record.comparison.holdSeconds > 0) { + scheduleHold(record) + } else { + executeEnter(record) + } + } + } + } + } + + private fun scheduleHold(record: AutomationRecord) { + record.cancelHold?.invoke() + val delayMs = record.comparison.holdSeconds * 1000L + record.cancelHold = + scheduler.schedule(delayMs) { + record.cancelHold = null + if (!record.tripped && record.active) { + if (record.automation.type == Automation.Type.TRIGGER) { + executeFire(record) + } else if (record.automation.type == Automation.Type.STATE && !record.open) { + executeEnter(record) + } + } + } + } + + private fun executeFire(record: AutomationRecord) { + if (!checkLoopBreaker(record)) return + record.lastFireTime = clock() + listener.onFire(record.automation) + } + + private fun executeEnter(record: AutomationRecord) { + if (!checkLoopBreaker(record)) return + record.open = true + saveOpenState() + listener.onEnter(record.automation) + } + + private fun checkLoopBreaker(record: AutomationRecord): Boolean { + val now = clock() + record.recentTimestamps.retainAll { now - it < LOOP_WINDOW_MS } + if (record.recentTimestamps.size >= LOOP_LIMIT) { + record.tripped = true + if (record.open) { + record.open = false + saveOpenState() + listener.onExit(record.automation) + } + listener.onLoopBreak(record.automation) + return false + } + record.recentTimestamps.add(now) + return true + } + + private fun evaluateDeltaTrigger( + record: AutomationRecord, + v: Double, + ) { + val ref = + record.deltaRef ?: run { + record.deltaRef = v + return + } + + val a = record.comparison.value + val shouldFire = + when (record.comparison.operator) { + Comparison.Operator.CHANGES_BY -> abs(v - ref) >= a + Comparison.Operator.INCREASES_BY -> { + val met = v - ref >= a + if (!met) { + record.deltaRef = minOf(ref, v) + } + met + } + Comparison.Operator.DECREASES_BY -> { + val met = ref - v >= a + if (!met) { + record.deltaRef = maxOf(ref, v) + } + met + } + else -> false + } + + if (shouldFire) { + val now = clock() + val cooldownMs = maxOf(record.cooldownSeconds, Trigger.MIN_VALUE_COOLDOWN_SECONDS) * 1000L + val last = record.lastFireTime + if (last == null || now - last >= cooldownMs) { + if (checkLoopBreaker(record)) { + record.lastFireTime = now + record.deltaRef = v + listener.onFire(record.automation) + } + } + } + } + + private fun evaluateLevelCondition( + record: AutomationRecord, + v: Double, + ) { + val wasActive = record.active + val isActive = record.comparison.isActive(v, wasActive, record.metric) + record.active = isActive + + if (!wasActive && isActive) { + if (record.automation.type == Automation.Type.TRIGGER) { + val now = clock() + val cooldownMs = maxOf(record.cooldownSeconds, Trigger.MIN_VALUE_COOLDOWN_SECONDS) * 1000L + val last = record.lastFireTime + if (last == null || now - last >= cooldownMs) { + if (record.comparison.holdSeconds > 0) { + scheduleHold(record) + } else { + executeFire(record) + } + } + } else if (record.automation.type == Automation.Type.STATE) { + if (record.comparison.holdSeconds > 0) { + scheduleHold(record) + } else { + executeEnter(record) + } + } + } else if (wasActive && !isActive) { + record.cancelHold?.invoke() + record.cancelHold = null + + if (record.automation.type == Automation.Type.STATE && record.open) { + record.open = false + saveOpenState() + listener.onExit(record.automation) + } + } + } + + private fun saveOpenState() { + val openIds = + records.values + .filter { it.open } + .map { it.automation.id } + .toSet() + restoredOpenIds + openStore.save(openIds) + } + + companion object { + const val LOOP_LIMIT = 5 + const val LOOP_WINDOW_MS = 60_000L + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt index 779f56b22..3d8cb53a4 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt @@ -206,9 +206,22 @@ sealed interface Trigger { override val icon: Int = R.drawable.rounded_directions_car_24 } + @Keep + data class SystemMetric( + @SerializedName("metric") val metric: Metric = Metric.BATTERY_LEVEL, + @SerializedName("comparison") val comparison: Comparison = Comparison(), + @SerializedName("cooldownSeconds") val cooldownSeconds: Int = DEFAULT_VALUE_COOLDOWN_SECONDS, + ) : Trigger { + override val title: Int get() = metric.title + override val icon: Int get() = metric.icon + override val isConfigurable: Boolean get() = true + } + companion object { const val DEFAULT_LOCATION_RADIUS_METERS = 200 const val MIN_LOCATION_RADIUS_METERS = 100 const val MAX_LOCATION_RADIUS_METERS = 5000 + const val DEFAULT_VALUE_COOLDOWN_SECONDS = 60 + const val MIN_VALUE_COOLDOWN_SECONDS = 5 } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt index b2580e51b..48e43e2e9 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt @@ -123,7 +123,7 @@ object AutomationManager { when (automation.type) { Automation.Type.TRIGGER -> { when (automation.trigger) { - is Trigger.ChargerConnected, is Trigger.ChargerDisconnected, is Trigger.PowerSavingOn, is Trigger.PowerSavingOff -> { + is Trigger.ChargerConnected, is Trigger.ChargerDisconnected, is Trigger.PowerSavingOn, is Trigger.PowerSavingOff, is Trigger.SystemMetric -> { requiredModuleIds.add(PowerModule.ID) powerAutomations.add(automation) } @@ -169,7 +169,7 @@ object AutomationManager { Automation.Type.STATE -> { when (automation.state) { - is DIYState.Charging, is DIYState.PowerSaving, is DIYState.BatteryLevel -> { + is DIYState.Charging, is DIYState.PowerSaving, is DIYState.BatteryLevel, is DIYState.SystemMetric -> { requiredModuleIds.add(PowerModule.ID) powerAutomations.add(automation) } diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt index 62c21b6c1..be4a4efeb 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt @@ -14,13 +14,25 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager +import android.os.Handler +import android.os.Looper import android.os.PowerManager +import android.os.SystemClock +import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.DIYRuntimeStore +import com.sameerasw.essentials.domain.diy.Metric +import com.sameerasw.essentials.domain.diy.SystemMetricTracker import com.sameerasw.essentials.domain.diy.Trigger import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor +import com.sameerasw.essentials.utils.AutomationFailureNotifier import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import com.sameerasw.essentials.domain.diy.State as DIYState class PowerModule : AutomationModule { @@ -31,13 +43,31 @@ class PowerModule : AutomationModule { override val id: String = ID private var automations: List = emptyList() private val scope = CoroutineScope(Dispatchers.IO) + private var tracker: SystemMetricTracker? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private val automationMutexes = mutableMapOf() // State tracking private var isCharging = false private var isPowerSaving = false private var appContext: Context? = null private var batteryLevel = -1 - private val batteryStateActive = mutableMapOf() + + private fun getMutex(id: String): Mutex = automationMutexes.getOrPut(id) { Mutex() } + + private fun runOrdered( + automation: Automation, + actions: List, + ) { + if (actions.isEmpty()) return + val context = appContext ?: return + val mutex = getMutex(automation.id) + scope.launch(start = CoroutineStart.UNDISPATCHED) { + mutex.withLock { + CombinedActionExecutor.executeAll(context, actions) + } + } + } private val receiver = object : BroadcastReceiver() { @@ -69,7 +99,8 @@ class PowerModule : AutomationModule { val percent = level * 100 / scale if (percent != batteryLevel) { batteryLevel = percent - evaluateBatteryLevel(context) + // The broadcast fires on any battery field change, so only forward level changes. + tracker?.onReading(Metric.BATTERY_LEVEL, percent.toDouble()) } } } @@ -101,6 +132,44 @@ class PowerModule : AutomationModule { addAction(Intent.ACTION_BATTERY_CHANGED) } appContext = context.applicationContext + DIYRuntimeStore.init(context) + tracker = + SystemMetricTracker( + clock = { SystemClock.elapsedRealtime() }, + scheduler = { delayMs, block -> + val runnable = Runnable { block() } + mainHandler.postDelayed(runnable, delayMs) + val cancel: () -> Unit = { mainHandler.removeCallbacks(runnable) } + cancel + }, + openStore = + object : SystemMetricTracker.OpenStateStore { + override fun load(): Set = DIYRuntimeStore.openStateIds(ID) + + override fun save(ids: Set) = DIYRuntimeStore.setOpenStateIds(ID, ids) + }, + listener = + object : SystemMetricTracker.Listener { + override fun onFire(automation: Automation) { + runOrdered(automation, automation.actionList) + } + + override fun onEnter(automation: Automation) { + runOrdered(automation, automation.entryActionList) + } + + override fun onExit(automation: Automation) { + runOrdered(automation, automation.exitActionList) + } + + override fun onLoopBreak(automation: Automation) { + DIYRepository.pauseAutomation(automation.id) + appContext?.let { + AutomationFailureNotifier.notifyLoopBreak(it, automation) + } + } + }, + ) context.registerReceiver(receiver, filter) // Initial check for charging @@ -125,36 +194,21 @@ class PowerModule : AutomationModule { } override fun stop(context: Context) { + tracker?.closeAll() + tracker = null + mainHandler.removeCallbacksAndMessages(null) try { context.unregisterReceiver(receiver) } catch (e: Exception) { // Ignore if not registered } - batteryStateActive.clear() batteryLevel = -1 appContext = null } override fun updateAutomations(automations: List) { this.automations = automations - batteryStateActive.keys.retainAll(automations.map { it.id }.toSet()) - appContext?.let { evaluateBatteryLevel(it) } - } - - private fun evaluateBatteryLevel(context: Context) { - val level = batteryLevel - if (level < 0) return - automations - .filter { it.type == Automation.Type.STATE && it.isEnabled } - .forEach { automation -> - val state = automation.state as? DIYState.BatteryLevel ?: return@forEach - val inRange = level in state.minLevel..state.maxLevel - val previous = batteryStateActive.put(automation.id, inRange) - if (inRange == (previous ?: false)) return@forEach - val actions = if (inRange) automation.entryActionList else automation.exitActionList - if (actions.isEmpty()) return@forEach - scope.launch { CombinedActionExecutor.executeAll(context, actions) } - } + tracker?.updateAutomations(automations) } private fun handleTrigger( diff --git a/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt b/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt new file mode 100644 index 000000000..9fc31c5fa --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: LoopBreakerReceiver.kt + * Description: Receiver to re-enable automations paused by the loop breaker. + */ + +package com.sameerasw.essentials.services.receivers + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.sameerasw.essentials.domain.diy.DIYRepository + +class LoopBreakerReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val automationId = intent.getStringExtra("automation_id") ?: return + DIYRepository.init(context) + val automation = DIYRepository.getAutomation(automationId) ?: return + DIYRepository.updateAutomation(automation.copy(isEnabled = true)) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(automationId.hashCode()) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt index e878b624f..afd3ec66f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt @@ -87,9 +87,11 @@ import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.ActionRegistry import com.sameerasw.essentials.domain.diy.Automation -import com.sameerasw.essentials.domain.model.AppPermission +import com.sameerasw.essentials.domain.diy.Comparison import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.Metric import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.domain.model.AppPermission import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.ui.components.CategoryExpandableSection @@ -99,6 +101,7 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker import com.sameerasw.essentials.ui.core.sheets.BluetoothDeviceSelectionSheet import com.sameerasw.essentials.ui.core.sheets.NotificationTriggerSheet +import com.sameerasw.essentials.ui.core.sheets.SystemMetricSheet import com.sameerasw.essentials.ui.core.sheets.WifiNetworkSelectionSheet import com.sameerasw.essentials.ui.features.location.sheets.LocationTriggerSheet import com.sameerasw.essentials.ui.features.system.ActionSequenceEditor @@ -208,7 +211,25 @@ class AutomationEditorActivity : ComponentActivity() { // State for selections // Initialize with existing data or defaults var selectedTrigger by remember { mutableStateOf(existingAutomation?.trigger) } - var selectedState by remember { mutableStateOf(existingAutomation?.state) } + var selectedState by remember { + val state = existingAutomation?.state + val initial = + if (state is DIYState.BatteryLevel) { + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = + Comparison( + operator = Comparison.Operator.BETWEEN, + value = state.minLevel.toDouble(), + upper = state.maxLevel.toDouble(), + hysteresis = 0.0, + ), + ) + } else { + state + } + mutableStateOf(initial) + } var selectedApps by remember { mutableStateOf>( existingAutomation?.selectedApps ?: emptyList(), @@ -358,10 +379,10 @@ class AutomationEditorActivity : ComponentActivity() { // Config Sheets var showTimeSettings by remember { mutableStateOf(false) } var showCalendarStateSettings by remember { mutableStateOf(false) } - var showBatteryLevelSettings by remember { mutableStateOf(false) } var showBluetoothSettings by remember { mutableStateOf(false) } var showWifiSettings by remember { mutableStateOf(false) } var showNotificationTriggerSettings by remember { mutableStateOf(false) } + var showSystemMetricSettings by remember { mutableStateOf(false) } var editingLocationTrigger by remember { mutableStateOf(null) } val isTriggerConfigured = @@ -1012,6 +1033,11 @@ class AutomationEditorActivity : ComponentActivity() { Trigger.ChargerDisconnected, Trigger.PowerSavingOn, Trigger.PowerSavingOff, + (selectedTrigger as? Trigger.SystemMetric)?.takeIf { + it.metric == + Metric.BATTERY_LEVEL + } + ?: Trigger.SystemMetric(metric = Metric.BATTERY_LEVEL), ), R.string.diy_category_connectivity to listOf( @@ -1071,7 +1097,12 @@ class AutomationEditorActivity : ComponentActivity() { .firstOrNull { (_, list) -> list.any { selectedTrigger != null && - it::class == selectedTrigger!!::class + it::class == selectedTrigger!!::class && + ( + it !is Trigger.SystemMetric || + (selectedTrigger as? Trigger.SystemMetric)?.metric == + it.metric + ) } }?.first ?: triggerCategories.firstOrNull()?.first, ) @@ -1095,7 +1126,13 @@ class AutomationEditorActivity : ComponentActivity() { ) { triggerList.forEach { trigger -> val isSelected = - selectedTrigger != null && selectedTrigger!!::class == trigger::class + selectedTrigger != null && + selectedTrigger!!::class == trigger::class && + ( + trigger !is Trigger.SystemMetric || + (selectedTrigger as? Trigger.SystemMetric)?.metric == + trigger.metric + ) EditorActionItem( title = stringResource(trigger.title), iconRes = trigger.icon, @@ -1119,6 +1156,10 @@ class AutomationEditorActivity : ComponentActivity() { true is Trigger.NotificationReceived -> showNotificationTriggerSettings = true is Trigger.ArriveAtLocation, is Trigger.LeaveLocation -> editingLocationTrigger = trigger + is Trigger.SystemMetric -> { + selectedTrigger = trigger + showSystemMetricSettings = true + } else -> {} } }, @@ -1134,7 +1175,11 @@ class AutomationEditorActivity : ComponentActivity() { listOf( DIYState.Charging, DIYState.PowerSaving, - (selectedState as? DIYState.BatteryLevel) ?: DIYState.BatteryLevel(), + (selectedState as? DIYState.SystemMetric)?.takeIf { + it.metric == + Metric.BATTERY_LEVEL + } + ?: DIYState.SystemMetric(metric = Metric.BATTERY_LEVEL), ), R.string.diy_category_system_screen to listOf( @@ -1166,7 +1211,13 @@ class AutomationEditorActivity : ComponentActivity() { stateCategories .firstOrNull { (_, list) -> list.any { - selectedState != null && it::class == selectedState!!::class + selectedState != null && + it::class == selectedState!!::class && + ( + it !is DIYState.SystemMetric || + (selectedState as? DIYState.SystemMetric)?.metric == + it.metric + ) } }?.first ?: stateCategories.firstOrNull()?.first, ) @@ -1190,25 +1241,34 @@ class AutomationEditorActivity : ComponentActivity() { ) { stateList.forEach { state -> val isSelected = - selectedState != null && selectedState!!::class == state::class + selectedState != null && + selectedState!!::class == state::class && + ( + state !is DIYState.SystemMetric || + (selectedState as? DIYState.SystemMetric)?.metric == + state.metric + ) EditorActionItem( title = stringResource(state.title), iconRes = state.icon, isSelected = isSelected, onClick = { selectedState = state }, - isConfigurable = state is DIYState.TimePeriod || state is DIYState.CalendarEvent || state is DIYState.BatteryLevel, + isConfigurable = + state is DIYState.TimePeriod || + state is DIYState.CalendarEvent || + state is DIYState.SystemMetric, onSettingsClick = { if (state is DIYState.TimePeriod) { showTimeSettings = true } - if (state is DIYState.BatteryLevel) { - selectedState = state - showBatteryLevelSettings = true - } if (state is DIYState.CalendarEvent) { selectedState = state showCalendarStateSettings = true } + if (state is DIYState.SystemMetric) { + selectedState = state + showSystemMetricSettings = true + } }, ) } @@ -1327,10 +1387,12 @@ class AutomationEditorActivity : ComponentActivity() { } } - if (showBatteryLevelSettings) { - com.sameerasw.essentials.ui.core.sheets.BatteryLevelStateSheet( - initialState = selectedState as? DIYState.BatteryLevel, - onDismiss = { showBatteryLevelSettings = false }, + if (showSystemMetricSettings) { + SystemMetricSheet( + initialTrigger = selectedTrigger as? Trigger.SystemMetric, + initialState = selectedState as? DIYState.SystemMetric, + onDismiss = { showSystemMetricSettings = false }, + onTriggerChange = { selectedTrigger = it }, onStateChange = { selectedState = it }, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt deleted file mode 100644 index 8722bf6f6..000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2026 sameerasw.com - * License: MIT License - * - * Feature Module: Core UI Sheets - * File: BatteryLevelStateSheet.kt - * Description: Configures the battery level DIY state range. - */ - -package com.sameerasw.essentials.ui.core.sheets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RangeSlider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.sameerasw.essentials.R -import com.sameerasw.essentials.utils.HapticUtil -import kotlin.math.roundToInt -import com.sameerasw.essentials.domain.diy.State as DIYState - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BatteryLevelStateSheet( - initialState: DIYState.BatteryLevel?, - onDismiss: () -> Unit, - onStateChange: (DIYState.BatteryLevel) -> Unit, -) { - val view = LocalView.current - var state by remember { mutableStateOf(initialState ?: DIYState.BatteryLevel()) } - - EssentialsBottomSheet(onDismissRequest = onDismiss) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = stringResource(R.string.diy_state_battery_level), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 8.dp), - ) - Text( - text = stringResource(R.string.diy_battery_level_range, state.minLevel, state.maxLevel), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) - RangeSlider( - value = state.minLevel.toFloat()..state.maxLevel.toFloat(), - onValueChange = { range -> - val next = DIYState.BatteryLevel(range.start.roundToInt(), range.endInclusive.roundToInt()) - if (next != state) { - HapticUtil.performSliderHaptic(view) - state = next - onStateChange(next) - } - }, - valueRange = 0f..100f, - steps = 99, - ) - } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt index e52f6031c..ebdabad9b 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,7 +62,9 @@ import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.DIYRuntimeStore import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.domain.diy.systemMetricText import com.sameerasw.essentials.domain.diy.State as DIYState import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem @@ -86,6 +89,7 @@ fun AutomationItem( ) { val view = LocalView.current var showMenu by remember { mutableStateOf(false) } + val pausedIds by DIYRuntimeStore.pausedIds.collectAsState() Card( colors = @@ -296,9 +300,16 @@ fun AutomationItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - automationSummary(automation)?.let { summary -> + val isPaused = !automation.isEnabled && automation.id in pausedIds + val summary = + if (isPaused) { + stringResource(R.string.diy_paused_loop) + } else { + automationSummary(automation) + } + summary?.let { Text( - text = summary, + text = it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), maxLines = 2, @@ -450,6 +461,7 @@ private fun automationSummary(automation: Automation): String? { trigger.appName.ifBlank { null }, trigger.textContains.ifBlank { null }?.let { "\"$it\"" }, ).joinToString(" · ").ifBlank { null } + is Trigger.SystemMetric -> systemMetricText(context, trigger.metric, trigger.comparison) else -> null } @@ -458,6 +470,7 @@ private fun automationSummary(automation: Automation): String? { is DIYState.TimePeriod -> "${time(state.startHour, state.startMinute)} – ${time(state.endHour, state.endMinute)} · ${days(state.days)}" is DIYState.BatteryLevel -> "${state.minLevel}% – ${state.maxLevel}%" + is DIYState.SystemMetric -> systemMetricText(context, state.metric, state.comparison) is DIYState.CalendarEvent -> if (state.calendarIds.isEmpty()) { allCalendars diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt new file mode 100644 index 000000000..e90ca50c3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Core UI Sheets + * File: SystemMetricSheet.kt + * Description: Bottom sheet editor for system metric triggers and states. + */ + +package com.sameerasw.essentials.ui.core.sheets + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Comparison +import com.sameerasw.essentials.domain.diy.Metric +import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.ui.components.CategoryExpandableSection +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil +import kotlin.math.roundToInt +import com.sameerasw.essentials.domain.diy.State as DIYState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SystemMetricSheet( + initialTrigger: Trigger.SystemMetric?, + initialState: DIYState.SystemMetric?, + onDismiss: () -> Unit, + onTriggerChange: (Trigger.SystemMetric) -> Unit, + onStateChange: (DIYState.SystemMetric) -> Unit, +) { + val view = LocalView.current + val context = LocalContext.current + val isTrigger = initialTrigger != null + + val metric = initialTrigger?.metric ?: initialState?.metric ?: Metric.BATTERY_LEVEL + var comparison by remember { + mutableStateOf(initialTrigger?.comparison ?: initialState?.comparison ?: Comparison()) + } + var cooldownSeconds by remember { + mutableIntStateOf(initialTrigger?.cooldownSeconds ?: Trigger.DEFAULT_VALUE_COOLDOWN_SECONDS) + } + var advancedExpanded by remember { mutableStateOf(false) } + + fun notifyChange() { + if (isTrigger) { + onTriggerChange( + Trigger.SystemMetric( + metric = metric, + comparison = comparison, + cooldownSeconds = cooldownSeconds, + ), + ) + } else { + onStateChange( + DIYState.SystemMetric( + metric = metric, + comparison = comparison, + ), + ) + } + } + + var currentBatteryPercent by remember { + val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = context.registerReceiver(null, filter) + val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100 + val p = if (level >= 0 && scale > 0) level * 100 / scale else null + mutableStateOf(p) + } + + DisposableEffect(context) { + val receiver = + object : BroadcastReceiver() { + override fun onReceive( + c: Context, + intent: Intent, + ) { + if (intent.action == Intent.ACTION_BATTERY_CHANGED) { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) + if (level >= 0 && scale > 0) { + currentBatteryPercent = level * 100 / scale + } + } + } + } + val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + context.registerReceiver(receiver, filter) + onDispose { + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + // Ignore if not registered + } + } + } + + EssentialsBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(metric.title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 8.dp), + ) + + val bigText = + if (comparison.operator.isRange) { + stringResource( + R.string.diy_battery_level_range, + comparison.value.roundToInt(), + (comparison.upper ?: comparison.value).roundToInt(), + ) + } else { + stringResource(R.string.diy_metric_percent, comparison.value.roundToInt()) + } + Text( + text = bigText, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + + val batteryLevelValue = currentBatteryPercent + if (batteryLevelValue != null) { + Text( + text = + stringResource( + R.string.diy_metric_now, + stringResource(R.string.diy_metric_percent, batteryLevelValue), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } + + val operators = + if (isTrigger) { + Comparison.Operator.entries + } else { + Comparison.Operator.entries.filter { it.forStates } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + operators.forEach { op -> + FilterChip( + selected = comparison.operator == op, + onClick = { + if (comparison.operator != op) { + HapticUtil.performVirtualKeyHaptic(view) + val newUpper = + if (op.isRange) { + comparison.upper ?: minOf(comparison.value + 20.0, metric.max) + } else { + comparison.upper + } + val newValue = + if (op.isDelta && comparison.value < metric.step) { + metric.step + } else { + comparison.value + } + comparison = comparison.copy(operator = op, upper = newUpper, value = newValue) + notifyChange() + } + }, + label = { Text(stringResource(operatorLabel(op))) }, + ) + } + } + + if (comparison.operator.isRange) { + val start = comparison.value.toFloat() + val end = (comparison.upper ?: comparison.value).toFloat() + val steps = ((metric.max - metric.min) / metric.step).roundToInt() - 1 + RangeSlider( + value = start..end, + onValueChange = { range -> + val newStart = + range.start + .toDouble() + .roundToInt() + .toDouble() + val newEnd = + range.endInclusive + .toDouble() + .roundToInt() + .toDouble() + if (newStart != comparison.value || newEnd != comparison.upper) { + HapticUtil.performSliderHaptic(view) + comparison = comparison.copy(value = newStart, upper = newEnd) + notifyChange() + } + }, + valueRange = metric.min.toFloat()..metric.max.toFloat(), + steps = steps.coerceAtLeast(0), + ) + } else { + val minVal = if (comparison.operator.isDelta) metric.step else metric.min + val maxVal = metric.max + val steps = ((maxVal - minVal) / metric.step).roundToInt() - 1 + Slider( + value = comparison.value.toFloat().coerceIn(minVal.toFloat(), maxVal.toFloat()), + onValueChange = { v -> + val newV = v.toDouble().roundToInt().toDouble() + if (newV != comparison.value) { + HapticUtil.performSliderHaptic(view) + comparison = comparison.copy(value = newV) + notifyChange() + } + }, + valueRange = minVal.toFloat()..maxVal.toFloat(), + steps = steps.coerceAtLeast(0), + ) + } + + val showHold = !comparison.operator.isDelta + val showCooldown = isTrigger + val showHysteresis = !comparison.operator.isDelta && comparison.operator != Comparison.Operator.NEQ + val advancedCount = (if (showHold) 1 else 0) + (if (showCooldown) 1 else 0) + (if (showHysteresis) 1 else 0) + + if (advancedCount > 0) { + CategoryExpandableSection( + title = stringResource(R.string.diy_metric_advanced), + itemCount = advancedCount, + isExpanded = advancedExpanded, + onToggleExpand = { advancedExpanded = !advancedExpanded }, + ) { + RoundedCardContainer( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.padding(top = 8.dp), + ) { + if (showHold) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.diy_metric_hold), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + if (comparison.holdSeconds == 0) { + stringResource(R.string.diy_metric_hold_off) + } else { + stringResource(R.string.diy_metric_seconds, comparison.holdSeconds) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + Slider( + value = comparison.holdSeconds.toFloat(), + onValueChange = { v -> + val stepped = (v / 5f).roundToInt() * 5 + if (stepped != comparison.holdSeconds) { + HapticUtil.performSliderHaptic(view) + comparison = comparison.copy(holdSeconds = stepped) + notifyChange() + } + }, + valueRange = 0f..300f, + steps = 59, + ) + } + } + + if (showCooldown) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.diy_metric_cooldown), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.diy_metric_seconds, cooldownSeconds), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + Slider( + value = cooldownSeconds.toFloat(), + onValueChange = { v -> + val stepped = (v / 5f).roundToInt() * 5 + val clamped = stepped.coerceIn(Trigger.MIN_VALUE_COOLDOWN_SECONDS, 600) + if (clamped != cooldownSeconds) { + HapticUtil.performSliderHaptic(view) + cooldownSeconds = clamped + notifyChange() + } + }, + valueRange = Trigger.MIN_VALUE_COOLDOWN_SECONDS.toFloat()..600f, + steps = 118, + ) + } + } + + if (showHysteresis) { + val currentHysteresis = (comparison.hysteresis ?: metric.defaultHysteresis).roundToInt() + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.diy_metric_hysteresis), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.diy_metric_percent, currentHysteresis), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + Slider( + value = currentHysteresis.toFloat(), + onValueChange = { v -> + val stepped = v.roundToInt() + if (stepped != currentHysteresis) { + HapticUtil.performSliderHaptic(view) + comparison = comparison.copy(hysteresis = stepped.toDouble()) + notifyChange() + } + }, + valueRange = 0f..10f, + steps = 9, + ) + } + } + } + } + } + } + } +} + +private fun operatorLabel(op: Comparison.Operator): Int = + when (op) { + Comparison.Operator.GT -> R.string.diy_metric_op_gt + Comparison.Operator.GTE -> R.string.diy_metric_op_gte + Comparison.Operator.LT -> R.string.diy_metric_op_lt + Comparison.Operator.LTE -> R.string.diy_metric_op_lte + Comparison.Operator.EQ -> R.string.diy_metric_op_eq + Comparison.Operator.NEQ -> R.string.diy_metric_op_neq + Comparison.Operator.BETWEEN -> R.string.diy_metric_op_between + Comparison.Operator.OUTSIDE -> R.string.diy_metric_op_outside + Comparison.Operator.RISES_ABOVE -> R.string.diy_metric_op_rises_above + Comparison.Operator.FALLS_BELOW -> R.string.diy_metric_op_falls_below + Comparison.Operator.CHANGES_BY -> R.string.diy_metric_op_changes_by + Comparison.Operator.INCREASES_BY -> R.string.diy_metric_op_increases_by + Comparison.Operator.DECREASES_BY -> R.string.diy_metric_op_decreases_by + } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt index b7274ef4c..2cd909587 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt @@ -19,11 +19,29 @@ import androidx.core.app.NotificationCompat import com.sameerasw.essentials.MainActivity import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.conditionText import com.sameerasw.essentials.domain.model.AppPermission +import com.sameerasw.essentials.services.receivers.LoopBreakerReceiver object AutomationFailureNotifier { private const val CHANNEL_ID = "automations" + private fun createChannelIfNeeded( + context: Context, + manager: NotificationManager, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.automation_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + } + } + fun missingPermissions( context: Context, action: Action, @@ -54,15 +72,7 @@ object AutomationFailureNotifier { ) { if (!PermissionUtils.isPostNotificationsEnabled(context)) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - context.getString(R.string.automation_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ), - ) - } + createChannelIfNeeded(context, manager) val actionName = context.getString(action.title) val openApp = @@ -87,4 +97,53 @@ object AutomationFailureNotifier { .build() manager.notify(action::class.java.name.hashCode(), notification) } + + fun notifyLoopBreak( + context: Context, + automation: Automation, + ) { + if (!PermissionUtils.isPostNotificationsEnabled(context)) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createChannelIfNeeded(context, manager) + + val openApp = + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val reenableIntent = + Intent(context, LoopBreakerReceiver::class.java).apply { + putExtra("automation_id", automation.id) + } + val reenablePendingIntent = + PendingIntent.getBroadcast( + context, + automation.id.hashCode(), + reenableIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val condText = conditionText(context, automation) + val text = context.getString(R.string.automation_paused_text, condText) + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle(context.getString(R.string.automation_paused_title)) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(openApp) + .addAction( + 0, + context.getString(R.string.automation_paused_reenable), + reenablePendingIntent, + ).setAutoCancel(true) + .build() + manager.notify(automation.id.hashCode(), notification) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e08d93c08..b3b99b88a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1442,6 +1442,42 @@ Screen On Battery level %1$d%% – %2$d%% + Battery level + %1$d%% + Now: %1$s + Above + At least + Below + At most + Equals + Not equal + Between + Outside + Rises above + Falls below + Changes by + Increases by + Decreases by + %1$s above %2$s + %1$s at least %2$s + %1$s below %2$s + %1$s at most %2$s + %1$s equals %2$s + %1$s not equal to %2$s + %1$s between %2$s and %3$s + %1$s outside %2$s and %3$s + %1$s rises above %2$s + %1$s falls below %2$s + %1$s changes by %2$s + %1$s increases by %2$s + %1$s decreases by %2$s + Advanced + Stay true for + Off + %1$d s + Wait before firing again + Margin before resetting + Paused: kept triggering itself Power Saving Vibrate @@ -1615,6 +1651,9 @@ %1$s: %2$s Missing permission: %1$s Could not change: %1$s + Automation paused + %1$s kept triggering itself, so it was turned off. + Re-enable App Updates Notifications for new app updates Update Available! diff --git a/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt new file mode 100644 index 000000000..d8e7ff780 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt @@ -0,0 +1,846 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Tests + * File: SystemMetricTrackerTest.kt + * Description: Unit tests for SystemMetricTracker, Comparison, and state lifecycle. + */ + +package com.sameerasw.essentials.domain + +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.Comparison +import com.sameerasw.essentials.domain.diy.Metric +import com.sameerasw.essentials.domain.diy.SystemMetricTracker +import com.sameerasw.essentials.domain.diy.Trigger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import com.sameerasw.essentials.domain.diy.State as DIYState + +class SystemMetricTrackerTest { + private class FakeClock( + var timeMs: Long = 1000L, + ) { + fun advance(ms: Long) { + timeMs += ms + } + } + + private class FakeScheduler( + private val clock: FakeClock, + ) : SystemMetricTracker.HoldScheduler { + data class Task( + val triggerTimeMs: Long, + val block: () -> Unit, + var cancelled: Boolean = false, + ) + + val tasks = mutableListOf() + + override fun schedule( + delayMs: Long, + block: () -> Unit, + ): () -> Unit { + val task = Task(clock.timeMs + delayMs, block) + tasks.add(task) + return { task.cancelled = true } + } + + fun advanceBy(delayMs: Long) { + val targetTime = clock.timeMs + delayMs + clock.timeMs = targetTime + val ready = tasks.filter { !it.cancelled && it.triggerTimeMs <= targetTime }.sortedBy { it.triggerTimeMs } + for (task in ready) { + if (!task.cancelled) { + task.cancelled = true + task.block() + } + } + } + } + + private class FakeOpenStateStore( + initial: Set = emptySet(), + ) : SystemMetricTracker.OpenStateStore { + var ids: Set = initial + + override fun load(): Set = ids + + override fun save(ids: Set) { + this.ids = ids + } + } + + private class FakeListener : SystemMetricTracker.Listener { + val fires = mutableListOf() + val enters = mutableListOf() + val exits = mutableListOf() + val loopBreaks = mutableListOf() + + override fun onFire(automation: Automation) { + fires.add(automation) + } + + override fun onEnter(automation: Automation) { + enters.add(automation) + } + + override fun onExit(automation: Automation) { + exits.add(automation) + } + + override fun onLoopBreak(automation: Automation) { + loopBreaks.add(automation) + } + + fun clear() { + fires.clear() + enters.clear() + exits.clear() + loopBreaks.clear() + } + } + + @Test + fun comparisonIsActiveForEveryOperator() { + // GT and RISES_ABOVE + val gt = Comparison(operator = Comparison.Operator.GT, value = 50.0, hysteresis = 2.0) + assertFalse(gt.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(gt.isActive(50.1, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(gt.isActive(48.1, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(gt.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL)) + + val risesAbove = Comparison(operator = Comparison.Operator.RISES_ABOVE, value = 50.0, hysteresis = 2.0) + assertFalse(risesAbove.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(risesAbove.isActive(50.1, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(risesAbove.isActive(48.1, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(risesAbove.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL)) + + // GTE + val gte = Comparison(operator = Comparison.Operator.GTE, value = 50.0, hysteresis = 2.0) + assertFalse(gte.isActive(49.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(gte.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(gte.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(gte.isActive(47.9, wasActive = true, Metric.BATTERY_LEVEL)) + + // LT and FALLS_BELOW + val lt = Comparison(operator = Comparison.Operator.LT, value = 20.0, hysteresis = 2.0) + assertFalse(lt.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(lt.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(lt.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(lt.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL)) + + val fallsBelow = Comparison(operator = Comparison.Operator.FALLS_BELOW, value = 20.0, hysteresis = 2.0) + assertFalse(fallsBelow.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(fallsBelow.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(fallsBelow.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(fallsBelow.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL)) + + // LTE + val lte = Comparison(operator = Comparison.Operator.LTE, value = 20.0, hysteresis = 2.0) + assertFalse(lte.isActive(20.1, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(lte.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(lte.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(lte.isActive(22.1, wasActive = true, Metric.BATTERY_LEVEL)) + + // EQ (step = 1.0, e = 0.5) + val eq = Comparison(operator = Comparison.Operator.EQ, value = 50.0, hysteresis = 2.0) + assertFalse(eq.isActive(49.4, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(eq.isActive(49.5, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(eq.isActive(50.5, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(eq.isActive(50.6, wasActive = false, Metric.BATTERY_LEVEL)) + // while active e + h = 2.5 + assertTrue(eq.isActive(47.5, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(eq.isActive(47.4, wasActive = true, Metric.BATTERY_LEVEL)) + assertTrue(eq.isActive(52.5, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(eq.isActive(52.6, wasActive = true, Metric.BATTERY_LEVEL)) + + // NEQ (no hysteresis) + val neq = Comparison(operator = Comparison.Operator.NEQ, value = 50.0, hysteresis = 2.0) + assertFalse(neq.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(neq.isActive(50.6, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(neq.isActive(50.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertTrue(neq.isActive(50.6, wasActive = true, Metric.BATTERY_LEVEL)) + + // BETWEEN + val between = Comparison(operator = Comparison.Operator.BETWEEN, value = 20.0, upper = 80.0, hysteresis = 2.0) + assertFalse(between.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(between.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(between.isActive(80.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(between.isActive(80.1, wasActive = false, Metric.BATTERY_LEVEL)) + // while active: a - h .. b + h -> 18.0 .. 82.0 + assertTrue(between.isActive(18.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(between.isActive(17.9, wasActive = true, Metric.BATTERY_LEVEL)) + assertTrue(between.isActive(82.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(between.isActive(82.1, wasActive = true, Metric.BATTERY_LEVEL)) + + // OUTSIDE: b - a > 2h + val outsideWide = Comparison(operator = Comparison.Operator.OUTSIDE, value = 20.0, upper = 80.0, hysteresis = 2.0) + assertTrue(outsideWide.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(outsideWide.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(outsideWide.isActive(80.0, wasActive = false, Metric.BATTERY_LEVEL)) + assertTrue(outsideWide.isActive(80.1, wasActive = false, Metric.BATTERY_LEVEL)) + // while active: v < a + h or v > b - h -> v < 22 or v > 78 + assertTrue(outsideWide.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(outsideWide.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(outsideWide.isActive(78.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertTrue(outsideWide.isActive(78.1, wasActive = true, Metric.BATTERY_LEVEL)) + + // OUTSIDE: b - a <= 2h uses h = 0 + val outsideNarrow = Comparison(operator = Comparison.Operator.OUTSIDE, value = 50.0, upper = 53.0, hysteresis = 2.0) + assertTrue(outsideNarrow.isActive(49.9, wasActive = false, Metric.BATTERY_LEVEL)) + assertFalse(outsideNarrow.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL)) + // while active with h = 0 + assertTrue(outsideNarrow.isActive(49.9, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(outsideNarrow.isActive(50.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertFalse(outsideNarrow.isActive(53.0, wasActive = true, Metric.BATTERY_LEVEL)) + assertTrue(outsideNarrow.isActive(53.1, wasActive = true, Metric.BATTERY_LEVEL)) + } + + @Test + fun deltaOperatorsTrackingAndReset() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + // CHANGES_BY + val changesByAuto = + Automation( + id = "c1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 5.0), + cooldownSeconds = 5, + ), + ) + tracker.updateAutomations(listOf(changesByAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline + assertEquals(0, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 53.0) + assertEquals(0, listener.fires.size) + + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 55.0) // +5 from ref 50 + assertEquals(1, listener.fires.size) + + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // -5 from ref 55 + assertEquals(2, listener.fires.size) + + // INCREASES_BY: tracks minimum while not fired + val incByAuto = + Automation( + id = "inc1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.INCREASES_BY, value = 10.0), + cooldownSeconds = 5, + ), + ) + tracker.updateAutomations(listOf(incByAuto)) + listener.clear() + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline + assertEquals(0, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 40.0) // drops to 40, ref becomes 40 + assertEquals(0, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 48.0) // +8 from min 40 + assertEquals(0, listener.fires.size) + + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // +10 from min 40, fires! + assertEquals(1, listener.fires.size) + + // DECREASES_BY: tracks maximum while not fired + val decByAuto = + Automation( + id = "dec1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.DECREASES_BY, value = 10.0), + cooldownSeconds = 5, + ), + ) + tracker.updateAutomations(listOf(decByAuto)) + listener.clear() + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline + assertEquals(0, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 60.0) // rises to 60, ref becomes 60 + assertEquals(0, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 52.0) // -8 from max 60 + assertEquals(0, listener.fires.size) + + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // -10 from max 60, fires! + assertEquals(1, listener.fires.size) + } + + @Test + fun deDupeSameValueTwiceEvaluatesOnce() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val triggerAuto = + Automation( + id = "t1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 5.0), + cooldownSeconds = 0, + ), + ) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) + tracker.onReading(Metric.BATTERY_LEVEL, 55.0) + assertEquals(1, listener.fires.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 55.0) // duplicate reading + assertEquals(1, listener.fires.size) + } + + @Test + fun baselineBehaviorBothOrders() { + val triggerAuto = + Automation( + id = "t1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0), + ), + ) + val stateAuto = + Automation( + id = "s1", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0), + ), + ) + + // Order 1: automations first, then reading + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(listOf(triggerAuto, stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) + + assertEquals(0, listener.fires.size) // trigger never fires on baseline + assertEquals(1, listener.enters.size) // state enters on baseline + } + + // Order 2: reading first, then automations + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) + tracker.updateAutomations(listOf(triggerAuto, stateAuto)) + + assertEquals(0, listener.fires.size) // trigger never fires on baseline + assertEquals(1, listener.enters.size) // state enters on baseline + assertEquals(setOf("s1"), store.ids) + } + } + + @Test + fun holdDelaysExecutionAndCanBeCancelled() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val stateAuto = + Automation( + id = "s1", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0, holdSeconds = 5), + ), + ) + tracker.updateAutomations(listOf(stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline inactive + assertEquals(0, listener.enters.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // active, schedules hold 5s + assertEquals(0, listener.enters.size) + + scheduler.advanceBy(4_000) + assertEquals(0, listener.enters.size) + + scheduler.advanceBy(1_000) + assertEquals(1, listener.enters.size) // 5s reached, enters + + // Exit is immediate + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) + assertEquals(1, listener.exits.size) + + // False before hold cancels hold + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // becomes active again + scheduler.advanceBy(2_000) + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) // goes false before 5s + scheduler.advanceBy(4_000) + assertEquals(1, listener.enters.size) // still 1 enter, hold was cancelled + } + + @Test + fun cooldownSkipsIntermediateEdges() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val triggerAuto = + Automation( + id = "t1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0, hysteresis = 2.0), + cooldownSeconds = 10, + ), + ) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) // baseline inactive + + tracker.onReading(Metric.BATTERY_LEVEL, 20.0) // fires (edge 25 -> 20) + assertEquals(1, listener.fires.size) + + // Re-arm: goes to 23 (inactive with hysteresis), then back to 20 inside cooldown (5s < 10s) + clock.advance(5_000) + tracker.onReading(Metric.BATTERY_LEVEL, 23.0) + tracker.onReading(Metric.BATTERY_LEVEL, 20.0) + assertEquals(1, listener.fires.size) // skipped due to cooldown + + // Re-arm again after cooldown expires (15s > 10s) + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 23.0) + tracker.onReading(Metric.BATTERY_LEVEL, 20.0) + assertEquals(2, listener.fires.size) // allowed after cooldown + } + + @Test + fun loopBreakerTripsOnSixthFireInWindow() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val triggerAuto = + Automation( + id = "loop1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 1.0), + cooldownSeconds = 5, + ), + ) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline + + // 5 fires in 60 s (each at +6s) + for (i in 1..5) { + clock.advance(6_000) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0 + i) + } + assertEquals(5, listener.fires.size) + assertEquals(0, listener.loopBreaks.size) + + // 6th fire at t = 36s (within 60s window) -> trips! + clock.advance(6_000) + tracker.onReading(Metric.BATTERY_LEVEL, 57.0) + assertEquals(5, listener.fires.size) // not run + assertEquals(1, listener.loopBreaks.size) // onLoopBreak called once + + // Tripped record ignores subsequent readings + clock.advance(6_000) + tracker.onReading(Metric.BATTERY_LEVEL, 60.0) + assertEquals(5, listener.fires.size) + assertEquals(1, listener.loopBreaks.size) + + // Re-enable (removed and added again): counter starts at zero + tracker.updateAutomations(emptyList()) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 65.0) + assertEquals(6, listener.fires.size) // runs again! + } + + @Test + fun loopBreakerDropsTimestampsOlderThanWindow() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val triggerAuto = + Automation( + id = "loop1", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 1.0), + cooldownSeconds = 5, + ), + ) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) + + // 5 fires spaced out: 0s, 10s, 20s, 30s, 40s + for (i in 1..5) { + clock.advance(10_000) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0 + i) + } + assertEquals(5, listener.fires.size) + + // Advance to 75s (the first fire at 10s is older than 60s from 75s) + clock.advance(25_000) + tracker.onReading(Metric.BATTERY_LEVEL, 60.0) + assertEquals(6, listener.fires.size) + assertEquals(0, listener.loopBreaks.size) + } + + @Test + fun closeGuaranteeTransitions() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val action1 = Action.ToggleFlashlight + val action2 = Action.SendNotification(notificationTitle = "Exit", body = "Body") + val stateAuto = + Automation( + id = "s1", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0), + ), + exitActions = listOf(action1), + ) + + // 1. Enter state + tracker.updateAutomations(listOf(stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) + assertEquals(1, listener.enters.size) + assertEquals(setOf("s1"), store.ids) + + // 2. Value exit + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) + assertEquals(1, listener.exits.size) + assertEquals(emptySet(), store.ids) + + // 3. Enter again, then remove while open -> exit with old actions + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) + assertEquals(2, listener.enters.size) + assertEquals(setOf("s1"), store.ids) + + tracker.updateAutomations(emptyList()) // removed/disabled + assertEquals(2, listener.exits.size) + assertEquals(listOf(action1), listener.exits.last().exitActionList) + assertEquals(emptySet(), store.ids) + + // 4. Condition edited -> exit then re-baseline + tracker.updateAutomations(listOf(stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // open again + assertEquals(3, listener.enters.size) + + val editedCondition = + stateAuto.copy( + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.GTE, value = 80.0), + ), + ) + tracker.updateAutomations(listOf(editedCondition)) + assertEquals(3, listener.exits.size) // exited old condition + assertEquals(emptySet(), store.ids) // 15 is not >= 80, so new condition is not entered + + // 5. Actions-only edit -> no exit, later exit uses new actions + val reentered = stateAuto.copy(exitActions = listOf(action1)) + tracker.updateAutomations(listOf(reentered)) // 15 <= 20, enters + assertEquals(4, listener.enters.size) + val initialExitCount = listener.exits.size + + val editedActionsOnly = reentered.copy(exitActions = listOf(action2)) + tracker.updateAutomations(listOf(editedActionsOnly)) + assertEquals(initialExitCount, listener.exits.size) // no exit on actions edit + + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) + assertEquals(initialExitCount + 1, listener.exits.size) + assertEquals(listOf(action2), listener.exits.last().exitActionList) + + // 6. closeAll exits all and saves empty set + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) + assertEquals(5, listener.enters.size) + assertEquals(setOf("s1"), store.ids) + + tracker.closeAll() + assertEquals(initialExitCount + 2, listener.exits.size) + assertEquals(emptySet(), store.ids) + + // 7. Exit never called for a never-entered state + val neverEntered = + Automation( + id = "s_never", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.GTE, value = 90.0), + ), + ) + tracker.updateAutomations(listOf(neverEntered)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // not >= 90 + val exitCountBeforeRemove = listener.exits.size + tracker.updateAutomations(emptyList()) // removed + assertEquals(exitCountBeforeRemove, listener.exits.size) // no exit called + + // 8. Same list twice -> no callbacks + tracker.updateAutomations(listOf(stateAuto)) + val currentEnters = listener.enters.size + val currentExits = listener.exits.size + tracker.updateAutomations(listOf(stateAuto)) + assertEquals(currentEnters, listener.enters.size) + assertEquals(currentExits, listener.exits.size) + } + + @Test + fun restoredOpenStateHandling() { + val stateAuto = + Automation( + id = "s_restored", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0), + ), + ) + + // Case A: restored open + still true -> no enter callback, marked open + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore(setOf("s_restored")) + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(listOf(stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // still true + + assertEquals(0, listener.enters.size) // no enter! + assertEquals(0, listener.exits.size) + assertEquals(setOf("s_restored"), store.ids) + } + + // Case B: restored open + now false -> exit callback + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore(setOf("s_restored")) + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(listOf(stateAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // false! + + assertEquals(0, listener.enters.size) + assertEquals(1, listener.exits.size) // exit called! + assertEquals(emptySet(), store.ids) + } + + // Case C: restored but missing from first update -> dropped, no callbacks + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore(setOf("s_restored")) + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(emptyList()) // missing from list + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) + + assertEquals(0, listener.enters.size) + assertEquals(0, listener.exits.size) + assertEquals(emptySet(), store.ids) + } + + // Restored open unbaselined, removed from updateAutomations -> exit callback + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore(setOf("s_restored")) + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(listOf(stateAuto)) + tracker.updateAutomations(emptyList()) + + assertEquals(0, listener.enters.size) + assertEquals(1, listener.exits.size) + assertEquals(emptySet(), store.ids) + } + + // Restored open unbaselined, closeAll -> exit callback + run { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore(setOf("s_restored")) + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + tracker.updateAutomations(listOf(stateAuto)) + tracker.closeAll() + + assertEquals(0, listener.enters.size) + assertEquals(1, listener.exits.size) + assertEquals(emptySet(), store.ids) + } + } + + @Test + fun firstFireAllowedWhenClockStartsAtZero() { + val clock = FakeClock(timeMs = 0L) + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val triggerAuto = + Automation( + id = "t_zero", + type = Automation.Type.TRIGGER, + trigger = + Trigger.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0), + cooldownSeconds = 60, + ), + ) + tracker.updateAutomations(listOf(triggerAuto)) + tracker.onReading(Metric.BATTERY_LEVEL, 25.0) + tracker.onReading(Metric.BATTERY_LEVEL, 20.0) + assertEquals(1, listener.fires.size) + } + + @Test + fun stateBatteryLevelMappedThroughTracker() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val batteryLevelState = + Automation( + id = "bl1", + type = Automation.Type.STATE, + state = DIYState.BatteryLevel(minLevel = 20, maxLevel = 80), + ) + + tracker.updateAutomations(listOf(batteryLevelState)) + tracker.onReading(Metric.BATTERY_LEVEL, 10.0) // out of range + assertEquals(0, listener.enters.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // in range: enters! + assertEquals(1, listener.enters.size) + assertEquals(setOf("bl1"), store.ids) + + tracker.updateAutomations(emptyList()) // removed: exits! + assertEquals(1, listener.exits.size) + assertEquals(emptySet(), store.ids) + } + + @Test + fun legacyBatteryLevelStateTransitionToSystemMetricKeepsStateOpen() { + val clock = FakeClock() + val scheduler = FakeScheduler(clock) + val store = FakeOpenStateStore() + val listener = FakeListener() + val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener) + + val legacy = + Automation( + id = "bl_convert", + type = Automation.Type.STATE, + state = DIYState.BatteryLevel(minLevel = 80, maxLevel = 100), + ) + + tracker.updateAutomations(listOf(legacy)) + tracker.onReading(Metric.BATTERY_LEVEL, 85.0) + assertEquals(1, listener.enters.size) + assertEquals(0, listener.exits.size) + assertEquals(setOf("bl_convert"), store.ids) + + val converted = + Automation( + id = "bl_convert", + type = Automation.Type.STATE, + state = + DIYState.SystemMetric( + metric = Metric.BATTERY_LEVEL, + comparison = + Comparison( + operator = Comparison.Operator.BETWEEN, + value = 80.0, + upper = 100.0, + hysteresis = 0.0, + ), + ), + ) + + tracker.updateAutomations(listOf(converted)) + assertEquals(1, listener.enters.size) + assertEquals(0, listener.exits.size) + + tracker.onReading(Metric.BATTERY_LEVEL, 70.0) + assertEquals(1, listener.enters.size) + assertEquals(1, listener.exits.size) + assertEquals(emptySet(), store.ids) + } +}