From 0ac7e7268a43fe95b52909b217272f5db114b2cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:38:21 +0000 Subject: [PATCH 1/6] Initial plan From ecf4b6c3eb52bd5717222d13734f9ec04d834e9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:50:42 +0000 Subject: [PATCH 2/6] Add CREATE_NOTIFICATION action with basic implementation - Added CREATE_NOTIFICATION to ActionId enum - Added CreateNotification data class to ActionData with title, text, and optional timeout - Added CHANNEL_CUSTOM_NOTIFICATIONS channel in NotificationController - Added POST_NOTIFICATIONS permission requirement in ActionUtils - Implemented notification creation in PerformActionsUseCase - Added entity mapping in ActionDataEntityMapper - Added string resources for the action - Added notification extras constants in ActionEntity Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionData.kt | 14 +++++++++++ .../base/actions/ActionDataEntityMapper.kt | 25 +++++++++++++++++++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUtils.kt | 6 +++++ .../base/actions/CreateActionDelegate.kt | 12 +++++++++ .../base/actions/PerformActionsUseCase.kt | 23 +++++++++++++++++ .../notifications/NotificationController.kt | 9 +++++++ base/src/main/res/values/strings.xml | 2 ++ .../keymapper/data/entities/ActionEntity.kt | 3 +++ 9 files changed, 95 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 35feedbca9..7fa8136182 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -871,6 +871,20 @@ sealed class ActionData : Comparable { override val id: ActionId = ActionId.DISMISS_ALL_NOTIFICATIONS } + @Serializable + data class CreateNotification( + val title: String, + val text: String, + val timeoutMs: Long?, + ) : ActionData() { + override val id: ActionId = ActionId.CREATE_NOTIFICATION + + override fun compareTo(other: ActionData) = when (other) { + is CreateNotification -> title.compareTo(other.title) + else -> super.compareTo(other) + } + } + @Serializable data object AnswerCall : ActionData() { override val id: ActionId = ActionId.ANSWER_PHONE_CALL diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index b13b0757e3..bc768a2668 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -50,6 +50,7 @@ object ActionDataEntityMapper { ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND + ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION } return when (actionId) { @@ -556,6 +557,21 @@ object ActionDataEntityMapper { ActionId.SHOW_POWER_MENU -> ActionData.ShowPowerMenu ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { + val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() + ?: return null + + val text = entity.data + + val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT).valueOrNull() + ?.toLongOrNull() + + ActionData.CreateNotification( + title = title, + text = text, + timeoutMs = timeoutMs, + ) + } ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall ActionId.END_PHONE_CALL -> ActionData.EndCall ActionId.DEVICE_CONTROLS -> ActionData.DeviceControls @@ -749,6 +765,7 @@ object ActionDataEntityMapper { is ActionData.Sound -> ActionEntity.Type.SOUND is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND + is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION else -> ActionEntity.Type.SYSTEM_ACTION } @@ -819,6 +836,7 @@ object ActionDataEntityMapper { data.command.toByteArray(), Base64.DEFAULT, ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + is ActionData.CreateNotification -> data.text is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! @@ -1105,6 +1123,13 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), ) + is ActionData.CreateNotification -> buildList { + add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TITLE, data.title)) + data.timeoutMs?.let { + add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT, it.toString())) + } + } + else -> emptyList() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 481dc59c5b..3cf37a8b94 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -138,6 +138,7 @@ enum class ActionId { DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, + CREATE_NOTIFICATION, ANSWER_PHONE_CALL, END_PHONE_CALL, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 0902ef74db..db1318872a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -248,6 +248,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS + ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS @@ -373,6 +374,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.string.action_dismiss_most_recent_notification ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications + ActionId.CREATE_NOTIFICATION -> R.string.action_create_notification ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call ActionId.END_PHONE_CALL -> R.string.action_end_call ActionId.SEND_SMS -> R.string.action_send_sms @@ -500,6 +502,7 @@ object ActionUtils { ActionId.SOUND -> R.drawable.ic_outline_volume_up_24 ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.drawable.ic_baseline_clear_all_24 ActionId.DISMISS_ALL_NOTIFICATIONS -> R.drawable.ic_baseline_clear_all_24 + ActionId.CREATE_NOTIFICATION -> R.drawable.ic_notification_play ActionId.ANSWER_PHONE_CALL -> R.drawable.ic_outline_call_24 ActionId.END_PHONE_CALL -> R.drawable.ic_outline_call_end_24 ActionId.SEND_SMS -> R.drawable.ic_outline_message_24 @@ -744,6 +747,8 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION, -> return listOf(Permission.NOTIFICATION_LISTENER) + ActionId.CREATE_NOTIFICATION -> return listOf(Permission.POST_NOTIFICATIONS) + ActionId.ANSWER_PHONE_CALL, ActionId.END_PHONE_CALL, -> return listOf(Permission.ANSWER_PHONE_CALL) @@ -882,6 +887,7 @@ object ActionUtils { ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll + ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index ad6ed0c4f3..9088eab927 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -52,6 +52,7 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) + var createNotificationBottomSheetState: ActionData.CreateNotification? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) @@ -884,6 +885,17 @@ class CreateActionDelegate( ActionId.DISABLE_DND_MODE -> return ActionData.DoNotDisturb.Disable ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> return ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { + // This will be handled by a configuration screen later + // For now, we'll navigate to the screen + createNotificationBottomSheetState = oldData as? ActionData.CreateNotification + ?: ActionData.CreateNotification( + title = "", + text = "", + timeoutMs = null, + ) + return null + } ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall ActionId.DEVICE_CONTROLS -> return ActionData.DeviceControls diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index ab1fe35d3b..a4cdd6ac1a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -18,6 +18,7 @@ import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityServic import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper +import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.Constants @@ -60,6 +61,7 @@ import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter +import io.github.sds100.keymapper.system.notifications.NotificationModel import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent import io.github.sds100.keymapper.system.phone.PhoneAdapter @@ -116,6 +118,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, private val notificationReceiverAdapter: NotificationReceiverAdapter, + private val manageNotifications: ManageNotificationsUseCase, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, @@ -928,6 +931,26 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.CreateNotification -> { + // Generate a unique notification ID based on title and text hash + val notificationId = (action.title + action.text).hashCode() + + val notification = NotificationModel( + id = notificationId, + channel = io.github.sds100.keymapper.base.system.notifications.NotificationController.CHANNEL_CUSTOM_NOTIFICATIONS, + title = action.title, + text = action.text, + icon = R.drawable.ic_notification_play, + showOnLockscreen = false, + onGoing = false, + autoCancel = true, + timeout = action.timeoutMs, + ) + + manageNotifications.show(notification) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt index 2c9fcc417b..1b03c9977a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt @@ -70,6 +70,7 @@ class NotificationController @Inject constructor( const val CHANNEL_TOGGLE_KEYBOARD = "channel_toggle_keymapper_keyboard" const val CHANNEL_NEW_FEATURES = "channel_new_features" const val CHANNEL_SETUP_ASSISTANT = "channel_setup_assistant" + const val CHANNEL_CUSTOM_NOTIFICATIONS = "channel_custom_notifications" @Deprecated("Removed in 2.0. This channel shouldn't exist") private const val CHANNEL_ID_WARNINGS = "channel_warnings" @@ -100,6 +101,14 @@ class NotificationController @Inject constructor( ), ) + manageNotifications.createChannel( + NotificationChannelModel( + id = CHANNEL_CUSTOM_NOTIFICATIONS, + name = getString(R.string.notification_channel_custom_notifications), + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ), + ) + combine( controlAccessibilityService.serviceState, pauseMappings.isPaused, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 188a2d3f59..c58b6bf573 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -512,6 +512,7 @@ Keyboard is hidden warning Toggle Key Mapper Input Method New features + Custom notifications Running Tap to open Key Mapper. @@ -1082,6 +1083,7 @@ Play sound Dismiss most recent notification Dismiss all notifications + Create notification Device controls screen HTTP request HTTP Method diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 5d33801ee2..3d1509ec69 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -89,6 +89,8 @@ data class ActionEntity( const val EXTRA_SHELL_COMMAND_USE_ROOT = "extra_shell_command_use_root" const val EXTRA_SHELL_COMMAND_DESCRIPTION = "extra_shell_command_description" const val EXTRA_SHELL_COMMAND_TIMEOUT = "extra_shell_command_timeout" + const val EXTRA_NOTIFICATION_TITLE = "extra_notification_title" + const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -183,6 +185,7 @@ data class ActionEntity( SOUND, INTERACT_UI_ELEMENT, SHELL_COMMAND, + CREATE_NOTIFICATION, } constructor( From 4c3d41fc95af25d782f7aa6bd560b8428c24bf52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:55:09 +0000 Subject: [PATCH 3/6] Add UI configuration screen for CREATE_NOTIFICATION action - Created ConfigCreateNotificationViewModel for managing notification state - Created CreateNotificationActionScreen with title, text, and timeout inputs - Added navigation support via NavDestination.ConfigCreateNotification - Integrated configuration screen into BaseMainNavHost - Added string resources for configuration UI labels - Marked CreateNotification action as editable - Configured timeout slider with 5-300 second range Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../sds100/keymapper/base/BaseMainNavHost.kt | 15 ++ .../keymapper/base/actions/ActionUtils.kt | 1 + .../ConfigCreateNotificationViewModel.kt | 80 ++++++++ .../base/actions/CreateActionDelegate.kt | 20 +- .../actions/CreateNotificationActionScreen.kt | 181 ++++++++++++++++++ .../base/utils/navigation/NavDestination.kt | 7 + base/src/main/res/values/strings.xml | 7 + 7 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index f57a042dbc..74eb1799f9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -18,7 +18,9 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import io.github.sds100.keymapper.base.actions.ChooseActionScreen import io.github.sds100.keymapper.base.actions.ChooseActionViewModel +import io.github.sds100.keymapper.base.actions.ConfigCreateNotificationViewModel import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel +import io.github.sds100.keymapper.base.actions.CreateNotificationActionScreen import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel @@ -89,6 +91,19 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val viewModel: ConfigCreateNotificationViewModel = hiltViewModel() + + backStackEntry.handleRouteArgs { destination -> + destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } + } + + CreateNotificationActionScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + composable { val viewModel: ChooseConstraintViewModel = hiltViewModel() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index db1318872a..7bad8091be 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -940,6 +940,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ComposeSms, is ActionData.HttpRequest, is ActionData.ShellCommand, + is ActionData.CreateNotification, is ActionData.InteractUiElement, is ActionData.MoveCursor, -> true diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt new file mode 100644 index 0000000000..43917d2fa7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt @@ -0,0 +1,80 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import javax.inject.Inject + +data class CreateNotificationActionState( + val title: String = "", + val text: String = "", + val timeoutEnabled: Boolean = false, + /** + * UI works with seconds for user-friendliness + */ + val timeoutSeconds: Int = 30, +) + +@HiltViewModel +class ConfigCreateNotificationViewModel @Inject constructor( + private val navigationProvider: NavigationProvider, + private val createActionDelegate: CreateActionDelegate, +) : ViewModel() { + + var state: CreateNotificationActionState by mutableStateOf(CreateNotificationActionState()) + private set + + fun loadAction(action: ActionData.CreateNotification) { + state = state.copy( + title = action.title, + text = action.text, + timeoutEnabled = action.timeoutMs != null, + timeoutSeconds = (action.timeoutMs ?: 30000) / 1000, + ) + } + + fun onTitleChanged(newTitle: String) { + state = state.copy(title = newTitle) + } + + fun onTextChanged(newText: String) { + state = state.copy(text = newText) + } + + fun onTimeoutEnabledChanged(enabled: Boolean) { + state = state.copy(timeoutEnabled = enabled) + } + + fun onTimeoutChanged(newTimeoutSeconds: Int) { + state = state.copy(timeoutSeconds = newTimeoutSeconds) + } + + fun onDoneClick() { + if (state.title.isBlank() || state.text.isBlank()) { + return + } + + val timeoutMs = if (state.timeoutEnabled) { + state.timeoutSeconds * 1000L + } else { + null + } + + val action = ActionData.CreateNotification( + title = state.title, + text = state.text, + timeoutMs = timeoutMs, + ) + + createActionDelegate.actionResult.value = action + navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) + } + + fun onCancelClick() { + navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 9088eab927..6223e8c850 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -52,7 +52,6 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) - var createNotificationBottomSheetState: ActionData.CreateNotification? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) @@ -886,15 +885,16 @@ class CreateActionDelegate( ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> return ActionData.DismissAllNotifications ActionId.CREATE_NOTIFICATION -> { - // This will be handled by a configuration screen later - // For now, we'll navigate to the screen - createNotificationBottomSheetState = oldData as? ActionData.CreateNotification - ?: ActionData.CreateNotification( - title = "", - text = "", - timeoutMs = null, - ) - return null + val oldAction = oldData as? ActionData.CreateNotification + + return navigate( + "config_create_notification_action", + NavDestination.ConfigCreateNotification( + oldAction?.let { + Json.encodeToString(oldAction) + }, + ), + ) } ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt new file mode 100644 index 0000000000..1b5045a234 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt @@ -0,0 +1,181 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText + +@Composable +fun CreateNotificationActionScreen( + modifier: Modifier = Modifier, + viewModel: ConfigCreateNotificationViewModel, +) { + CreateNotificationActionScreen( + modifier = modifier, + state = viewModel.state, + onTitleChanged = viewModel::onTitleChanged, + onTextChanged = viewModel::onTextChanged, + onTimeoutEnabledChanged = viewModel::onTimeoutEnabledChanged, + onTimeoutChanged = viewModel::onTimeoutChanged, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateNotificationActionScreen( + state: CreateNotificationActionState, + onTitleChanged: (String) -> Unit, + onTextChanged: (String) -> Unit, + onTimeoutEnabledChanged: (Boolean) -> Unit, + onTimeoutChanged: (Int) -> Unit, + onDoneClick: () -> Unit, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_create_notification)) }, + navigationIcon = { + IconButton(onClick = onCancelClick) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.pos_cancel), + ) + } + }, + ) + }, + bottomBar = { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + ExtendedFloatingActionButton( + onClick = { + keyboardController?.hide() + onDoneClick() + }, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + text = { Text(stringResource(R.string.pos_done)) }, + icon = { + Icon( + Icons.Rounded.Check, + contentDescription = stringResource(R.string.pos_done), + ) + }, + ) + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.title, + onValueChange = onTitleChanged, + label = { Text(stringResource(R.string.action_create_notification_title_label)) }, + placeholder = { Text(stringResource(R.string.action_create_notification_title_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.text, + onValueChange = onTextChanged, + label = { Text(stringResource(R.string.action_create_notification_text_label)) }, + placeholder = { Text(stringResource(R.string.action_create_notification_text_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CheckBoxText( + label = stringResource(R.string.action_create_notification_timeout_checkbox), + checked = state.timeoutEnabled, + onCheckedChange = onTimeoutEnabledChanged, + ) + + if (state.timeoutEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + + SliderOptionText( + label = stringResource(R.string.action_create_notification_timeout_label), + value = state.timeoutSeconds, + onValueChange = { onTimeoutChanged(it.toInt()) }, + sliderValue = state.timeoutSeconds.toFloat(), + valueRange = 5f..300f, + steps = 58, // (300 - 5) / 5 - 1 = 58 steps for increments of 5 seconds + valueLabel = stringResource( + R.string.action_create_notification_timeout_value, + state.timeoutSeconds, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview +@Composable +private fun CreateNotificationActionScreenPreview() { + KeyMapperTheme { + CreateNotificationActionScreen( + state = CreateNotificationActionState( + title = "Test Notification", + text = "This is a test notification message", + timeoutEnabled = true, + timeoutSeconds = 30, + ), + onTitleChanged = {}, + onTextChanged = {}, + onTimeoutEnabledChanged = {}, + onTimeoutChanged = {}, + onDoneClick = {}, + onCancelClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index fdbc390a85..e2d70489ff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -38,6 +38,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_CONFIG_KEY_MAP = "config_key_map" const val ID_INTERACT_UI_ELEMENT_ACTION = "interact_ui_element_action" const val ID_SHELL_COMMAND_ACTION = "shell_command_action" + const val ID_CREATE_NOTIFICATION_ACTION = "create_notification_action" const val ID_PRO_MODE = "pro_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" @@ -172,6 +173,12 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_SHELL_COMMAND_ACTION } + @Serializable + data class ConfigCreateNotification(val actionJson: String?) : + NavDestination(isCompose = true) { + override val id: String = ID_CREATE_NOTIFICATION_ACTION + } + @Serializable data object ProMode : NavDestination(isCompose = true) { override val id: String = ID_PRO_MODE diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c58b6bf573..ae66de7dad 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1084,6 +1084,13 @@ Dismiss most recent notification Dismiss all notifications Create notification + Notification title + Enter notification title + Notification content + Enter notification content + Auto-dismiss notification + Auto-dismiss after + %d seconds Device controls screen HTTP request HTTP Method From 1af1451595bafa96dc1e766aa5ccdb7590ae5647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:58:34 +0000 Subject: [PATCH 4/6] Address code review feedback - Improved notification ID generation to avoid collisions using timestamp + hash - Added validation feedback with error messages and disabled Done button - Extracted magic numbers to constants (MIN/MAX/STEP timeout values) - Added supportingText for empty field errors in UI Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../actions/CreateNotificationActionScreen.kt | 17 +++++++++++++++-- .../base/actions/PerformActionsUseCase.kt | 4 ++-- base/src/main/res/values/strings.xml | 2 ++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt index 1b5045a234..856f1220cd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt @@ -33,6 +33,10 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +private const val MIN_TIMEOUT_SECONDS = 5 +private const val MAX_TIMEOUT_SECONDS = 300 +private const val TIMEOUT_STEP_SECONDS = 5 + @Composable fun CreateNotificationActionScreen( modifier: Modifier = Modifier, @@ -87,6 +91,7 @@ private fun CreateNotificationActionScreen( keyboardController?.hide() onDoneClick() }, + enabled = state.title.isNotBlank() && state.text.isNotBlank(), elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), text = { Text(stringResource(R.string.pos_done)) }, icon = { @@ -115,6 +120,10 @@ private fun CreateNotificationActionScreen( placeholder = { Text(stringResource(R.string.action_create_notification_title_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = state.title.isBlank(), + supportingText = if (state.title.isBlank()) { + { Text(stringResource(R.string.action_create_notification_title_error)) } + } else null, ) Spacer(modifier = Modifier.height(16.dp)) @@ -127,6 +136,10 @@ private fun CreateNotificationActionScreen( modifier = Modifier.fillMaxWidth(), minLines = 3, maxLines = 10, + isError = state.text.isBlank(), + supportingText = if (state.text.isBlank()) { + { Text(stringResource(R.string.action_create_notification_text_error)) } + } else null, ) Spacer(modifier = Modifier.height(16.dp)) @@ -145,8 +158,8 @@ private fun CreateNotificationActionScreen( value = state.timeoutSeconds, onValueChange = { onTimeoutChanged(it.toInt()) }, sliderValue = state.timeoutSeconds.toFloat(), - valueRange = 5f..300f, - steps = 58, // (300 - 5) / 5 - 1 = 58 steps for increments of 5 seconds + valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), + steps = ((MAX_TIMEOUT_SECONDS - MIN_TIMEOUT_SECONDS) / TIMEOUT_STEP_SECONDS) - 1, valueLabel = stringResource( R.string.action_create_notification_timeout_value, state.timeoutSeconds, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index a4cdd6ac1a..21eaca195e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -932,8 +932,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID based on title and text hash - val notificationId = (action.title + action.text).hashCode() + // Generate a unique notification ID using timestamp and hash to avoid collisions + val notificationId = (System.currentTimeMillis() / 1000).toInt() + (action.title + action.text).hashCode() val notification = NotificationModel( id = notificationId, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index ae66de7dad..56240c43a7 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1086,8 +1086,10 @@ Create notification Notification title Enter notification title + Title cannot be empty Notification content Enter notification content + Content cannot be empty Auto-dismiss notification Auto-dismiss after %d seconds From e1828155f5235d0c5ed9b29cadb83ddc6e3d7da9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:00:27 +0000 Subject: [PATCH 5/6] Fix validation and ID generation issues - Added validation for entity.data to ensure text is not blank - Fixed notification ID generation to avoid integer overflow - Use Long arithmetic and convert to absolute value to ensure positive IDs Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionDataEntityMapper.kt | 3 ++- .../keymapper/base/actions/PerformActionsUseCase.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index bc768a2668..54e95dc517 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -561,7 +561,8 @@ object ActionDataEntityMapper { val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() ?: return null - val text = entity.data + val text = entity.data.takeIf { it.isNotBlank() } + ?: return null val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT).valueOrNull() ?.toLongOrNull() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 21eaca195e..3eb17f3bf9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -932,8 +932,13 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID using timestamp and hash to avoid collisions - val notificationId = (System.currentTimeMillis() / 1000).toInt() + (action.title + action.text).hashCode() + // Generate a unique notification ID using absolute value to avoid negative IDs + // Combine timestamp and hash to ensure uniqueness + val timestamp = (System.currentTimeMillis() / 1000).toInt() + val contentHash = (action.title + action.text).hashCode() + val notificationId = (timestamp.toLong() + contentHash).toInt().let { + if (it < 0) -it else it + } val notification = NotificationModel( id = notificationId, From c8dcc53e8737890f1409ad9fb5837a8aa7814558 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:36:30 +0000 Subject: [PATCH 6/6] Address PR review feedback - Use action.hashCode() for notification ID as requested - Rename ConfigCreateNotification to ConfigNotificationAction Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../io/github/sds100/keymapper/base/BaseMainNavHost.kt | 4 ++-- .../sds100/keymapper/base/actions/CreateActionDelegate.kt | 2 +- .../sds100/keymapper/base/actions/PerformActionsUseCase.kt | 7 ++----- .../keymapper/base/utils/navigation/NavDestination.kt | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 74eb1799f9..44d4693722 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -91,10 +91,10 @@ fun BaseMainNavHost( ) } - composable { backStackEntry -> + composable { backStackEntry -> val viewModel: ConfigCreateNotificationViewModel = hiltViewModel() - backStackEntry.handleRouteArgs { destination -> + backStackEntry.handleRouteArgs { destination -> destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 6223e8c850..b9c8432cb9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -889,7 +889,7 @@ class CreateActionDelegate( return navigate( "config_create_notification_action", - NavDestination.ConfigCreateNotification( + NavDestination.ConfigNotificationAction( oldAction?.let { Json.encodeToString(oldAction) }, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 3eb17f3bf9..3c945f9f0e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -932,11 +932,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID using absolute value to avoid negative IDs - // Combine timestamp and hash to ensure uniqueness - val timestamp = (System.currentTimeMillis() / 1000).toInt() - val contentHash = (action.title + action.text).hashCode() - val notificationId = (timestamp.toLong() + contentHash).toInt().let { + // Use the hashcode of the action instance as the unique notification ID + val notificationId = action.hashCode().let { if (it < 0) -it else it } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index e2d70489ff..26dcf46a3b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -174,7 +174,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ConfigCreateNotification(val actionJson: String?) : + data class ConfigNotificationAction(val actionJson: String?) : NavDestination(isCompose = true) { override val id: String = ID_CREATE_NOTIFICATION_ACTION }