Skip to content
Merged
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
12 changes: 12 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Action?>(null) }
var configIndex by remember { mutableStateOf<Int?>(null) }
var showAddActionSheet by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 -> {}
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading