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 9468add4a..e2acdb4ac 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 @@ -156,6 +156,18 @@ sealed interface Action { override val isConfigurable: Boolean get() = true } + @Keep + data class SendNotification( + @SerializedName("title") val notificationTitle: String = "", + @SerializedName("body") val body: String = "", + @SerializedName("label") val label: String = "", + ) : Action { + override val title: Int get() = R.string.diy_action_send_notification + override val icon: Int get() = R.drawable.rounded_notifications_unread_24 + override val permissions: List get() = listOf("POST_NOTIFICATIONS") + override val isConfigurable: Boolean get() = true + } + @Keep data object TurnOffLowPower : Action { override val title: Int = R.string.diy_action_low_power_off diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt index 236ef2bfa..17253ab27 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt @@ -82,6 +82,7 @@ object ActionRegistry { add(Action.SetChargingMode()) add(Action.CustomSettings()) add(Action.Wait()) + add(Action.SendNotification()) add(Action.CircleToSearch) add(Action.TurnOffBackGesture) add(Action.TurnOnBackGesture) diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt index fb3a91aba..ab755f7b7 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt @@ -49,6 +49,7 @@ import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.activities.PixelSearchResultsActivity import com.sameerasw.essentials.utils.AutomationFailureNotifier +import com.sameerasw.essentials.utils.AutomationNotificationPoster import com.sameerasw.essentials.utils.DeviceLockUtils import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUtils @@ -171,6 +172,7 @@ object CombinedActionExecutor { is Action.TurnOnCaffeinate -> CaffeinateController.start(context) is Action.TurnOffCaffeinate -> CaffeinateController.cancelAll(context) is Action.Wait -> delay(action.seconds * 1000L) + is Action.SendNotification -> AutomationNotificationPoster.post(context, action) is Action.TurnOffLowPower -> setLowPowerMode(context, false) is Action.SetChargingMode -> com.sameerasw.essentials.utils.battery.ChargingModeUtil diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt new file mode 100644 index 000000000..98a1d15a7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Automation + * File: SendNotificationSettingsSheet.kt + * Description: UI bottom sheet for configuring the Send Notification automation action. + */ + +package com.sameerasw.essentials.ui.core.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendNotificationSettingsSheet( + initialAction: Action.SendNotification, + onDismiss: () -> Unit, + onSave: (Action.SendNotification) -> Unit, +) { + val view = LocalView.current + var title by remember { mutableStateOf(initialAction.notificationTitle) } + var body by remember { mutableStateOf(initialAction.body) } + var label by remember { mutableStateOf(initialAction.label) } + + EssentialsBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.diy_action_send_notification), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + NotificationField( + value = title, + onValueChange = { title = it }, + label = stringResource(R.string.diy_send_notification_title), + singleLine = true, + ) + NotificationField( + value = body, + onValueChange = { body = it }, + label = stringResource(R.string.diy_send_notification_body), + minLines = 3, + maxLines = 6, + ) + NotificationField( + value = label, + onValueChange = { label = it }, + label = stringResource(R.string.diy_send_notification_label), + singleLine = true, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSave( + Action.SendNotification( + notificationTitle = title.trim(), + body = body.trim(), + label = label.trim(), + ), + ) + }, + modifier = Modifier.weight(1f), + enabled = title.isNotBlank() || body.isNotBlank(), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_check_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + } + } + } +} + +@Composable +private fun NotificationField( + value: String, + onValueChange: (String) -> Unit, + label: String, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(12.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = singleLine, + minLines = minLines, + maxLines = if (singleLine) 1 else maxLines, + shape = MaterialTheme.shapes.medium, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt index 5df69b626..fd53d7edf 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action @@ -58,6 +59,7 @@ import com.sameerasw.essentials.ui.core.sheets.ScreenOffSettingsSheet import com.sameerasw.essentials.ui.core.sheets.OpenActivityPicker import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.WaitSettingsSheet +import com.sameerasw.essentials.ui.core.sheets.SendNotificationSettingsSheet import com.sameerasw.essentials.ui.core.sheets.formatWait import com.sameerasw.essentials.ui.core.sheets.ChargingModeSettingsSheet import com.sameerasw.essentials.ui.core.sheets.NotificationLightingActionSheet @@ -110,6 +112,7 @@ fun ActionSequenceEditor( var showSetVolumeSettings by remember { mutableStateOf(false) } var showHilightSettings by remember { mutableStateOf(false) } var showWaitSettings by remember { mutableStateOf(false) } + var showSendNotificationSettings by remember { mutableStateOf(false) } var configAction by remember { mutableStateOf(null) } var configIndex by remember { mutableStateOf(null) } var showAddActionSheet by remember { mutableStateOf(false) } @@ -146,6 +149,7 @@ fun ActionSequenceEditor( "WRITE_SECURE_SETTINGS" -> !viewModel.isWriteSecureSettingsEnabled.value "DRAW_OVERLAYS" -> !viewModel.isOverlayPermissionGranted.value "ACCESSIBILITY" -> !viewModel.isAccessibilityEnabled.value + "POST_NOTIFICATIONS" -> !viewModel.isPostNotificationsEnabled.value else -> false } } @@ -222,6 +226,7 @@ fun ActionSequenceEditor( is Action.Hilight -> showHilightSettings = true is Action.CustomSettings -> showCustomSettingsSettings = true is Action.Wait -> showWaitSettings = true + is Action.SendNotification -> showSendNotificationSettings = true else -> {} } } @@ -623,6 +628,18 @@ fun ActionSequenceEditor( ) } + if (showSendNotificationSettings && configAction is Action.SendNotification) { + SendNotificationSettingsSheet( + initialAction = configAction as Action.SendNotification, + onDismiss = { showSendNotificationSettings = false }, + onSave = { newAction -> + showSendNotificationSettings = false + onActionSelected(newAction) + configAction = null + }, + ) + } + if (showPermissionSheet) { val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( @@ -655,7 +672,14 @@ private fun RemapSequenceItem( onRemove: () -> Unit, ) { val view = LocalView.current - val title = stringResource(action.title).let { if (action is Action.Wait) "$it · ${formatWait(action.seconds)}" else it } + val title = + stringResource(action.title).let { baseTitle -> + when { + action is Action.Wait -> "$baseTitle · ${formatWait(action.seconds)}" + action is Action.SendNotification && action.notificationTitle.isNotBlank() -> "$baseTitle · ${action.notificationTitle}" + else -> baseTitle + } + } Row( modifier = Modifier @@ -702,6 +726,8 @@ private fun RemapSequenceItem( Text( text = title, style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt new file mode 100644 index 000000000..1bcc7c883 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - General + * File: AutomationNotificationPoster.kt + * Description: Posts user-configured notifications for automation actions. + */ + +package com.sameerasw.essentials.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action + +object AutomationNotificationPoster { + private const val CHANNEL_ID = "automation_notifications" + + @Volatile + private var channelCreated = false + + fun post( + context: Context, + action: Action.SendNotification, + ) { + if (!PermissionUtils.isPostNotificationsEnabled(context)) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureChannel(context, manager) + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_name) + .setAutoCancel(true) + .setContentIntent( + launchIntent?.let { + PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + }, + ) + + if (action.notificationTitle.isNotBlank()) { + builder.setContentTitle(action.notificationTitle) + } + if (action.body.isNotBlank()) { + builder.setContentText(action.body) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(action.body)) + } + if (action.label.isNotBlank()) { + builder.setSubText(action.label) + } + + manager.notify(action.hashCode(), builder.build()) + } + + private fun ensureChannel( + context: Context, + manager: NotificationManager, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || channelCreated) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.automation_notifications_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + channelCreated = true + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7bc5f12cd..9039340bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1457,6 +1457,10 @@ Wait Duration Pause before the next action runs + Send notification + Title + Message + Label Turn Off Low Power Mode Set Charging Mode Trigger notification lighting @@ -1607,6 +1611,7 @@ Automations + Automation notifications Automation failed %1$s: %2$s Missing permission: %1$s diff --git a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt index c2862cf6e..84e6a5722 100644 --- a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt @@ -22,4 +22,20 @@ class ActionListJsonTest { fun invalidJsonGivesEmptyList() { assertEquals(emptyList(), ActionGsonAdapter.listFromJson("not json")) } + + @Test + fun sendNotificationRoundTrip() { + val actions = listOf(Action.SendNotification(notificationTitle = "Title", body = "Body", label = "Label")) + assertEquals(actions, ActionGsonAdapter.listFromJson(ActionGsonAdapter.listToJson(actions))) + } + + @Test + fun sendNotificationMissingFieldsLoadEmpty() { + val singleAction = ActionGsonAdapter.fromJson("""{"type":"SendNotification","title":"Hi"}""") + assertEquals(Action.SendNotification(notificationTitle = "Hi", body = "", label = ""), singleAction) + + val json = """[{"type":"SendNotification","title":"Hi"}]""" + val expected = listOf(Action.SendNotification(notificationTitle = "Hi", body = "", label = "")) + assertEquals(expected, ActionGsonAdapter.listFromJson(json)) + } }