From 166f751269a0457f2caf08d53a0cbdf75090a14b Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 13:48:26 +0200 Subject: [PATCH 1/2] #1029 feat: add action to show a toast message --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 16 ++ .../base/actions/ActionDataEntityMapper.kt | 152 ++++++++++++- .../base/actions/ActionErrorSnapshot.kt | 8 + .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 11 + .../keymapper/base/actions/ActionUtils.kt | 5 + .../base/actions/ChooseActionScreen.kt | 1 + .../base/actions/CreateActionDelegate.kt | 30 +++ .../base/actions/PerformActionsUseCase.kt | 8 + .../base/actions/ToastActionBottomSheet.kt | 208 ++++++++++++++++++ base/src/main/res/values/strings.xml | 8 + .../keymapper/data/entities/ActionEntity.kt | 2 + .../system/popup/AndroidToastAdapter.kt | 5 +- .../keymapper/system/popup/ToastAdapter.kt | 2 +- 15 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cc80581a..f597f3e8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. +- #1029 add action to show a toast message. ## Fixed 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 e3903bd094..4c3bb89e50 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 @@ -924,6 +924,22 @@ sealed class ActionData : Comparable { } } + @Serializable + data class Toast(val message: String, val duration: Duration) : ActionData() { + override val id: ActionId = ActionId.TOAST + + @Serializable + enum class Duration { + SHORT, + LONG, + } + + override fun compareTo(other: ActionData) = when (other) { + is Toast -> compareValuesBy(this, other, { it.message }, { it.duration }) + 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 a8fc7a24d0..75c7d48fa6 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 @@ -33,26 +33,43 @@ object ActionDataEntityMapper { fun fromEntity(entity: ActionEntity): ActionData? { val actionId = when (entity.type) { ActionEntity.Type.APP -> ActionId.APP + ActionEntity.Type.APP_SHORTCUT -> ActionId.APP_SHORTCUT + ActionEntity.Type.KEY_EVENT -> ActionId.KEY_EVENT + ActionEntity.Type.TEXT_BLOCK -> ActionId.TEXT + ActionEntity.Type.URL -> ActionId.URL + ActionEntity.Type.TAP_COORDINATE -> ActionId.TAP_SCREEN + ActionEntity.Type.SWIPE_COORDINATE -> ActionId.SWIPE_SCREEN + ActionEntity.Type.PINCH_COORDINATE -> ActionId.PINCH_SCREEN + ActionEntity.Type.INTENT -> ActionId.INTENT + ActionEntity.Type.PHONE_CALL -> ActionId.PHONE_CALL + ActionEntity.Type.SEND_SMS -> ActionId.SEND_SMS + ActionEntity.Type.COMPOSE_SMS -> ActionId.COMPOSE_SMS + ActionEntity.Type.SOUND -> ActionId.SOUND + ActionEntity.Type.SYSTEM_ACTION -> { SYSTEM_ACTION_ID_MAP.getKey(entity.data) ?: return null } ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT + ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND + ActionEntity.Type.MODIFY_SETTING -> ActionId.MODIFY_SETTING + ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION + ActionEntity.Type.TOAST -> ActionId.TOAST } return when (actionId) { @@ -246,10 +263,12 @@ object ActionDataEntityMapper { number = entity.data, message = message, ) + ActionId.COMPOSE_SMS -> ActionData.ComposeSms( number = entity.data, message = message, ) + else -> return null } } @@ -320,12 +339,15 @@ object ActionDataEntityMapper { when (actionId) { ActionId.VOLUME_UP -> ActionData.Volume.Up(showVolumeUi, volumeStream) + ActionId.VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi, volumeStream) + ActionId.VOLUME_TOGGLE_MUTE -> ActionData.Volume.ToggleMute( showVolumeUi, ) ActionId.VOLUME_UNMUTE -> ActionData.Volume.UnMute(showVolumeUi) + ActionId.VOLUME_MUTE -> ActionData.Volume.Mute(showVolumeUi) else -> throw Exception("don't know how to create system action for $actionId") @@ -333,7 +355,9 @@ object ActionDataEntityMapper { } ActionId.MUTE_MICROPHONE -> ActionData.Microphone.Mute + ActionId.UNMUTE_MICROPHONE -> ActionData.Microphone.Unmute + ActionId.TOGGLE_MUTE_MICROPHONE -> ActionData.Microphone.Toggle ActionId.TOGGLE_FLASHLIGHT, @@ -350,6 +374,7 @@ object ActionDataEntityMapper { when (actionId) { ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle(lens, flashStrength) + ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable(lens, flashStrength) ActionId.CHANGE_FLASHLIGHT_STRENGTH -> { @@ -477,97 +502,159 @@ object ActionDataEntityMapper { } ActionId.TOGGLE_WIFI -> ActionData.Wifi.Toggle + ActionId.ENABLE_WIFI -> ActionData.Wifi.Enable + ActionId.DISABLE_WIFI -> ActionData.Wifi.Disable ActionId.TOGGLE_BLUETOOTH -> ActionData.Bluetooth.Toggle + ActionId.ENABLE_BLUETOOTH -> ActionData.Bluetooth.Enable + ActionId.DISABLE_BLUETOOTH -> ActionData.Bluetooth.Disable ActionId.TOGGLE_MOBILE_DATA -> ActionData.MobileData.Toggle + ActionId.ENABLE_MOBILE_DATA -> ActionData.MobileData.Enable + ActionId.DISABLE_MOBILE_DATA -> ActionData.MobileData.Disable ActionId.TOGGLE_HOTSPOT -> ActionData.Hotspot.Toggle + ActionId.ENABLE_HOTSPOT -> ActionData.Hotspot.Enable + ActionId.DISABLE_HOTSPOT -> ActionData.Hotspot.Disable ActionId.TOGGLE_AUTO_BRIGHTNESS -> ActionData.Brightness.ToggleAuto + ActionId.DISABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.DisableAuto + ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.EnableAuto + ActionId.INCREASE_BRIGHTNESS -> ActionData.Brightness.Increase + ActionId.DECREASE_BRIGHTNESS -> ActionData.Brightness.Decrease ActionId.TOGGLE_NIGHT_SHIFT -> ActionData.NightShift.Toggle + ActionId.ENABLE_NIGHT_SHIFT -> ActionData.NightShift.Enable + ActionId.DISABLE_NIGHT_SHIFT -> ActionData.NightShift.Disable ActionId.TOGGLE_AUTO_ROTATE -> ActionData.Rotation.ToggleAuto + ActionId.ENABLE_AUTO_ROTATE -> ActionData.Rotation.EnableAuto + ActionId.DISABLE_AUTO_ROTATE -> ActionData.Rotation.DisableAuto + ActionId.PORTRAIT_MODE -> ActionData.Rotation.Portrait + ActionId.LANDSCAPE_MODE -> ActionData.Rotation.Landscape + ActionId.SWITCH_ORIENTATION -> ActionData.Rotation.SwitchOrientation ActionId.VOLUME_SHOW_DIALOG -> ActionData.Volume.ShowDialog + ActionId.CYCLE_RINGER_MODE -> ActionData.Volume.CycleRingerMode + ActionId.CYCLE_VIBRATE_RING -> ActionData.Volume.CycleVibrateRing ActionId.EXPAND_NOTIFICATION_DRAWER -> ActionData.StatusBar.ExpandNotifications + ActionId.TOGGLE_NOTIFICATION_DRAWER -> ActionData.StatusBar.ToggleNotifications + ActionId.EXPAND_QUICK_SETTINGS -> ActionData.StatusBar.ExpandQuickSettings + ActionId.TOGGLE_QUICK_SETTINGS -> ActionData.StatusBar.ToggleQuickSettings + ActionId.COLLAPSE_STATUS_BAR -> ActionData.StatusBar.Collapse ActionId.PAUSE_MEDIA -> ActionData.ControlMedia.Pause + ActionId.PLAY_MEDIA -> ActionData.ControlMedia.Play + ActionId.PLAY_PAUSE_MEDIA -> ActionData.ControlMedia.PlayPause + ActionId.NEXT_TRACK -> ActionData.ControlMedia.NextTrack + ActionId.PREVIOUS_TRACK -> ActionData.ControlMedia.PreviousTrack + ActionId.FAST_FORWARD -> ActionData.ControlMedia.FastForward + ActionId.REWIND -> ActionData.ControlMedia.Rewind + ActionId.STOP_MEDIA -> ActionData.ControlMedia.Stop + ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward + ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward ActionId.GO_BACK -> ActionData.GoBack + ActionId.GO_HOME -> ActionData.GoHome + ActionId.OPEN_RECENTS -> ActionData.OpenRecents + ActionId.TOGGLE_SPLIT_SCREEN -> ActionData.ToggleSplitScreen + ActionId.GO_LAST_APP -> ActionData.GoLastApp + ActionId.OPEN_MENU -> ActionData.OpenMenu ActionId.ENABLE_NFC -> ActionData.Nfc.Enable + ActionId.DISABLE_NFC -> ActionData.Nfc.Disable + ActionId.TOGGLE_NFC -> ActionData.Nfc.Toggle ActionId.TOGGLE_KEYBOARD -> ActionData.ToggleKeyboard + ActionId.SHOW_KEYBOARD -> ActionData.ShowKeyboard + ActionId.HIDE_KEYBOARD -> ActionData.HideKeyboard + ActionId.SHOW_KEYBOARD_PICKER -> ActionData.ShowKeyboardPicker + ActionId.PERFORM_IME_ACTION -> ActionData.PerformImeAction + ActionId.TEXT_CUT -> ActionData.CutText + ActionId.TEXT_COPY -> ActionData.CopyText + ActionId.TEXT_PASTE -> ActionData.PasteText + ActionId.SELECT_WORD_AT_CURSOR -> ActionData.SelectWordAtCursor ActionId.TOGGLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Toggle + ActionId.ENABLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Enable + ActionId.DISABLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Disable ActionId.SCREENSHOT -> ActionData.Screenshot + ActionId.OPEN_VOICE_ASSISTANT -> ActionData.VoiceAssistant + ActionId.OPEN_DEVICE_ASSISTANT -> ActionData.DeviceAssistant ActionId.OPEN_CAMERA -> ActionData.OpenCamera + ActionId.LOCK_DEVICE -> ActionData.LockDevice + ActionId.POWER_ON_OFF_DEVICE -> ActionData.ScreenOnOff + ActionId.SECURE_LOCK_DEVICE -> ActionData.SecureLock + ActionId.CONSUME_KEY_EVENT -> ActionData.ConsumeKeyEvent + ActionId.OPEN_SETTINGS -> ActionData.OpenSettings + 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() @@ -587,9 +674,31 @@ object ActionDataEntityMapper { timeoutMs = timeoutMs, ) } + + ActionId.TOAST -> { + val message = entity.data.takeIf { it.isNotBlank() } ?: return null + + val durationString = entity.extras.getData(ActionEntity.EXTRA_TOAST_DURATION) + .valueOrNull() ?: return null + + val duration = try { + ActionData.Toast.Duration.valueOf(durationString) + } catch (_: IllegalArgumentException) { + return null + } + + ActionData.Toast( + message = message, + duration = duration, + ) + } + ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall + ActionId.END_PHONE_CALL -> ActionData.EndCall + ActionId.DEVICE_CONTROLS -> ActionData.DeviceControls + ActionId.HTTP_REQUEST -> { val method = entity.extras.getData(ActionEntity.EXTRA_HTTP_METHOD).then { HTTP_METHOD_MAP.getKey(it)!!.success() @@ -684,18 +793,23 @@ object ActionDataEntityMapper { ActionEntity.CURSOR_TYPE_CHAR -> Success( ActionData.MoveCursor.Type.CHAR, ) + ActionEntity.CURSOR_TYPE_WORD -> Success( ActionData.MoveCursor.Type.WORD, ) + ActionEntity.CURSOR_TYPE_LINE -> Success( ActionData.MoveCursor.Type.LINE, ) + ActionEntity.CURSOR_TYPE_PARAGRAPH -> Success( ActionData.MoveCursor.Type.PARAGRAPH, ) + ActionEntity.CURSOR_TYPE_PAGE -> Success( ActionData.MoveCursor.Type.PAGE, ) + else -> KMError.Exception( IllegalArgumentException("Unknown move cursor type: $value"), ) @@ -708,9 +822,11 @@ object ActionDataEntityMapper { ActionEntity.CURSOR_DIRECTION_START -> Success( ActionData.MoveCursor.Direction.START, ) + ActionEntity.CURSOR_DIRECTION_END -> Success( ActionData.MoveCursor.Direction.END, ) + else -> KMError.Exception( IllegalArgumentException("Unknown move cursor direction: $value"), ) @@ -753,6 +869,7 @@ object ActionDataEntityMapper { } ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp ActionId.MODIFY_SETTING -> { @@ -802,6 +919,7 @@ object ActionDataEntityMapper { is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND is ActionData.ModifySetting -> ActionEntity.Type.MODIFY_SETTING is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION + is ActionData.Toast -> ActionEntity.Type.TOAST else -> ActionEntity.Type.SYSTEM_ACTION } @@ -851,35 +969,60 @@ object ActionDataEntityMapper { @Suppress("ktlint:standard:max-line-length") private fun getDataString(data: ActionData): String = when (data) { is ActionData.Intent -> data.uri + is ActionData.InputKeyEvent -> data.keyCode.toString() + is ActionData.App -> data.packageName + is ActionData.AppShortcut -> data.uri + is ActionData.PhoneCall -> data.number + is ActionData.SendSms -> data.number + is ActionData.ComposeSms -> data.number + is ActionData.TapScreen -> "${data.x},${data.y}" + is ActionData.SwipeScreen -> "${data.xStart},${data.yStart},${data.xEnd},${data.yEnd},${data.fingerCount},${data.duration}" + is ActionData.PinchScreen -> "${data.x},${data.y},${data.distance},${data.pinchType},${data.fingerCount},${data.duration}" + is ActionData.Text -> data.text + is ActionData.Url -> data.url + is ActionData.Sound -> when (data) { is ActionData.Sound.Ringtone -> data.uri is ActionData.Sound.SoundFile -> data.soundUid } is ActionData.InteractUiElement -> data.description + is ActionData.ShellCommand -> Base64.encodeToString( data.command.toByteArray(), Base64.DEFAULT, - ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + ).trim() + + // Trim to remove trailing newline added by Base64.DEFAULT is ActionData.CreateNotification -> data.text + + is ActionData.Toast -> data.message + 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]!! + is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMedia.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.GoBack -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ModifySetting -> data.settingKey + else -> SYSTEM_ACTION_ID_MAP[data.id]!! } @@ -916,6 +1059,7 @@ object ActionDataEntityMapper { }.toList() is ActionData.App -> emptyList() + is ActionData.AppShortcut -> sequence { yield(EntityExtra(ActionEntity.EXTRA_SHORTCUT_TITLE, data.shortcutTitle)) data.packageName?.let { yield(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, it)) } @@ -987,6 +1131,7 @@ object ActionDataEntityMapper { } is ActionData.Flashlight.Disable -> listOf(lensExtra) + is ActionData.Flashlight.ChangeStrength -> buildList { add(lensExtra) add( @@ -1054,6 +1199,7 @@ object ActionDataEntityMapper { }.toList() is ActionData.Text -> emptyList() + is ActionData.Url -> emptyList() is ActionData.Sound.SoundFile -> listOf( @@ -1172,6 +1318,10 @@ object ActionDataEntityMapper { } } + is ActionData.Toast -> listOf( + EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name), + ) + else -> emptyList() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index 9cf7a00748..6c3e5c78ed 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions import android.annotation.SuppressLint +import android.os.Build import io.github.sds100.keymapper.base.actions.sound.SoundsManager import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface @@ -165,6 +166,13 @@ class LazyActionErrorSnapshot( } } + if (action is ActionData.Toast && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !isPermissionGranted(Permission.POST_NOTIFICATIONS) + ) { + return SystemError.PermissionDenied(Permission.POST_NOTIFICATIONS) + } + when (action) { is ActionData.App -> { return getAppError(action.packageName) 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 2ce2fd0eb3..d42bb721ed 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 @@ -148,6 +148,7 @@ enum class ActionId { DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, CREATE_NOTIFICATION, + TOAST, ANSWER_PHONE_CALL, END_PHONE_CALL, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index b97425adf2..9d1f277883 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -674,6 +674,17 @@ class ActionUiHelper( action.title, ) } + + is ActionData.Toast -> { + when (action.duration) { + ActionData.Toast.Duration.SHORT -> { + getString(R.string.action_toast_description_short, action.message) + } + ActionData.Toast.Duration.LONG -> { + getString(R.string.action_toast_description_long, action.message) + } + } + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { 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 85586fe539..846e2e3aba 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 @@ -239,6 +239,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS + ActionId.TOAST -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS ActionId.FORCE_STOP_APP -> ActionCategory.APPS @@ -485,6 +486,7 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications ActionId.CREATE_NOTIFICATION -> R.string.action_create_notification + ActionId.TOAST -> R.string.action_toast ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call @@ -632,6 +634,7 @@ object ActionUtils { 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.TOAST -> R.drawable.ic_outline_message_24 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 @@ -1067,6 +1070,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message + ActionId.TOAST -> Icons.AutoMirrored.Outlined.Message ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice @@ -1125,6 +1129,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.HttpRequest, is ActionData.ShellCommand, is ActionData.CreateNotification, + is ActionData.Toast, is ActionData.InteractUiElement, is ActionData.MoveCursor, is ActionData.ModifySetting, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index 7d895e7fcf..3ad37eb413 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -58,6 +58,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { VolumeActionBottomSheet(delegate) ModifySettingActionBottomSheet(delegate) CreateNotificationActionBottomSheet(delegate) + ToastActionBottomSheet(delegate) } @Composable 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 e1d2124db5..76fa4b1068 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 @@ -65,6 +65,7 @@ class CreateActionDelegate( by mutableStateOf(null) var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? by mutableStateOf(null) + var toastActionBottomSheetState: ToastActionBottomSheetState? by mutableStateOf(null) init { coroutineScope.launch { @@ -357,6 +358,26 @@ class CreateActionDelegate( actionResult.update { action } } + fun onToastMessageChange(message: String) { + toastActionBottomSheetState = + toastActionBottomSheetState?.copy(message = message) + } + + fun onToastDurationChange(duration: ActionData.Toast.Duration) { + toastActionBottomSheetState = + toastActionBottomSheetState?.copy(duration = duration) + } + + fun onDoneToastClick() { + val state = toastActionBottomSheetState ?: return + val action = ActionData.Toast( + message = state.message, + duration = state.duration, + ) + toastActionBottomSheetState = null + actionResult.update { action } + } + fun onRequestNotificationPermissionClick() { useCase.requestPermission(Permission.POST_NOTIFICATIONS) } @@ -1121,6 +1142,15 @@ class CreateActionDelegate( return null } + ActionId.TOAST -> { + val oldAction = oldData as? ActionData.Toast + toastActionBottomSheetState = ToastActionBottomSheetState( + message = oldAction?.message ?: "", + duration = oldAction?.duration ?: ActionData.Toast.Duration.SHORT, + ) + return null + } + 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/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index e2dd348dde..dccaeee822 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 @@ -967,6 +967,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = success() } + is ActionData.Toast -> { + toastAdapter.show( + message = action.message, + isLong = action.duration == ActionData.Toast.Duration.LONG, + ) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt new file mode 100644 index 0000000000..2a0d9a9c56 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt @@ -0,0 +1,208 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.KeyMapperSegmentedButtonRow +import kotlinx.coroutines.launch + +data class ToastActionBottomSheetState( + val message: String = "", + val duration: ActionData.Toast.Duration = ActionData.Toast.Duration.SHORT, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ToastActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.toastActionBottomSheetState != null) { + ToastActionBottomSheet( + sheetState = sheetState, + state = delegate.toastActionBottomSheetState!!, + onDismissRequest = { delegate.toastActionBottomSheetState = null }, + onMessageChange = delegate::onToastMessageChange, + onDurationChange = delegate::onToastDurationChange, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneToastClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ToastActionBottomSheet( + sheetState: SheetState, + state: ToastActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onMessageChange: (String) -> Unit = {}, + onDurationChange: (ActionData.Toast.Duration) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val messageEmptyError = stringResource(R.string.action_toast_message_error) + var messageError: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(state.message) { + if (state.message.isNotBlank()) { + messageError = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.action_toast), + style = MaterialTheme.typography.headlineMedium, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.message, + onValueChange = onMessageChange, + label = { Text(stringResource(R.string.action_toast_message_label)) }, + placeholder = { Text(stringResource(R.string.action_toast_message_hint)) }, + singleLine = true, + isError = messageError != null, + supportingText = { + if (messageError != null) { + Text(text = messageError!!, color = MaterialTheme.colorScheme.error) + } + }, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + ActionData.Toast.Duration.SHORT to + stringResource(R.string.action_toast_duration_short), + ActionData.Toast.Duration.LONG to + stringResource(R.string.action_toast_duration_long), + ), + selectedState = state.duration, + onStateSelected = onDurationChange, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (state.message.isBlank()) { + messageError = messageEmptyError + } else { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ToastActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ToastActionBottomSheet( + sheetState = sheetState, + state = ToastActionBottomSheetState( + message = "Hello world", + duration = ActionData.Toast.Duration.SHORT, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ToastActionBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ToastActionBottomSheet( + sheetState = sheetState, + state = ToastActionBottomSheetState(), + ) + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index d90aef6edc..49693f9bd2 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1136,6 +1136,14 @@ Test Testing… Notification shown successfully + Show toast + Show short toast: %1$s + Show long toast: %1$s + Toast message + Enter toast message + Message cannot be empty + Short + Long 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 b233308d29..65f644da15 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 @@ -91,6 +91,7 @@ data class ActionEntity( 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" + const val EXTRA_TOAST_DURATION = "extra_toast_duration" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -199,6 +200,7 @@ data class ActionEntity( SHELL_COMMAND, MODIFY_SETTING, CREATE_NOTIFICATION, + TOAST, } constructor( diff --git a/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt index a9086a8810..b12b853293 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt @@ -9,7 +9,8 @@ import javax.inject.Singleton @Singleton class AndroidToastAdapter @Inject constructor(@ApplicationContext private val ctx: Context) : ToastAdapter { - override fun show(message: String) { - Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + override fun show(message: String, isLong: Boolean) { + val duration = if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + Toast.makeText(ctx, message, duration).show() } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt index 5db5b1fdcc..6d6d52282f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt @@ -1,5 +1,5 @@ package io.github.sds100.keymapper.system.popup interface ToastAdapter { - fun show(message: String) + fun show(message: String, isLong: Boolean = false) } From 39ee0b1e7560bc2f11c15399859fac349d3548b7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:06:44 +0200 Subject: [PATCH 2/2] #1029 fix tests --- .../sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index 3f7941a56d..7654fbeed8 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -14,7 +14,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -100,6 +99,6 @@ class PerformActionsUseCaseTest { useCase.perform(action) // THEN - verify(mockToastAdapter, never()).show(anyOrNull()) + verify(mockToastAdapter, never()).show(any(), any()) } }