diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index fc5d67bcd..0f478e9d9 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -102,15 +102,17 @@ 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 @@ -118,13 +120,7 @@ sealed interface Action { @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 = listOf("NOTIFICATION_POLICY") override val isConfigurable: Boolean = true } 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..af7b1319f 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,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 + } } 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..a3dd0e675 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 @@ -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 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..ebd9181db 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 @@ -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 @@ -116,6 +117,7 @@ object AutomationManager { val wifiAutomations = mutableListOf() val calendarAutomations = mutableListOf() val notificationAutomations = mutableListOf() + val soundAutomations = mutableListOf() val locationAutomations = mutableListOf() val androidAutoAutomations = mutableListOf() @@ -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) @@ -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 -> {} } } @@ -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) { diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/NotificationModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/NotificationModule.kt index b3d6bc684..dbce21871 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/NotificationModule.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/NotificationModule.kt @@ -5,6 +5,7 @@ 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 @@ -12,8 +13,10 @@ 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 { @@ -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 { @@ -52,6 +56,7 @@ class NotificationModule : AutomationModule { dndOn = on val fired = if (on) Trigger.DndOn else Trigger.DndOff run(context) { it == fired } + syncStates(context) } } @@ -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) @@ -72,6 +82,20 @@ class NotificationModule : AutomationModule { override fun updateAutomations(automations: List) { 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>, + ) { + actionLists.forEach { actions -> + scope.launch { + CombinedActionExecutor.executeAll(context, actions) + } + } } private fun run( diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/SoundModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/SoundModule.kt new file mode 100644 index 000000000..f40fd5be6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/SoundModule.kt @@ -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 = 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) { + 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>, + ) { + actionLists.forEach { actions -> + scope.launch { + CombinedActionExecutor.executeAll(context, actions) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/StateEntries.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/StateEntries.kt new file mode 100644 index 000000000..757ca1ef1 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/StateEntries.kt @@ -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() + + // 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, + isActive: (Automation) -> Boolean, + ): List> { + val exits = mutableListOf>() + val entries = mutableListOf>() + + 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> { + val exits = + entered.values + .map { it.exitActionList } + .filter { it.isNotEmpty() } + entered.clear() + return exits + } +} 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..a68b70876 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 @@ -99,6 +99,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.SoundModeSettingsSheet 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 @@ -359,6 +360,7 @@ class AutomationEditorActivity : ComponentActivity() { var showTimeSettings by remember { mutableStateOf(false) } var showCalendarStateSettings by remember { mutableStateOf(false) } var showBatteryLevelSettings by remember { mutableStateOf(false) } + var showSoundModeSettings by remember { mutableStateOf(false) } var showBluetoothSettings by remember { mutableStateOf(false) } var showWifiSettings by remember { mutableStateOf(false) } var showNotificationTriggerSettings by remember { mutableStateOf(false) } @@ -1048,6 +1050,11 @@ class AutomationEditorActivity : ComponentActivity() { Trigger.DndOn, Trigger.DndOff, ), + R.string.diy_category_sound to + listOf( + (selectedTrigger as? Trigger.SoundModeChanged) + ?: Trigger.SoundModeChanged(), + ), R.string.diy_category_location to listOf( (selectedTrigger as? Trigger.ArriveAtLocation) ?: Trigger.ArriveAtLocation(), @@ -1119,6 +1126,10 @@ class AutomationEditorActivity : ComponentActivity() { true is Trigger.NotificationReceived -> showNotificationTriggerSettings = true is Trigger.ArriveAtLocation, is Trigger.LeaveLocation -> editingLocationTrigger = trigger + is Trigger.SoundModeChanged -> { + selectedTrigger = trigger + showSoundModeSettings = true + } else -> {} } }, @@ -1136,6 +1147,11 @@ class AutomationEditorActivity : ComponentActivity() { DIYState.PowerSaving, (selectedState as? DIYState.BatteryLevel) ?: DIYState.BatteryLevel(), ), + R.string.diy_category_sound to + listOf( + DIYState.Dnd, + (selectedState as? DIYState.SoundMode) ?: DIYState.SoundMode(), + ), R.string.diy_category_system_screen to listOf( DIYState.ScreenOn, @@ -1196,7 +1212,10 @@ class AutomationEditorActivity : ComponentActivity() { 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.BatteryLevel || + state is DIYState.SoundMode, onSettingsClick = { if (state is DIYState.TimePeriod) { showTimeSettings = true @@ -1209,6 +1228,10 @@ class AutomationEditorActivity : ComponentActivity() { selectedState = state showCalendarStateSettings = true } + if (state is DIYState.SoundMode) { + selectedState = state + showSoundModeSettings = true + } }, ) } @@ -1327,6 +1350,31 @@ class AutomationEditorActivity : ComponentActivity() { } } + if (showSoundModeSettings) { + val isTrigger = automationType == Automation.Type.TRIGGER + val titleRes = if (isTrigger) R.string.diy_trigger_sound_mode else R.string.diy_state_sound_mode + val currentMode = + if (isTrigger) { + (selectedTrigger as? Trigger.SoundModeChanged)?.mode ?: Action.SoundModeType.SILENT + } else { + (selectedState as? DIYState.SoundMode)?.mode ?: Action.SoundModeType.SILENT + } + SoundModeSettingsSheet( + title = stringResource(titleRes), + showInfo = false, + initialAction = Action.SoundMode(currentMode), + onDismiss = { showSoundModeSettings = false }, + onSave = { + showSoundModeSettings = false + if (isTrigger) { + selectedTrigger = Trigger.SoundModeChanged(it.mode) + } else { + selectedState = DIYState.SoundMode(it.mode) + } + }, + ) + } + if (showBatteryLevelSettings) { com.sameerasw.essentials.ui.core.sheets.BatteryLevelStateSheet( initialState = selectedState as? DIYState.BatteryLevel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/SoundModeSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/SoundModeSettingsSheet.kt index 2fdcd28d6..460c916db 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/SoundModeSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/SoundModeSettingsSheet.kt @@ -54,6 +54,8 @@ import com.sameerasw.essentials.utils.HapticUtil @Composable fun SoundModeSettingsSheet( initialAction: Action.SoundMode, + title: String = stringResource(R.string.diy_action_sound_mode), + showInfo: Boolean = true, onDismiss: () -> Unit, onSave: (Action.SoundMode) -> Unit, ) { @@ -72,48 +74,50 @@ fun SoundModeSettingsSheet( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.diy_action_sound_mode), + text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) // Info Card - Card( - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - ), - shape = RoundedCornerShape(24.dp), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + if (showInfo) { + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + shape = RoundedCornerShape(24.dp), ) { - val infoTitle = "Sound Mode" - Box( - modifier = - Modifier - .size(40.dp) - .background( - color = ColorUtil.getPastelColorFor(infoTitle), - shape = CircleShape, - ), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Icon( - painter = painterResource(R.drawable.rounded_info_24), - contentDescription = null, - tint = ColorUtil.getVibrantColorFor(infoTitle), - modifier = Modifier.size(24.dp), + val infoTitle = "Sound Mode" + Box( + modifier = + Modifier + .size(40.dp) + .background( + color = ColorUtil.getPastelColorFor(infoTitle), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_info_24), + contentDescription = null, + tint = ColorUtil.getVibrantColorFor(infoTitle), + modifier = Modifier.size(24.dp), + ) + } + Text( + text = stringResource(R.string.diy_sound_mode_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = stringResource(R.string.diy_sound_mode_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } 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..393fec6dc 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 @@ -10,6 +10,7 @@ package com.sameerasw.essentials.ui.components.diy import android.text.format.DateFormat +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn @@ -450,6 +451,7 @@ private fun automationSummary(automation: Automation): String? { trigger.appName.ifBlank { null }, trigger.textContains.ifBlank { null }?.let { "\"$it\"" }, ).joinToString(" · ").ifBlank { null } + is Trigger.SoundModeChanged -> stringResource(soundModeName(trigger.mode)) else -> null } @@ -464,6 +466,7 @@ private fun automationSummary(automation: Automation): String? { } else { stringResource(R.string.diy_summary_calendars_count, state.calendarIds.size) } + is DIYState.SoundMode -> stringResource(soundModeName(state.mode)) else -> null } @@ -485,6 +488,14 @@ private fun automationSummary(automation: Automation): String? { } } +@StringRes +private fun soundModeName(mode: Action.SoundModeType): Int = + when (mode) { + Action.SoundModeType.SOUND -> R.string.sound_mode_sound + Action.SoundModeType.VIBRATE -> R.string.sound_mode_vibrate + Action.SoundModeType.SILENT -> R.string.sound_mode_silent + } + @Composable fun ActionItem( action: Action?, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f97275599..303d14d59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1366,6 +1366,7 @@ Notification Received Do Not Disturb On Do Not Disturb Off + Sound mode changes Runs when a notification from this app arrives, or one containing this text. Set at least one. App Any app @@ -1421,6 +1422,7 @@ Battery & Power Time & Schedule Notifications + Sound Location No place chosen yet Pinned place @@ -1443,6 +1445,8 @@ Battery level %1$d%% – %2$d%% Power Saving + Do Not Disturb + Sound mode Vibrate Show Notification diff --git a/app/src/test/java/com/sameerasw/essentials/services/SoundModuleTest.kt b/app/src/test/java/com/sameerasw/essentials/services/SoundModuleTest.kt new file mode 100644 index 000000000..ea2f05a99 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/SoundModuleTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - Services + * File: SoundModuleTest.kt + * Description: Unit tests for SoundModule ringer mode mapping. + */ + +package com.sameerasw.essentials.services + +import android.media.AudioManager +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.services.automation.modules.SoundModule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SoundModuleTest { + @Test + fun mapsNormalRingerModeToSound() { + assertEquals(Action.SoundModeType.SOUND, SoundModule.soundModeOf(AudioManager.RINGER_MODE_NORMAL)) + } + + @Test + fun mapsVibrateRingerModeToVibrate() { + assertEquals(Action.SoundModeType.VIBRATE, SoundModule.soundModeOf(AudioManager.RINGER_MODE_VIBRATE)) + } + + @Test + fun mapsSilentRingerModeToSilent() { + assertEquals(Action.SoundModeType.SILENT, SoundModule.soundModeOf(AudioManager.RINGER_MODE_SILENT)) + } + + @Test + fun returnsNullForUnknownRingerMode() { + assertNull(SoundModule.soundModeOf(-1)) + assertNull(SoundModule.soundModeOf(999)) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/services/StateEntriesTest.kt b/app/src/test/java/com/sameerasw/essentials/services/StateEntriesTest.kt new file mode 100644 index 000000000..245de562f --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/StateEntriesTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - Services + * File: StateEntriesTest.kt + * Description: Unit tests for StateEntries. + */ + +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.State +import com.sameerasw.essentials.services.automation.modules.StateEntries +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StateEntriesTest { + @Test + fun activeOnFirstSyncReturnsEntryListOnceThenNothing() { + val entries = StateEntries() + val automation = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + val first = entries.sync(listOf(automation)) { true } + assertEquals(listOf(listOf(Action.TurnOnWifi)), first) + + val second = entries.sync(listOf(automation)) { true } + assertTrue(second.isEmpty()) + } + + @Test + fun activeThenInactiveReturnsExitListOnce() { + val entries = StateEntries() + val automation = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + entries.sync(listOf(automation)) { true } + + val inactive = entries.sync(listOf(automation)) { false } + assertEquals(listOf(listOf(Action.TurnOffWifi)), inactive) + + val secondInactive = entries.sync(listOf(automation)) { false } + assertTrue(secondInactive.isEmpty()) + } + + @Test + fun enteredThenRemovedOrDisabledReturnsExitList() { + val entries = StateEntries() + val automation = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + entries.sync(listOf(automation)) { true } + + val removed = entries.sync(emptyList()) { true } + assertEquals(listOf(listOf(Action.TurnOffWifi)), removed) + + entries.sync(listOf(automation)) { true } + val disabledAuto = automation.copy(isEnabled = false) + val disabled = entries.sync(listOf(disabledAuto)) { true } + assertEquals(listOf(listOf(Action.TurnOffWifi)), disabled) + } + + @Test + fun enteredThenEditedWhileActiveReplacesStoredCopy() { + val entries = StateEntries() + val initial = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + entries.sync(listOf(initial)) { true } + + val edited = initial.copy(exitActions = listOf(Action.TurnOnWifi)) + val syncEdited = entries.sync(listOf(edited)) { true } + assertTrue(syncEdited.isEmpty()) + + val inactive = entries.sync(listOf(edited)) { false } + assertEquals(listOf(listOf(Action.TurnOnWifi)), inactive) + } + + @Test + fun enteredThenInactiveWithNewExitActionsUsesStoredExitList() { + val entries = StateEntries() + val initial = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + entries.sync(listOf(initial)) { true } + + val edited = initial.copy(exitActions = listOf(Action.TurnOnWifi)) + val inactive = entries.sync(listOf(edited)) { false } + assertEquals(listOf(listOf(Action.TurnOffWifi)), inactive) + } + + @Test + fun neverEnteredAndRemovedReturnsNothing() { + val entries = StateEntries() + val automation = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + val syncInactive = entries.sync(listOf(automation)) { false } + assertTrue(syncInactive.isEmpty()) + + val syncRemoved = entries.sync(emptyList()) { false } + assertTrue(syncRemoved.isEmpty()) + } + + @Test + fun closeAllReturnsExitsAndAllowsReentry() { + val entries = StateEntries() + val automation = + Automation( + id = "1", + type = Automation.Type.STATE, + state = State.Dnd, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + entries.sync(listOf(automation)) { true } + + val closed = entries.closeAll() + assertEquals(listOf(listOf(Action.TurnOffWifi)), closed) + + val reenter = entries.sync(listOf(automation)) { true } + assertEquals(listOf(listOf(Action.TurnOnWifi)), reenter) + } + + @Test + fun triggerAutomationsAreIgnored() { + val entries = StateEntries() + val triggerAutomation = + Automation( + id = "1", + type = Automation.Type.TRIGGER, + entryActions = listOf(Action.TurnOnWifi), + exitActions = listOf(Action.TurnOffWifi), + ) + val result = entries.sync(listOf(triggerAutomation)) { true } + assertTrue(result.isEmpty()) + } +}