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
18 changes: 7 additions & 11 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,29 +102,25 @@ sealed interface Action {
}

@Keep
enum class SoundModeType {
enum class SoundModeType(
@param:DrawableRes val icon: Int,
) {
@SerializedName("SOUND")
SOUND,
SOUND(R.drawable.rounded_volume_up_24),

@SerializedName("VIBRATE")
VIBRATE,
VIBRATE(R.drawable.rounded_mobile_vibrate_24),

@SerializedName("SILENT")
SILENT,
SILENT(R.drawable.rounded_volume_off_24),
}

@Keep
data class SoundMode(
@SerializedName("mode") val mode: SoundModeType = SoundModeType.SOUND,
) : Action {
override val title: Int get() = R.string.diy_action_sound_mode
override val icon: Int
get() =
when (mode) {
SoundModeType.SOUND -> R.drawable.rounded_volume_up_24
SoundModeType.VIBRATE -> R.drawable.rounded_mobile_vibrate_24
SoundModeType.SILENT -> R.drawable.rounded_volume_off_24
}
override val icon: Int get() = mode.icon
override val permissions: List<String> = listOf("NOTIFICATION_POLICY")
override val isConfigurable: Boolean = true
}
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,18 @@ 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 object Dnd : State {
override val title: Int = R.string.diy_state_dnd
override val icon: Int = R.drawable.rounded_do_not_disturb_on_24
}

@Keep
data class SoundMode(
@SerializedName("mode") val mode: Action.SoundModeType = Action.SoundModeType.SILENT,
) : State {
override val title: Int get() = R.string.diy_state_sound_mode
override val icon: Int get() = mode.icon
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ sealed interface Trigger {
override val icon: Int = R.drawable.rounded_do_not_disturb_on_24
}

@Keep
data class SoundModeChanged(
@SerializedName("mode") val mode: Action.SoundModeType = Action.SoundModeType.SILENT,
) : Trigger {
override val title: Int get() = R.string.diy_trigger_sound_mode
override val icon: Int get() = mode.icon
override val isConfigurable: Boolean get() = true
}

@Keep
data object AndroidAutoConnected : Trigger {
override val title: Int = R.string.diy_trigger_android_auto_connected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.sameerasw.essentials.services.automation.modules.LocationModule
import com.sameerasw.essentials.services.automation.modules.PowerModule
import com.sameerasw.essentials.services.automation.modules.TimeModule
import com.sameerasw.essentials.services.automation.modules.CalendarModule
import com.sameerasw.essentials.services.automation.modules.SoundModule
import com.sameerasw.essentials.services.automation.modules.WifiModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -116,6 +117,7 @@ object AutomationManager {
val wifiAutomations = mutableListOf<Automation>()
val calendarAutomations = mutableListOf<Automation>()
val notificationAutomations = mutableListOf<Automation>()
val soundAutomations = mutableListOf<Automation>()
val locationAutomations = mutableListOf<Automation>()
val androidAutoAutomations = mutableListOf<Automation>()

Expand Down Expand Up @@ -153,6 +155,11 @@ object AutomationManager {
notificationAutomations.add(automation)
}

is Trigger.SoundModeChanged -> {
requiredModuleIds.add(SoundModule.ID)
soundAutomations.add(automation)
}

is Trigger.ArriveAtLocation, is Trigger.LeaveLocation -> {
requiredModuleIds.add(LocationModule.ID)
locationAutomations.add(automation)
Expand Down Expand Up @@ -189,6 +196,16 @@ object AutomationManager {
calendarAutomations.add(automation)
}

is DIYState.Dnd -> {
requiredModuleIds.add(NotificationModule.ID)
notificationAutomations.add(automation)
}

is DIYState.SoundMode -> {
requiredModuleIds.add(SoundModule.ID)
soundAutomations.add(automation)
}

else -> {}
}
}
Expand Down Expand Up @@ -296,6 +313,17 @@ object AutomationManager {
activeModules.remove(NotificationModule.ID)?.stop(context)
}

// Sound Module
if (requiredModuleIds.contains(SoundModule.ID)) {
val module =
activeModules.getOrPut(SoundModule.ID) {
SoundModule().also { it.start(context) }
}
module.updateAutomations(soundAutomations)
} else {
activeModules.remove(SoundModule.ID)?.stop(context)
}

if (requiredModuleIds.contains(LocationModule.ID)) {
val module =
activeModules.getOrPut(LocationModule.ID) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.Automation
import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.domain.model.ActiveNotificationAlert
import com.sameerasw.essentials.services.NotificationListener
import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import com.sameerasw.essentials.domain.diy.State as DIYState

class NotificationModule : AutomationModule {
companion object {
Expand All @@ -27,6 +30,7 @@ class NotificationModule : AutomationModule {
private val scope = CoroutineScope(Dispatchers.IO)
private var appContext: Context? = null
private var dndOn = false
private val stateEntries = StateEntries()

private val alertListener =
object : NotificationListener.NotificationAlertListener {
Expand All @@ -52,6 +56,7 @@ class NotificationModule : AutomationModule {
dndOn = on
val fired = if (on) Trigger.DndOn else Trigger.DndOff
run(context) { it == fired }
syncStates(context)
}
}

Expand All @@ -63,6 +68,11 @@ class NotificationModule : AutomationModule {
}

override fun stop(context: Context) {
stateEntries.closeAll().forEach { actions ->
scope.launch(NonCancellable) {
CombinedActionExecutor.executeAll(context, actions)
}
}
NotificationListener.removeNotificationAlertListener(alertListener)
try {
context.unregisterReceiver(dndReceiver)
Expand All @@ -72,6 +82,20 @@ class NotificationModule : AutomationModule {

override fun updateAutomations(automations: List<Automation>) {
this.automations = automations
appContext?.let { syncStates(it) }
}

private fun syncStates(context: Context) = launchAll(context, stateEntries.sync(automations) { it.state is DIYState.Dnd && dndOn })

private fun launchAll(
context: Context,
actionLists: List<List<Action>>,
) {
actionLists.forEach { actions ->
scope.launch {
CombinedActionExecutor.executeAll(context, actions)
}
}
}

private fun run(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: Background Services & Receivers
* File: SoundModule.kt
* Description: Background service component for SoundModule.kt.
*/

package com.sameerasw.essentials.services.automation.modules

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.Automation
import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import com.sameerasw.essentials.domain.diy.State as DIYState

class SoundModule : AutomationModule {
companion object {
const val ID = "sound_module"

fun soundModeOf(ringerMode: Int): Action.SoundModeType? =
when (ringerMode) {
AudioManager.RINGER_MODE_NORMAL -> Action.SoundModeType.SOUND
AudioManager.RINGER_MODE_VIBRATE -> Action.SoundModeType.VIBRATE
AudioManager.RINGER_MODE_SILENT -> Action.SoundModeType.SILENT
else -> null
}
}

override val id: String = ID

@Volatile
private var automations: List<Automation> = emptyList()
private val scope = CoroutineScope(Dispatchers.IO)
private var appContext: Context? = null
private var currentMode: Action.SoundModeType? = null
private val stateEntries = StateEntries()

private val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action != AudioManager.RINGER_MODE_CHANGED_ACTION) return
val audioManager = context.getSystemService(AudioManager::class.java) ?: return
val mode = soundModeOf(audioManager.ringerMode) ?: return
if (mode == currentMode) return
currentMode = mode
runTriggers(context, mode)
syncStates(context)
}
}

override fun start(context: Context) {
appContext = context.applicationContext
val audioManager = context.getSystemService(AudioManager::class.java)
currentMode = audioManager?.let { soundModeOf(it.ringerMode) }
context.registerReceiver(receiver, IntentFilter(AudioManager.RINGER_MODE_CHANGED_ACTION))
}

override fun stop(context: Context) {
stateEntries.closeAll().forEach { actions ->
scope.launch(NonCancellable) {
CombinedActionExecutor.executeAll(context, actions)
}
}
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
}
scope.coroutineContext.cancelChildren()
}

override fun updateAutomations(automations: List<Automation>) {
this.automations = automations
appContext?.let { syncStates(it) }
}

private fun runTriggers(
context: Context,
mode: Action.SoundModeType,
) {
scope.launch {
automations
.filter {
it.type == Automation.Type.TRIGGER &&
it.isEnabled &&
it.trigger is Trigger.SoundModeChanged &&
(it.trigger as Trigger.SoundModeChanged).mode == mode
}.forEach { automation ->
CombinedActionExecutor.executeAll(context, automation.actionList)
}
}
}

private fun syncStates(context: Context) =
launchAll(context, stateEntries.sync(automations) { (it.state as? DIYState.SoundMode)?.mode == currentMode })

private fun launchAll(
context: Context,
actionLists: List<List<Action>>,
) {
actionLists.forEach { actions ->
scope.launch {
CombinedActionExecutor.executeAll(context, actions)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: Background Services & Receivers
* File: StateEntries.kt
* Description: Background service component for StateEntries.kt.
*/

package com.sameerasw.essentials.services.automation.modules

import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.Automation

// Tracks which state automations have run their entry actions, so every entry gets exactly one exit
class StateEntries {
// The automation as it was when it entered, so its own exit actions run even after an edit or delete
private val entered = mutableMapOf<String, Automation>()

// Returns the action lists to run, in order: exits for automations that are gone or no longer active, then entries
@Synchronized
fun sync(
automations: List<Automation>,
isActive: (Automation) -> Boolean,
): List<List<Action>> {
val exits = mutableListOf<List<Action>>()
val entries = mutableListOf<List<Action>>()

val owned = automations.filter { it.type == Automation.Type.STATE && it.isEnabled }
val ownedMap = owned.associateBy { it.id }

val removedIds = entered.keys.filter { it !in ownedMap }
for (id in removedIds) {
val old = entered.remove(id)
if (old != null && old.exitActionList.isNotEmpty()) {
exits.add(old.exitActionList)
}
}

for (auto in owned) {
val active = isActive(auto)
val isEntered = entered.containsKey(auto.id)
if (active && !isEntered) {
entered[auto.id] = auto
if (auto.entryActionList.isNotEmpty()) {
entries.add(auto.entryActionList)
}
} else if (!active && isEntered) {
val old = entered.remove(auto.id)
if (old != null && old.exitActionList.isNotEmpty()) {
exits.add(old.exitActionList)
}
} else if (active && isEntered) {
entered[auto.id] = auto
}
}

return exits + entries
}

// Exits for everything entered, then forgets them
@Synchronized
fun closeAll(): List<List<Action>> {
val exits =
entered.values
.map { it.exitActionList }
.filter { it.isNotEmpty() }
entered.clear()
return exits
}
}
Loading
Loading