Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,10 @@
</intent-filter>
</receiver>

<receiver
android:name=".services.receivers.LoopBreakerReceiver"
android:exported="false" />

<receiver
android:name=".services.receivers.AppBootReceiver"
android:exported="true">
Expand Down
109 changes: 109 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -124,13 +126,26 @@ object DIYRepository {
}

fun removeAutomation(id: String) {
DIYRuntimeStore.setPaused(id, false)
val current = _automations.value.toMutableList()
current.removeAll { it.id == id }
_automations.value = current
saveToPrefs()
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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Set<String>>(emptySet())
val pausedIds: StateFlow<Set<String>> = _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<String> =
prefs?.getStringSet(KEY_OPEN_STATES_PREFIX + moduleId, emptySet())?.toSet() ?: emptySet()

fun setOpenStateIds(
moduleId: String,
ids: Set<String>,
) {
prefs?.edit()?.putStringSet(KEY_OPEN_STATES_PREFIX + moduleId, ids.toSet())?.apply()
}
}
34 changes: 34 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt
Original file line number Diff line number Diff line change
@@ -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,
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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 -> ""
}
Loading
Loading