From eab570a09f177e5e9a10bf3870970cf03c30f1fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:14:56 +0000 Subject: [PATCH 01/14] Initial plan From 105dd0b6146d4c7724325f68b9ce0ec954c63830 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:25:07 +0000 Subject: [PATCH 02/14] Add shell command action feature with full-screen UI Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../sds100/keymapper/base/BaseMainNavHost.kt | 17 ++ .../keymapper/base/actions/ActionData.kt | 13 ++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUtils.kt | 4 + .../actions/ConfigShellCommandViewModel.kt | 86 +++++++ .../base/actions/CreateActionDelegate.kt | 9 + .../base/actions/PerformActionsUseCase.kt | 8 + .../base/actions/ShellCommandActionScreen.kt | 218 ++++++++++++++++++ .../base/utils/navigation/NavDestination.kt | 7 + base/src/main/res/values/strings.xml | 11 + 10 files changed, 374 insertions(+) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.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 1fce8dbf81..56a61af869 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,6 +18,8 @@ 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.ConfigShellCommandViewModel +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 import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen @@ -61,6 +63,21 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val viewModel: ConfigShellCommandViewModel = hiltViewModel() + + ShellCommandActionScreen( + command = viewModel.command, + useRoot = viewModel.useRoot, + onCommandChanged = viewModel::onCommandChanged, + onUseRootChanged = viewModel::onUseRootChanged, + onTestClick = viewModel::onTestClick, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + testResult = viewModel.testResult, + ) + } + composable { val viewModel: ChooseConstraintViewModel = hiltViewModel() 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 b9edc2a0d5..df4ce21368 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 @@ -932,6 +932,19 @@ sealed class ActionData : Comparable { } } + @Serializable + data class ShellCommand( + val command: String, + val useRoot: Boolean, + ) : ActionData() { + override val id: ActionId = ActionId.SHELL_COMMAND + + override fun toString(): String { + // Do not leak sensitive command info to logs. + return "ShellCommand(useRoot=$useRoot)" + } + } + @Serializable data class InteractUiElement( val description: String, 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 a589b0494f..002e2c8ac5 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 @@ -11,6 +11,7 @@ enum class ActionId { PINCH_SCREEN, URL, HTTP_REQUEST, + SHELL_COMMAND, INTENT, PHONE_CALL, INTERACT_UI_ELEMENT, 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 12bbf63779..df2bec1709 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 @@ -126,6 +126,7 @@ object ActionUtils { ActionId.INTENT -> ActionCategory.APPS ActionId.URL -> ActionCategory.APPS ActionId.HTTP_REQUEST -> ActionCategory.APPS + ActionId.SHELL_COMMAND -> ActionCategory.APPS ActionId.TOGGLE_WIFI -> ActionCategory.CONNECTIVITY ActionId.ENABLE_WIFI -> ActionCategory.CONNECTIVITY @@ -368,6 +369,7 @@ object ActionUtils { ActionId.COMPOSE_SMS -> R.string.action_compose_sms ActionId.DEVICE_CONTROLS -> R.string.action_device_controls ActionId.HTTP_REQUEST -> R.string.action_http_request + ActionId.SHELL_COMMAND -> R.string.action_shell_command ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title ActionId.FORCE_STOP_APP -> R.string.action_force_stop_app ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app @@ -876,6 +878,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice ActionId.HTTP_REQUEST -> Icons.Outlined.Http + ActionId.SHELL_COMMAND -> Icons.Outlined.DataObject ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit @@ -924,6 +927,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.SendSms, is ActionData.ComposeSms, is ActionData.HttpRequest, + is ActionData.ShellCommand, is ActionData.InteractUiElement, is ActionData.MoveCursor, -> true diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt new file mode 100644 index 0000000000..1853f18f4d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -0,0 +1,86 @@ +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.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.popBackStackWithResult +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.getFullMessage +import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import javax.inject.Inject + +@HiltViewModel +class ConfigShellCommandViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val shellAdapter: ShellAdapter, + private val navigationProvider: NavigationProvider, +) : ViewModel() { + + private val oldAction: ActionData.ShellCommand? = + savedStateHandle.get("action") + + var command: String by mutableStateOf(oldAction?.command ?: "") + private set + + var useRoot: Boolean by mutableStateOf(oldAction?.useRoot ?: false) + private set + + var testResult: State? by mutableStateOf(null) + private set + + fun onCommandChanged(newCommand: String) { + command = newCommand + } + + fun onUseRootChanged(newUseRoot: Boolean) { + useRoot = newUseRoot + } + + fun onTestClick() { + viewModelScope.launch { + testResult = State.Loading + + val result = if (useRoot) { + shellAdapter.executeWithOutput(command) + } else { + shellAdapter.executeWithOutput(command) + } + + testResult = result.fold( + onSuccess = { output -> State.Data(output) }, + onFailure = { error -> State.Data("Error: ${error.getFullMessage()}") }, + ) + } + } + + fun onDoneClick() { + val action = ActionData.ShellCommand( + command = command, + useRoot = useRoot, + ) + + viewModelScope.launch { + navigationProvider.popBackStackWithResult(Json.encodeToString(action)) + } + } + + fun onCancelClick() { + viewModelScope.launch { + navigationProvider.popBackStack() + } + } +} + +private fun io.github.sds100.keymapper.common.utils.KMResult.getFullMessage(): String { + return when (this) { + is io.github.sds100.keymapper.common.utils.Success -> "Success" + is io.github.sds100.keymapper.common.utils.KMError -> this.toString() + } +} 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 f36f510849..8f2a908661 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 @@ -867,6 +867,15 @@ class CreateActionDelegate( return null } + ActionId.SHELL_COMMAND -> { + val oldAction = oldData as? ActionData.ShellCommand + + return navigate( + "config_shell_command_action", + NavDestination.ConfigShellCommand(oldAction), + ) + } + ActionId.INTERACT_UI_ELEMENT -> { val oldAction = oldData as? ActionData.InteractUiElement 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 dd5fe8d4d9..9c9a9ea861 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 @@ -905,6 +905,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.ShellCommand -> { + result = if (action.useRoot) { + suAdapter.execute(action.command) + } else { + shell.execute(action.command) + } + } + is ActionData.InteractUiElement -> { if (service.activeWindowPackage.first() != action.packageName) { result = KMError.UiElementNotFound diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt new file mode 100644 index 0000000000..e84fb46b04 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -0,0 +1,218 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.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.font.FontFamily +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.common.utils.State +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShellCommandActionScreen( + command: String, + useRoot: Boolean, + onCommandChanged: (String) -> Unit, + onUseRootChanged: (Boolean) -> Unit, + onTestClick: () -> Unit, + onDoneClick: () -> Unit, + onCancelClick: () -> Unit, + testResult: State? = null, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + var commandError: String? by rememberSaveable { mutableStateOf(null) } + val commandEmptyErrorString = stringResource(R.string.action_shell_command_command_empty_error) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_shell_command_title)) }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = command, + onValueChange = { + commandError = null + onCommandChanged(it) + }, + label = { Text(stringResource(R.string.action_shell_command_command_label)) }, + minLines = 3, + maxLines = 10, + isError = commandError != null, + supportingText = { + if (commandError != null) { + Text( + text = commandError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = useRoot, + onCheckedChange = onUseRootChanged, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_shell_command_use_root_label)) + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (command.isBlank()) { + commandError = commandEmptyErrorString + } else { + onTestClick() + } + }, + ) { + Text(stringResource(R.string.action_shell_command_test_button)) + } + + if (testResult != null) { + Text( + text = stringResource(R.string.action_shell_command_output_label), + style = MaterialTheme.typography.titleMedium, + ) + + when (testResult) { + is State.Loading -> { + Text( + text = stringResource(R.string.action_shell_command_testing), + style = MaterialTheme.typography.bodyMedium, + ) + } + is State.Data -> { + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = testResult.data, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onCancelClick, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (command.isBlank()) { + commandError = commandEmptyErrorString + } else { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreen() { + KeyMapperTheme { + ShellCommandActionScreen( + command = "echo 'Hello World'", + useRoot = false, + onCommandChanged = {}, + onUseRootChanged = {}, + onTestClick = {}, + onDoneClick = {}, + onCancelClick = {}, + testResult = State.Data("Hello World\n"), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenEmpty() { + KeyMapperTheme { + ShellCommandActionScreen( + command = "", + useRoot = true, + onCommandChanged = {}, + onUseRootChanged = {}, + onTestClick = {}, + 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 48f23a5c14..a5cec53adf 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 @@ -37,6 +37,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_ABOUT = "about" 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_PRO_MODE = "pro_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" @@ -167,6 +168,12 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_INTERACT_UI_ELEMENT_ACTION } + @Serializable + data class ConfigShellCommand(val action: ActionData.ShellCommand?) : + NavDestination(isCompose = true) { + override val id: String = ID_SHELL_COMMAND_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 17fe9771a6..063c43c9d4 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1078,6 +1078,17 @@ Authorization header (optional) You must prepend \'Bearer\' if necessary + Shell command + Shell command + Command + Command cannot be empty! + Run as root + Test + Output + Executing… + Success + Failed + Interact with app element Key Mapper can detect and interact with app elements like menus, tabs, buttons and checkboxes. You need to record yourself interacting with the app so that Key Mapper knows what you want to do. Start recording From fb2570dbebfcc5582556856249c45fceb313c735 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:26:51 +0000 Subject: [PATCH 03/14] Fix shell command execution to support root mode with output Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../actions/ConfigShellCommandViewModel.kt | 25 +++++++++++-------- .../sds100/keymapper/system/root/SuAdapter.kt | 19 ++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index 1853f18f4d..37192f9963 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -9,8 +9,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.popBackStackWithResult +import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.getFullMessage +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -20,6 +22,7 @@ import javax.inject.Inject class ConfigShellCommandViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val shellAdapter: ShellAdapter, + private val suAdapter: SuAdapter, private val navigationProvider: NavigationProvider, ) : ViewModel() { @@ -48,15 +51,15 @@ class ConfigShellCommandViewModel @Inject constructor( testResult = State.Loading val result = if (useRoot) { - shellAdapter.executeWithOutput(command) + suAdapter.executeWithOutput(command) } else { shellAdapter.executeWithOutput(command) } - testResult = result.fold( - onSuccess = { output -> State.Data(output) }, - onFailure = { error -> State.Data("Error: ${error.getFullMessage()}") }, - ) + testResult = when (result) { + is Success -> State.Data(result.data) + is KMError -> State.Data("Error: ${formatError(result)}") + } } } @@ -76,11 +79,11 @@ class ConfigShellCommandViewModel @Inject constructor( navigationProvider.popBackStack() } } -} -private fun io.github.sds100.keymapper.common.utils.KMResult.getFullMessage(): String { - return when (this) { - is io.github.sds100.keymapper.common.utils.Success -> "Success" - is io.github.sds100.keymapper.common.utils.KMError -> this.toString() + private fun formatError(error: KMError): String { + return when (error) { + is KMError.Exception -> error.exception.message ?: "Unknown error" + else -> error.toString() + } } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index 4b3f65eac3..03f0d36d37 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -45,6 +45,24 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } + override fun executeWithOutput(command: String): KMResult { + if (!isRootGranted.firstBlocking()) { + return SystemError.PermissionDenied(Permission.ROOT) + } + + try { + val result = Shell.cmd(command).exec() + + return if (result.isSuccess) { + Success(result.out.joinToString("\n")) + } else { + KMError.Exception(Exception(result.err.joinToString("\n"))) + } + } catch (e: Exception) { + return KMError.Exception(e) + } + } + fun invalidateIsRooted() { try { // Close the shell so a new one is started without root permission. @@ -68,4 +86,5 @@ interface SuAdapter { fun requestPermission() fun execute(command: String, block: Boolean = false): KMResult + fun executeWithOutput(command: String): KMResult } From 93191737d9fe5b8b4a8f1980edcd5410e33ff0ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:28:14 +0000 Subject: [PATCH 04/14] Add shell command description display in UI Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../github/sds100/keymapper/base/actions/ActionUiHelper.kt | 6 ++++++ base/src/main/res/values/strings.xml | 1 + 2 files changed, 7 insertions(+) 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 eedac43621..b6b7b10ca0 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 @@ -572,6 +572,12 @@ class ActionUiHelper( ActionData.DeviceControls -> getString(R.string.action_device_controls) is ActionData.HttpRequest -> action.description + is ActionData.ShellCommand -> if (action.useRoot) { + getString(R.string.action_shell_command_description_with_root, action.command) + } else { + action.command + } + is ActionData.InteractUiElement -> action.description ActionData.ClearRecentApp -> getString(R.string.action_clear_recent_app) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 063c43c9d4..6ca7f050b6 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1088,6 +1088,7 @@ Executing… Success Failed + su -c %s Interact with app element Key Mapper can detect and interact with app elements like menus, tabs, buttons and checkboxes. You need to record yourself interacting with the app so that Key Mapper knows what you want to do. From df12acced9c01005e8f83c1c3592f429adfd33a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:30:02 +0000 Subject: [PATCH 05/14] Add tests for shell command action Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ShellCommandActionTest.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ShellCommandActionTest.kt diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ShellCommandActionTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ShellCommandActionTest.kt new file mode 100644 index 0000000000..1f5e7c8fe1 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ShellCommandActionTest.kt @@ -0,0 +1,73 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.common.utils.InputEventAction +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class ShellCommandActionTest { + + private lateinit var mockShellAdapter: ShellAdapter + private lateinit var mockSuAdapter: SuAdapter + + @Before + fun setup() { + mockShellAdapter = mock { + on { execute(any()) }.doReturn(Success(Unit)) + on { executeWithOutput(any()) }.doReturn(Success("output")) + } + mockSuAdapter = mock { + on { execute(any()) }.doReturn(Success(Unit)) + on { executeWithOutput(any()) }.doReturn(Success("root output")) + } + } + + @Test + fun `shell command without root executes with shell adapter`() = runTest { + val action = ActionData.ShellCommand( + command = "echo test", + useRoot = false, + ) + + // This test verifies the action data structure is correct + // Actual execution testing should be done in PerformActionsUseCaseTest + assert(action.command == "echo test") + assert(!action.useRoot) + assert(action.id == ActionId.SHELL_COMMAND) + } + + @Test + fun `shell command with root flag set correctly`() = runTest { + val action = ActionData.ShellCommand( + command = "reboot", + useRoot = true, + ) + + assert(action.command == "reboot") + assert(action.useRoot) + assert(action.id == ActionId.SHELL_COMMAND) + } + + @Test + fun `shell command is editable`() { + val action = ActionData.ShellCommand( + command = "ls", + useRoot = false, + ) + + assert(action.isEditable()) + } +} From d2eb033a9ae1627fd31b4ada67b7cf2dd485f4a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:00:09 +0000 Subject: [PATCH 06/14] Refactor shell command to use state class and improve error handling Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../sds100/keymapper/base/BaseMainNavHost.kt | 4 +- .../base/actions/ActionDataEntityMapper.kt | 24 +++++++-- .../actions/ConfigShellCommandViewModel.kt | 50 +++++++++---------- .../base/actions/ShellCommandActionScreen.kt | 38 ++++++++------ .../keymapper/data/entities/ActionEntity.kt | 3 ++ 5 files changed, 72 insertions(+), 47 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 56a61af869..807dda840d 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 @@ -67,14 +67,12 @@ fun BaseMainNavHost( val viewModel: ConfigShellCommandViewModel = hiltViewModel() ShellCommandActionScreen( - command = viewModel.command, - useRoot = viewModel.useRoot, + state = viewModel.state, onCommandChanged = viewModel::onCommandChanged, onUseRootChanged = viewModel::onUseRootChanged, onTestClick = viewModel::onTestClick, onDoneClick = viewModel::onDoneClick, onCancelClick = viewModel::onCancelClick, - testResult = viewModel.testResult, ) } 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 968087ea13..71cd6f3727 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 @@ -47,6 +47,7 @@ object ActionDataEntityMapper { } ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT + ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND } return when (actionId) { @@ -652,6 +653,15 @@ object ActionDataEntityMapper { ActionData.MoveCursor(moveType = type, direction = direction) } + ActionId.SHELL_COMMAND -> { + val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT) + + ActionData.ShellCommand( + command = entity.data, + useRoot = useRoot, + ) + } + ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp } @@ -679,6 +689,7 @@ object ActionDataEntityMapper { is ActionData.Url -> ActionEntity.Type.URL is ActionData.Sound -> ActionEntity.Type.SOUND is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT + is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND else -> ActionEntity.Type.SYSTEM_ACTION } @@ -691,6 +702,8 @@ object ActionDataEntityMapper { } private fun getFlags(data: ActionData): Int { + var flags = 0 + val showVolumeUiFlag = when (data) { is ActionData.Volume.Stream -> data.showVolumeUi is ActionData.Volume.Up -> data.showVolumeUi @@ -702,10 +715,14 @@ object ActionDataEntityMapper { } if (showVolumeUiFlag) { - return ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI - } else { - return 0 + flags = flags or ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI + } + + if (data is ActionData.ShellCommand && data.useRoot) { + flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT } + + return flags } private fun getDataString(data: ActionData): String = when (data) { @@ -727,6 +744,7 @@ object ActionDataEntityMapper { } is ActionData.InteractUiElement -> data.description + is ActionData.ShellCommand -> data.command 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]!! diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index 37192f9963..68eee809fa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -7,8 +7,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.popBackStackWithResult +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success @@ -24,49 +26,52 @@ class ConfigShellCommandViewModel @Inject constructor( private val shellAdapter: ShellAdapter, private val suAdapter: SuAdapter, private val navigationProvider: NavigationProvider, + private val resourceProvider: ResourceProvider, ) : ViewModel() { private val oldAction: ActionData.ShellCommand? = savedStateHandle.get("action") - var command: String by mutableStateOf(oldAction?.command ?: "") - private set - - var useRoot: Boolean by mutableStateOf(oldAction?.useRoot ?: false) - private set - - var testResult: State? by mutableStateOf(null) + var state: ShellCommandActionState by mutableStateOf( + ShellCommandActionState( + command = oldAction?.command ?: "", + useRoot = oldAction?.useRoot ?: false, + testResult = null, + ), + ) private set fun onCommandChanged(newCommand: String) { - command = newCommand + state = state.copy(command = newCommand) } fun onUseRootChanged(newUseRoot: Boolean) { - useRoot = newUseRoot + state = state.copy(useRoot = newUseRoot) } fun onTestClick() { viewModelScope.launch { - testResult = State.Loading + state = state.copy(testResult = State.Loading) - val result = if (useRoot) { - suAdapter.executeWithOutput(command) + val result = if (state.useRoot) { + suAdapter.executeWithOutput(state.command) } else { - shellAdapter.executeWithOutput(command) + shellAdapter.executeWithOutput(state.command) } - testResult = when (result) { - is Success -> State.Data(result.data) - is KMError -> State.Data("Error: ${formatError(result)}") - } + state = state.copy( + testResult = when (result) { + is Success -> State.Data(result.data) + is KMError -> State.Data(result.getFullMessage(resourceProvider)) + }, + ) } } fun onDoneClick() { val action = ActionData.ShellCommand( - command = command, - useRoot = useRoot, + command = state.command, + useRoot = state.useRoot, ) viewModelScope.launch { @@ -79,11 +84,4 @@ class ConfigShellCommandViewModel @Inject constructor( navigationProvider.popBackStack() } } - - private fun formatError(error: KMError): String { - return when (error) { - is KMError.Exception -> error.exception.message ?: "Unknown error" - else -> error.toString() - } - } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index e84fb46b04..d534339707 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -39,17 +39,21 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.launch +data class ShellCommandActionState( + val command: String, + val useRoot: Boolean, + val testResult: State? = null, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ShellCommandActionScreen( - command: String, - useRoot: Boolean, + state: ShellCommandActionState, onCommandChanged: (String) -> Unit, onUseRootChanged: (Boolean) -> Unit, onTestClick: () -> Unit, onDoneClick: () -> Unit, onCancelClick: () -> Unit, - testResult: State? = null, ) { val scrollState = rememberScrollState() val scope = rememberCoroutineScope() @@ -75,7 +79,7 @@ fun ShellCommandActionScreen( ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), - value = command, + value = state.command, onValueChange = { commandError = null onCommandChanged(it) @@ -99,7 +103,7 @@ fun ShellCommandActionScreen( verticalAlignment = Alignment.CenterVertically, ) { Checkbox( - checked = useRoot, + checked = state.useRoot, onCheckedChange = onUseRootChanged, ) Spacer(modifier = Modifier.width(8.dp)) @@ -109,7 +113,7 @@ fun ShellCommandActionScreen( Button( modifier = Modifier.fillMaxWidth(), onClick = { - if (command.isBlank()) { + if (state.command.isBlank()) { commandError = commandEmptyErrorString } else { onTestClick() @@ -119,13 +123,13 @@ fun ShellCommandActionScreen( Text(stringResource(R.string.action_shell_command_test_button)) } - if (testResult != null) { + if (state.testResult != null) { Text( text = stringResource(R.string.action_shell_command_output_label), style = MaterialTheme.typography.titleMedium, ) - when (testResult) { + when (state.testResult) { is State.Loading -> { Text( text = stringResource(R.string.action_shell_command_testing), @@ -136,7 +140,7 @@ fun ShellCommandActionScreen( SelectionContainer { OutlinedTextField( modifier = Modifier.fillMaxWidth(), - value = testResult.data, + value = state.testResult.data, onValueChange = {}, readOnly = true, minLines = 5, @@ -168,7 +172,7 @@ fun ShellCommandActionScreen( Button( modifier = Modifier.weight(1f), onClick = { - if (command.isBlank()) { + if (state.command.isBlank()) { commandError = commandEmptyErrorString } else { onDoneClick() @@ -189,14 +193,16 @@ fun ShellCommandActionScreen( private fun PreviewShellCommandActionScreen() { KeyMapperTheme { ShellCommandActionScreen( - command = "echo 'Hello World'", - useRoot = false, + state = ShellCommandActionState( + command = "echo 'Hello World'", + useRoot = false, + testResult = State.Data("Hello World\n"), + ), onCommandChanged = {}, onUseRootChanged = {}, onTestClick = {}, onDoneClick = {}, onCancelClick = {}, - testResult = State.Data("Hello World\n"), ) } } @@ -206,8 +212,10 @@ private fun PreviewShellCommandActionScreen() { private fun PreviewShellCommandActionScreenEmpty() { KeyMapperTheme { ShellCommandActionScreen( - command = "", - useRoot = true, + state = ShellCommandActionState( + command = "", + useRoot = true, + ), onCommandChanged = {}, onUseRootChanged = {}, onTestClick = {}, 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 bcb79b60e7..8ec538d82b 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 @@ -86,6 +86,7 @@ data class ActionEntity( const val EXTRA_HTTP_DESCRIPTION = "extra_http_description" const val EXTRA_HTTP_AUTHORIZATION_HEADER = "extra_http_authorization_header" const val EXTRA_SMS_MESSAGE = "extra_sms_message" + const val EXTRA_SHELL_COMMAND_USE_ROOT = "extra_shell_command_use_root" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -126,6 +127,7 @@ data class ActionEntity( const val ACTION_FLAG_SHOW_VOLUME_UI = 1 const val ACTION_FLAG_REPEAT = 4 const val ACTION_FLAG_HOLD_DOWN = 8 + const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16 const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour" const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour" @@ -177,6 +179,7 @@ data class ActionEntity( COMPOSE_SMS, SOUND, INTERACT_UI_ELEMENT, + SHELL_COMMAND, } constructor( From 86cd243509f04ab0958420b7a3f3f05e22de3145 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 19 Oct 2025 22:11:30 +0200 Subject: [PATCH 07/14] #661 feat: add timeout and description to Shell command action --- .../sds100/keymapper/base/BaseMainNavHost.kt | 12 +- .../keymapper/base/actions/ActionData.kt | 4 +- .../base/actions/ActionDataEntityMapper.kt | 29 +- .../keymapper/base/actions/ActionUtils.kt | 9 +- .../actions/ConfigShellCommandViewModel.kt | 101 +++-- .../base/actions/CreateActionDelegate.kt | 2 +- .../actions/ExecuteShellCommandUseCase.kt | 45 ++ .../base/actions/PerformActionsUseCase.kt | 32 +- .../base/actions/ShellCommandActionScreen.kt | 403 +++++++++++++----- .../sds100/keymapper/base/utils/ErrorUtils.kt | 4 + .../base/utils/navigation/NavDestination.kt | 2 +- base/src/main/res/values/strings.xml | 8 +- .../sds100/keymapper/common/utils/KMResult.kt | 1 + .../keymapper/data/entities/ActionEntity.kt | 2 + .../permissions/AndroidPermissionAdapter.kt | 1 - .../sds100/keymapper/system/root/SuAdapter.kt | 51 ++- .../keymapper/system/shell/ShellAdapter.kt | 5 +- .../keymapper/system/shell/SimpleShell.kt | 62 ++- 18 files changed, 562 insertions(+), 211 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.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 89b652acb0..9a7377cc99 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 @@ -71,13 +71,13 @@ fun BaseMainNavHost( composable { backStackEntry -> val viewModel: ConfigShellCommandViewModel = hiltViewModel() + backStackEntry.handleRouteArgs { destination -> + destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } + } + ShellCommandActionScreen( - state = viewModel.state, - onCommandChanged = viewModel::onCommandChanged, - onUseRootChanged = viewModel::onUseRootChanged, - onTestClick = viewModel::onTestClick, - onDoneClick = viewModel::onDoneClick, - onCancelClick = viewModel::onCancelClick, + modifier = Modifier.fillMaxSize(), + viewModel = viewModel ) } 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 df4ce21368..9704b00014 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 @@ -934,14 +934,16 @@ sealed class ActionData : Comparable { @Serializable data class ShellCommand( + val description: String, val command: String, val useRoot: Boolean, + val timeoutMs: Int = 10000, // milliseconds (default 10 seconds) ) : ActionData() { override val id: ActionId = ActionId.SHELL_COMMAND override fun toString(): String { // Do not leak sensitive command info to logs. - return "ShellCommand(useRoot=$useRoot)" + return "ShellCommand(description=$description, useRoot=$useRoot, timeoutMs=$timeoutMs)" } } 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 71cd6f3727..7ff8e279fb 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import android.util.Base64 import androidx.core.net.toUri import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult @@ -655,10 +656,25 @@ object ActionDataEntityMapper { ActionId.SHELL_COMMAND -> { val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT) + val description = + entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION) + .valueOrNull() ?: return null + + val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT) + .valueOrNull()?.toIntOrNull() ?: 10000 + + // Decode Base64 command + val command = try { + String(Base64.decode(entity.data, Base64.DEFAULT)) + } catch (e: Exception) { + return null + } ActionData.ShellCommand( - command = entity.data, + description = description, + command = command, useRoot = useRoot, + timeoutMs = timeoutMs, ) } @@ -744,7 +760,11 @@ object ActionDataEntityMapper { } is ActionData.InteractUiElement -> data.description - is ActionData.ShellCommand -> data.command + is ActionData.ShellCommand -> Base64.encodeToString( + data.command.toByteArray(), + Base64.DEFAULT + ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + 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]!! @@ -1004,6 +1024,11 @@ object ActionDataEntityMapper { add(EntityExtra(ActionEntity.EXTRA_MOVE_CURSOR_DIRECTION, directionString)) } + is ActionData.ShellCommand -> listOf( + EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION, data.description), + EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMs.toString()), + ) + else -> emptyList() } 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 df2bec1709..f3c9b2363a 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 @@ -7,6 +7,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.automirrored.outlined.ShortText import androidx.compose.material.icons.automirrored.outlined.Undo @@ -40,7 +41,6 @@ import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.KeyboardHide import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Message import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Nfc import androidx.compose.material.icons.outlined.NotStarted @@ -72,6 +72,7 @@ import androidx.compose.material.icons.rounded.BluetoothDisabled import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCut import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.WifiOff import androidx.compose.ui.graphics.vector.ImageVector @@ -869,8 +870,8 @@ object ActionUtils { ActionId.URL -> Icons.Outlined.Link ActionId.INTENT -> Icons.Outlined.DataObject ActionId.PHONE_CALL -> Icons.Outlined.Call - ActionId.SEND_SMS -> Icons.Outlined.Message - ActionId.COMPOSE_SMS -> Icons.Outlined.Message + ActionId.SEND_SMS -> Icons.AutoMirrored.Outlined.Message + ActionId.COMPOSE_SMS -> Icons.AutoMirrored.Outlined.Message ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll @@ -878,7 +879,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice ActionId.HTTP_REQUEST -> Icons.Outlined.Http - ActionId.SHELL_COMMAND -> Icons.Outlined.DataObject + ActionId.SHELL_COMMAND -> Icons.Rounded.Terminal ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index 68eee809fa..c7aede688a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -3,44 +3,43 @@ 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.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.navigation.popBackStackWithResult -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.KMError -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.system.root.SuAdapter -import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import javax.inject.Inject @HiltViewModel class ConfigShellCommandViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val shellAdapter: ShellAdapter, - private val suAdapter: SuAdapter, + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val navigationProvider: NavigationProvider, - private val resourceProvider: ResourceProvider, ) : ViewModel() { - private val oldAction: ActionData.ShellCommand? = - savedStateHandle.get("action") - - var state: ShellCommandActionState by mutableStateOf( - ShellCommandActionState( - command = oldAction?.command ?: "", - useRoot = oldAction?.useRoot ?: false, - testResult = null, - ), - ) + var state: ShellCommandActionState by mutableStateOf(ShellCommandActionState()) private set + private var testJob: Job? = null + + fun loadAction(action: ActionData.ShellCommand) { + state = state.copy( + description = action.description, + command = action.command, + useRoot = action.useRoot, + timeoutSeconds = action.timeoutMs / 1000, + ) + } + + fun onDescriptionChanged(newDescription: String) { + state = state.copy(description = newDescription) + } + fun onCommandChanged(newCommand: String) { state = state.copy(command = newCommand) } @@ -49,29 +48,61 @@ class ConfigShellCommandViewModel @Inject constructor( state = state.copy(useRoot = newUseRoot) } + fun onTimeoutChanged(newTimeoutSeconds: Int) { + state = state.copy(timeoutSeconds = newTimeoutSeconds) + } + fun onTestClick() { - viewModelScope.launch { - state = state.copy(testResult = State.Loading) + // Cancel any existing test + testJob?.cancel() - val result = if (state.useRoot) { - suAdapter.executeWithOutput(state.command) - } else { - shellAdapter.executeWithOutput(state.command) - } + state = state.copy( + isRunning = true, + testResult = null, + ) + + testJob = viewModelScope.launch { + try { + withTimeout(state.timeoutSeconds * 1000L) { + val flow = executeShellCommandUseCase.executeWithStreamingOutput( + command = state.command, + useRoot = state.useRoot, + ) - state = state.copy( - testResult = when (result) { - is Success -> State.Data(result.data) - is KMError -> State.Data(result.getFullMessage(resourceProvider)) - }, - ) + flow.catch { e -> + state = state.copy( + isRunning = false, + testResult = KMError.Exception(e as? Exception ?: Exception(e.message)), + ) + }.collect { result -> + state = state.copy( + isRunning = false, + testResult = result, + ) + } + } + } catch (e: TimeoutCancellationException) { + state = state.copy( + isRunning = false, + testResult = KMError.ShellCommandTimeout(state.timeoutSeconds * 1000), + ) + } } } + fun onKillClick() { + testJob?.cancel() + state = state.copy( + isRunning = false, + ) + } + fun onDoneClick() { val action = ActionData.ShellCommand( + description = state.description, command = state.command, useRoot = state.useRoot, + timeoutMs = state.timeoutSeconds * 1000, ) viewModelScope.launch { 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 8f2a908661..e678276340 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 @@ -872,7 +872,7 @@ class CreateActionDelegate( return navigate( "config_shell_command_action", - NavDestination.ConfigShellCommand(oldAction), + NavDestination.ConfigShellCommand(oldAction?.let { Json.encodeToString(oldAction) }), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt new file mode 100644 index 0000000000..ae230a8be4 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withTimeout +import javax.inject.Inject + +class ExecuteShellCommandUseCase @Inject constructor( + private val shellAdapter: ShellAdapter, + private val suAdapter: SuAdapter, +) { + suspend fun execute( + command: String, + useRoot: Boolean, + timeoutMs: Long, + ): KMResult { + return try { + withTimeout(timeoutMs) { + if (useRoot) { + suAdapter.execute(command) + } else { + shellAdapter.execute(command) + } + } + } catch (e: TimeoutCancellationException) { + KMError.ShellCommandTimeout(timeoutMs.toInt()) + } + } + + fun executeWithStreamingOutput( + command: String, + useRoot: Boolean, + ): Flow> { + return if (useRoot) { + suAdapter.executeWithStreamingOutput(command) + } else { + shellAdapter.executeWithStreamingOutput(command) + } + } +} + 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 9c9a9ea861..4445131924 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 @@ -94,6 +94,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val shell: ShellAdapter, private val intentAdapter: IntentAdapter, private val getActionErrorUseCase: GetActionErrorUseCase, + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val keyMapperImeMessenger: ImeInputEventInjector, private val packageManagerAdapter: PackageManagerAdapter, private val appShortcutAdapter: AppShortcutAdapter, @@ -538,7 +539,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS result = service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-notifications") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-notifications") } } @@ -550,7 +551,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-notifications") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-notifications") } } } @@ -560,7 +561,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-settings") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-settings") } } @@ -572,7 +573,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-settings") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-settings") } } } @@ -786,7 +787,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val fileDate = FileUtils.createFileDate() result = - suAdapter.execute("mkdir -p $screenshotsFolder; screencap -p $screenshotsFolder/Screenshot_$fileDate.png") + getShellAdapter(useRoot = true).execute("mkdir -p $screenshotsFolder; screencap -p $screenshotsFolder/Screenshot_$fileDate.png") .onSuccess { // Wait 3 seconds so the message isn't shown in the screenshot. delay(3000) @@ -817,7 +818,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.LockDevice -> { result = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - suAdapter.execute("input keyevent ${KeyEvent.KEYCODE_POWER}") + getShellAdapter(useRoot = true).execute("input keyevent ${KeyEvent.KEYCODE_POWER}") } else { service.doGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) } @@ -843,7 +844,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } } else { - result = suAdapter.execute("input keyevent ${KeyEvent.KEYCODE_POWER}") + result = + getShellAdapter(useRoot = true).execute("input keyevent ${KeyEvent.KEYCODE_POWER}") } } @@ -906,11 +908,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ShellCommand -> { - result = if (action.useRoot) { - suAdapter.execute(action.command) - } else { - shell.execute(action.command) - } + result = executeShellCommandUseCase.execute( + command = action.command, + useRoot = action.useRoot, + timeoutMs = action.timeoutMs.toLong(), + ) } is ActionData.InteractUiElement -> { @@ -1032,10 +1034,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( return service .doGlobalAction(AccessibilityService.GLOBAL_ACTION_DISMISS_NOTIFICATION_SHADE) } else { - return shell.execute("cmd statusbar collapse") + return getShellAdapter(useRoot = false).execute("cmd statusbar collapse") } } + private fun getShellAdapter(useRoot: Boolean): ShellAdapter { + return if (useRoot) suAdapter else shell + } + private fun KMResult<*>.showErrorMessageOnFail() { onFailure { toastAdapter.show(it.getFullMessage(resourceProvider)) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index d534339707..edd2737641 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -6,16 +6,24 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer 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.material.icons.rounded.PlayArrow +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox 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.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -30,160 +38,299 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily 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.common.utils.State +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success import kotlinx.coroutines.launch data class ShellCommandActionState( - val command: String, - val useRoot: Boolean, - val testResult: State? = null, + val description: String = "", + val command: String = "", + val useRoot: Boolean = false, + /** + * UI works with seconds for user-friendliness + */ + val timeoutSeconds: Int = 10, + val isRunning: Boolean = false, + val testResult: KMResult? = null, ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShellCommandActionScreen( + modifier: Modifier = Modifier, + viewModel: ConfigShellCommandViewModel +) { + ShellCommandActionScreen( + modifier = modifier, + state = viewModel.state, + onDescriptionChanged = viewModel::onDescriptionChanged, + onCommandChanged = viewModel::onCommandChanged, + onUseRootChanged = viewModel::onUseRootChanged, + onTimeoutChanged = viewModel::onTimeoutChanged, + onTestClick = viewModel::onTestClick, + onKillClick = viewModel::onKillClick, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShellCommandActionScreen( + modifier: Modifier = Modifier, state: ShellCommandActionState, - onCommandChanged: (String) -> Unit, - onUseRootChanged: (Boolean) -> Unit, - onTestClick: () -> Unit, - onDoneClick: () -> Unit, - onCancelClick: () -> Unit, + onDescriptionChanged: (String) -> Unit = {}, + onCommandChanged: (String) -> Unit = {}, + onUseRootChanged: (Boolean) -> Unit = {}, + onTimeoutChanged: (Int) -> Unit = {}, + onTestClick: () -> Unit = {}, + onKillClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, ) { val scrollState = rememberScrollState() val scope = rememberCoroutineScope() + var descriptionError: String? by rememberSaveable { mutableStateOf(null) } var commandError: String? by rememberSaveable { mutableStateOf(null) } + val descriptionEmptyErrorString = stringResource(R.string.error_cant_be_empty) val commandEmptyErrorString = stringResource(R.string.action_shell_command_command_empty_error) Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(R.string.action_shell_command_title)) }, ) }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .imePadding() - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = state.command, - onValueChange = { - commandError = null - onCommandChanged(it) + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + var hasError = false + + if (state.description.isBlank()) { + descriptionError = descriptionEmptyErrorString + hasError = true + } + + if (state.command.isBlank()) { + commandError = commandEmptyErrorString + hasError = true + } + + if (hasError) { + scope.launch { + scrollState.animateScrollTo(0) + } + } else { + onDoneClick() + } + }, + text = { Text(stringResource(R.string.pos_done)) }, + icon = { + Icon(Icons.Rounded.Check, stringResource(R.string.pos_done)) + }, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) }, - label = { Text(stringResource(R.string.action_shell_command_command_label)) }, - minLines = 3, - maxLines = 10, - isError = commandError != null, - supportingText = { - if (commandError != null) { - Text( - text = commandError!!, - color = MaterialTheme.colorScheme.error, - ) + actions = { + IconButton(onClick = onCancelClick) { + Icon(Icons.Rounded.Close, stringResource(R.string.neg_cancel)) } }, ) + }, + ) { padding -> + ShellCommandActionContent( + state = state, + descriptionError = descriptionError, + commandError = commandError, + onDescriptionChanged = { + descriptionError = null + onDescriptionChanged(it) + }, + onCommandChanged = { + commandError = null + onCommandChanged(it) + }, + onUseRootChanged = onUseRootChanged, + onTimeoutChanged = onTimeoutChanged, + onTestClick = { + if (state.command.isBlank()) { + commandError = commandEmptyErrorString + } else { + onTestClick() + } + }, + onKillClick = onKillClick, + modifier = Modifier.padding(padding), + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, +@Composable +private fun ShellCommandActionContent( + state: ShellCommandActionState, + descriptionError: String?, + commandError: String?, + onDescriptionChanged: (String) -> Unit, + onCommandChanged: (String) -> Unit, + onUseRootChanged: (Boolean) -> Unit, + onTimeoutChanged: (Int) -> Unit, + onTestClick: () -> Unit, + onKillClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.description, + onValueChange = onDescriptionChanged, + label = { Text(stringResource(R.string.hint_shell_command_description)) }, + singleLine = true, + isError = descriptionError != null, + supportingText = { + if (descriptionError != null) { + Text( + text = descriptionError, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.command, + onValueChange = onCommandChanged, + label = { Text(stringResource(R.string.action_shell_command_command_label)) }, + minLines = 3, + maxLines = 10, + isError = commandError != null, + supportingText = { + if (commandError != null) { + Text( + text = commandError, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + SliderOptionText( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.hint_shell_command_timeout), + defaultValue = 10f, + value = state.timeoutSeconds.toFloat(), + valueText = { "${it.toInt()}s" }, + onValueChange = { onTimeoutChanged(it.toInt()) }, + valueRange = 5f..60f, + stepSize = 5, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CheckBoxText( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_shell_command_use_root_label), + isChecked = state.useRoot, + onCheckedChange = onUseRootChanged, + ) + + Button( + onClick = onTestClick, + enabled = !state.isRunning, ) { - Checkbox( - checked = state.useRoot, - onCheckedChange = onUseRootChanged, - ) + Icon(Icons.Rounded.PlayArrow, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.action_shell_command_use_root_label)) + Text(stringResource(R.string.action_shell_command_test_button)) } + } - Button( + if (state.isRunning) { + OutlinedButton( modifier = Modifier.fillMaxWidth(), - onClick = { - if (state.command.isBlank()) { - commandError = commandEmptyErrorString - } else { - onTestClick() - } - }, + onClick = onKillClick, ) { - Text(stringResource(R.string.action_shell_command_test_button)) + Icon(Icons.Rounded.Close, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.pos_kill)) } + } + + if (state.isRunning) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.action_shell_command_testing), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - if (state.testResult != null) { + when (val result = state.testResult) { + null -> {} + is Success -> { Text( text = stringResource(R.string.action_shell_command_output_label), style = MaterialTheme.typography.titleMedium, ) - when (state.testResult) { - is State.Loading -> { - Text( - text = stringResource(R.string.action_shell_command_testing), - style = MaterialTheme.typography.bodyMedium, - ) - } - is State.Data -> { - SelectionContainer { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = state.testResult.data, - onValueChange = {}, - readOnly = true, - minLines = 5, - maxLines = 15, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } - } + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = result.value, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) } } - Spacer(modifier = Modifier.weight(1f)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = onCancelClick, - ) { - Text(stringResource(R.string.neg_cancel)) - } + is KMError -> { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = { - if (state.command.isBlank()) { - commandError = commandEmptyErrorString - } else { - onDoneClick() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } + Text( + text = result.getFullMessage(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) } - - Spacer(modifier = Modifier.height(16.dp)) } } } @@ -194,15 +341,11 @@ private fun PreviewShellCommandActionScreen() { KeyMapperTheme { ShellCommandActionScreen( state = ShellCommandActionState( + description = "Hello world script", command = "echo 'Hello World'", useRoot = false, - testResult = State.Data("Hello World\n"), + testResult = Success("Hello World\nNew line\nNew new line"), ), - onCommandChanged = {}, - onUseRootChanged = {}, - onTestClick = {}, - onDoneClick = {}, - onCancelClick = {}, ) } } @@ -213,14 +356,40 @@ private fun PreviewShellCommandActionScreenEmpty() { KeyMapperTheme { ShellCommandActionScreen( state = ShellCommandActionState( + description = "", command = "", useRoot = true, ), - onCommandChanged = {}, - onUseRootChanged = {}, - onTestClick = {}, - onDoneClick = {}, - onCancelClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + useRoot = false, + testResult = KMError.Exception(Exception("Permission denied")), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenTesting() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Count to 10", + command = "for i in \$(seq 1 10); do echo \"Line \$i\"; sleep 1; done", + useRoot = false, + isRunning = true, + ), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 0191702757..a647c43435 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -209,6 +209,10 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { KMError.InvalidBackup -> resourceProvider.getString(R.string.error_invalid_backup) KMError.MalformedUrl -> resourceProvider.getString(R.string.error_malformed_url) KMError.UiElementNotFound -> resourceProvider.getString(R.string.error_ui_element_not_found) + is KMError.ShellCommandTimeout -> resourceProvider.getString( + R.string.error_shell_command_timeout, + timeoutMs / 1000, + ) is SystemBridgeError.Disconnected -> resourceProvider.getString(R.string.error_system_bridge_disconnected) PurchasingError.PurchasingProcessError.Cancelled -> resourceProvider.getString( 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 a5cec53adf..fe1752e5b5 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 @@ -169,7 +169,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ConfigShellCommand(val action: ActionData.ShellCommand?) : + data class ConfigShellCommand(val actionJson: String?) : NavDestination(isCompose = true) { override val id: String = ID_SHELL_COMMAND_ACTION } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index a0949307b0..e018230393 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -471,6 +471,7 @@ Some actions and options need this permission to work. You can also get notified when there is important news about the app. Done + Kill Guide Guide Change @@ -863,6 +864,7 @@ Must be %d or less! UI element not found! + Command timed out after %1$d seconds PRO Mode needs starting @@ -1084,8 +1086,8 @@ Shell command Shell command - Command - Command cannot be empty! + Script + Script cannot be empty! Run as root Test Output @@ -1752,5 +1754,7 @@ Test SMS Sent successfully! + Description + Timeout diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt index fee514693d..5c01b813ae 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt @@ -90,6 +90,7 @@ abstract class KMError : KMResult() { data object UiElementNotFound : KMError() data class KeyEventActionError(val baseError: KMError) : KMError() + data class ShellCommandTimeout(val timeoutMs: Int) : KMError() } inline fun KMResult.onSuccess(f: (T) -> Unit): KMResult { 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 8ec538d82b..3b2ab89696 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 @@ -87,6 +87,8 @@ data class ActionEntity( const val EXTRA_HTTP_AUTHORIZATION_HEADER = "extra_http_authorization_header" const val EXTRA_SMS_MESSAGE = "extra_sms_message" 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" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index b7a433f287..237b3bfbf9 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -181,7 +181,6 @@ class AndroidPermissionAdapter @Inject constructor( } else if (isGranted(Permission.ROOT)) { suAdapter.execute( "pm grant ${buildConfigProvider.packageName} $permissionName", - block = true, ) if (ContextCompat.checkSelfPermission(ctx, permissionName) == PERMISSION_GRANTED) { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index 03f0d36d37..83e2facf3a 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.system.root +import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.Shell import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult @@ -7,8 +8,12 @@ import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.update import timber.log.Timber import javax.inject.Inject @@ -27,17 +32,13 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { invalidateIsRooted() } - override fun execute(command: String, block: Boolean): KMResult { + override fun execute(command: String): KMResult { if (!isRootGranted.firstBlocking()) { return SystemError.PermissionDenied(Permission.ROOT) } try { - if (block) { - Shell.cmd(command).exec() - } else { - Shell.cmd(command).submit() - } + Shell.cmd(command).exec() return Success(Unit) } catch (e: Exception) { @@ -63,6 +64,40 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } + override fun executeWithStreamingOutput(command: String): Flow> = + callbackFlow { + if (!isRootGranted.firstBlocking()) { + trySend(SystemError.PermissionDenied(Permission.ROOT)) + close() + return@callbackFlow + } + + try { + val outputLines = mutableListOf() + val errorLines = mutableListOf() + + Shell.cmd(command) + .to(object : CallbackList() { + override fun onAddElement(s: String) { + outputLines.add(s) + trySend(Success(outputLines.joinToString("\n"))) + } + }) + .to(errorLines) + .submit { result -> + if (!result.isSuccess && errorLines.isNotEmpty()) { + trySend(KMError.Exception(Exception(errorLines.joinToString("\n")))) + } + close() + } + + awaitClose { } + } catch (e: Exception) { + trySend(KMError.Exception(e)) + close() + } + } + fun invalidateIsRooted() { try { // Close the shell so a new one is started without root permission. @@ -81,10 +116,8 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } -interface SuAdapter { +interface SuAdapter : ShellAdapter { val isRootGranted: StateFlow fun requestPermission() - fun execute(command: String, block: Boolean = false): KMResult - fun executeWithOutput(command: String): KMResult } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt index d523ae62db..a5f5419fb0 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt @@ -1,9 +1,10 @@ package io.github.sds100.keymapper.system.shell import io.github.sds100.keymapper.common.utils.KMResult +import kotlinx.coroutines.flow.Flow interface ShellAdapter { - fun run(vararg command: String, waitFor: Boolean = false): Boolean - fun execute(command: String): KMResult<*> + fun execute(command: String): KMResult fun executeWithOutput(command: String): KMResult + fun executeWithStreamingOutput(command: String): Flow> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt index 7771765349..45f6e42829 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt @@ -3,30 +3,20 @@ package io.github.sds100.keymapper.system.shell import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @Singleton class SimpleShell @Inject constructor() : ShellAdapter { - /** - * @return whether the command was executed successfully - */ - override fun run(vararg command: String, waitFor: Boolean): Boolean = try { - val process = Runtime.getRuntime().exec(command) - if (waitFor) { - process.waitFor() - } - - true - } catch (e: IOException) { - false - } - - override fun execute(command: String): KMResult<*> { + override fun execute(command: String): KMResult { try { - Runtime.getRuntime().exec(command) + Runtime.getRuntime().exec(prepareCommand(command)) return Success(Unit) } catch (e: IOException) { @@ -36,7 +26,7 @@ class SimpleShell @Inject constructor() : ShellAdapter { override fun executeWithOutput(command: String): KMResult { try { - val process = Runtime.getRuntime().exec(command) + val process = Runtime.getRuntime().exec(prepareCommand(command)) process.waitFor() @@ -56,4 +46,42 @@ class SimpleShell @Inject constructor() : ShellAdapter { return KMError.Exception(e) } } + + override fun executeWithStreamingOutput(command: String): Flow> { + return flow { + try { + val process = Runtime.getRuntime().exec(prepareCommand(command)) + val outputReader = process.inputStream.bufferedReader() + val errorReader = process.errorStream.bufferedReader() + val outputLines = mutableListOf() + + // Read output line by line + var line: String? + while (outputReader.readLine().also { line = it } != null) { + outputLines.add(line!!) + emit(Success(outputLines.joinToString("\n"))) + } + + process.waitFor() + + // Check for errors after process completes + if (process.exitValue() != 0) { + val errorLines = errorReader.readLines() + if (errorLines.isNotEmpty()) { + emit(KMError.Exception(IOException(errorLines.joinToString("\n")))) + } + } + + outputReader.close() + errorReader.close() + } catch (e: IOException) { + emit(KMError.Exception(e)) + } + }.flowOn(Dispatchers.IO) + } + + private fun prepareCommand(command: String): Array { + // Execute through sh -c to properly handle multi-line commands and shell syntax + return arrayOf("sh", "-c", command) + } } \ No newline at end of file From 72faecb6311f584ac327d62f29e1da49417174f5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 19 Oct 2025 22:49:35 +0200 Subject: [PATCH 08/14] #661 refactor shell adapters to return a ShellResult class and tweak the ShellCommandActionScreen --- .../keymapper/base/actions/ActionData.kt | 4 +- .../base/actions/ActionDataEntityMapper.kt | 4 +- .../actions/ConfigShellCommandViewModel.kt | 5 +- .../actions/ExecuteShellCommandUseCase.kt | 9 +- .../base/actions/PerformActionsUseCase.kt | 2 +- .../base/actions/ShellCommandActionScreen.kt | 158 ++++++++++++++---- base/src/main/res/values/strings.xml | 3 +- .../sds100/keymapper/system/root/SuAdapter.kt | 25 ++- .../keymapper/system/shell/ShellAdapter.kt | 4 +- .../keymapper/system/shell/ShellResult.kt | 29 ++++ .../keymapper/system/shell/SimpleShell.kt | 42 +++-- 11 files changed, 212 insertions(+), 73 deletions(-) create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt 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 9704b00014..1c5eb74702 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 @@ -937,13 +937,13 @@ sealed class ActionData : Comparable { val description: String, val command: String, val useRoot: Boolean, - val timeoutMs: Int = 10000, // milliseconds (default 10 seconds) + val timeoutMillis: Int = 10000, // milliseconds (default 10 seconds) ) : ActionData() { override val id: ActionId = ActionId.SHELL_COMMAND override fun toString(): String { // Do not leak sensitive command info to logs. - return "ShellCommand(description=$description, useRoot=$useRoot, timeoutMs=$timeoutMs)" + return "ShellCommand(description=$description, useRoot=$useRoot, timeoutMs=$timeoutMillis)" } } 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 7ff8e279fb..5cb9b5d651 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 @@ -674,7 +674,7 @@ object ActionDataEntityMapper { description = description, command = command, useRoot = useRoot, - timeoutMs = timeoutMs, + timeoutMillis = timeoutMs, ) } @@ -1026,7 +1026,7 @@ object ActionDataEntityMapper { is ActionData.ShellCommand -> listOf( EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION, data.description), - EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMs.toString()), + EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), ) else -> emptyList() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index c7aede688a..db28091068 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -32,7 +32,7 @@ class ConfigShellCommandViewModel @Inject constructor( description = action.description, command = action.command, useRoot = action.useRoot, - timeoutSeconds = action.timeoutMs / 1000, + timeoutSeconds = action.timeoutMillis / 1000, ) } @@ -53,7 +53,6 @@ class ConfigShellCommandViewModel @Inject constructor( } fun onTestClick() { - // Cancel any existing test testJob?.cancel() state = state.copy( @@ -102,7 +101,7 @@ class ConfigShellCommandViewModel @Inject constructor( description = state.description, command = state.command, useRoot = state.useRoot, - timeoutMs = state.timeoutSeconds * 1000, + timeoutMillis = state.timeoutSeconds * 1000, ) viewModelScope.launch { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt index ae230a8be4..ca512fd905 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter +import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withTimeout @@ -16,10 +17,10 @@ class ExecuteShellCommandUseCase @Inject constructor( suspend fun execute( command: String, useRoot: Boolean, - timeoutMs: Long, + timeoutMillis: Long, ): KMResult { return try { - withTimeout(timeoutMs) { + withTimeout(timeoutMillis) { if (useRoot) { suAdapter.execute(command) } else { @@ -27,14 +28,14 @@ class ExecuteShellCommandUseCase @Inject constructor( } } } catch (e: TimeoutCancellationException) { - KMError.ShellCommandTimeout(timeoutMs.toInt()) + KMError.ShellCommandTimeout(timeoutMillis.toInt()) } } fun executeWithStreamingOutput( command: String, useRoot: Boolean, - ): Flow> { + ): Flow> { return if (useRoot) { suAdapter.executeWithStreamingOutput(command) } else { 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 4445131924..36c341d780 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 @@ -911,7 +911,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = executeShellCommandUseCase.execute( command = action.command, useRoot = action.useRoot, - timeoutMs = action.timeoutMs.toLong(), + timeoutMillis = action.timeoutMillis.toLong(), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index edd2737641..9ea646b4bc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -4,9 +4,11 @@ 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.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -18,9 +20,11 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -39,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview @@ -51,6 +56,9 @@ import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.SystemError +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.launch data class ShellCommandActionState( @@ -62,7 +70,7 @@ data class ShellCommandActionState( */ val timeoutSeconds: Int = 10, val isRunning: Boolean = false, - val testResult: KMResult? = null, + val testResult: KMResult? = null, ) @Composable @@ -152,8 +160,21 @@ private fun ShellCommandActionScreen( }, ) }, - ) { padding -> + ) { innerPadding -> + + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + ShellCommandActionContent( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), state = state, descriptionError = descriptionError, commandError = commandError, @@ -175,13 +196,13 @@ private fun ShellCommandActionScreen( } }, onKillClick = onKillClick, - modifier = Modifier.padding(padding), ) } } @Composable private fun ShellCommandActionContent( + modifier: Modifier = Modifier, state: ShellCommandActionState, descriptionError: String?, commandError: String?, @@ -191,17 +212,13 @@ private fun ShellCommandActionContent( onTimeoutChanged: (Int) -> Unit, onTestClick: () -> Unit, onKillClick: () -> Unit, - modifier: Modifier = Modifier, ) { - val scrollState = rememberScrollState() val context = LocalContext.current Column( modifier = modifier - .fillMaxSize() - .imePadding() - .verticalScroll(scrollState) - .padding(16.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( @@ -237,6 +254,9 @@ private fun ShellCommandActionContent( ) } }, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), ) SliderOptionText( @@ -268,7 +288,13 @@ private fun ShellCommandActionContent( ) { Icon(Icons.Rounded.PlayArrow, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.action_shell_command_test_button)) + Text( + if (state.isRunning) { + stringResource(R.string.action_shell_command_testing) + } else { + stringResource(R.string.action_shell_command_test_button) + } + ) } } @@ -276,6 +302,9 @@ private fun ShellCommandActionContent( OutlinedButton( modifier = Modifier.fillMaxWidth(), onClick = onKillClick, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Icon(Icons.Rounded.Close, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) @@ -287,35 +316,73 @@ private fun ShellCommandActionContent( LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), ) + } - Text( - text = stringResource(R.string.action_shell_command_testing), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.testResult != null) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.outlineVariant, ) } when (val result = state.testResult) { null -> {} is Success -> { - Text( - text = stringResource(R.string.action_shell_command_output_label), - style = MaterialTheme.typography.titleMedium, - ) + when (val shellResult = result.value) { + is ShellResult.Success -> { + Text( + text = stringResource(R.string.action_shell_command_output_label), + style = MaterialTheme.typography.titleMedium, + ) - SelectionContainer { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = result.value, - onValueChange = {}, - readOnly = true, - minLines = 5, - maxLines = 15, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - ) + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = shellResult.stdout, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + + } + + is ShellResult.Error -> { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = shellResult.stderr, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + isError = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } } + + Text( + text = stringResource( + R.string.action_shell_command_exit_code, + result.value.exitCode + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } is KMError -> { @@ -333,6 +400,8 @@ private fun ShellCommandActionContent( } } } + + Spacer(modifier = Modifier.height(16.dp)) } @Preview @@ -344,7 +413,7 @@ private fun PreviewShellCommandActionScreen() { description = "Hello world script", command = "echo 'Hello World'", useRoot = false, - testResult = Success("Hello World\nNew line\nNew new line"), + testResult = Success(ShellResult.Success("Hello World\nNew line\nNew new line")), ), ) } @@ -372,8 +441,28 @@ private fun PreviewShellCommandActionScreenError() { state = ShellCommandActionState( description = "Read secret file", command = "cat /root/secret.txt", - useRoot = false, - testResult = KMError.Exception(Exception("Permission denied")), + useRoot = true, + testResult = SystemError.PermissionDenied(Permission.ROOT), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenShellError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "", + command = "ls", + useRoot = true, + testResult = Success( + ShellResult.Error( + stderr = "ls: .: Permission denied", + exitCode = 1 + ) + ), ), ) } @@ -389,6 +478,7 @@ private fun PreviewShellCommandActionScreenTesting() { command = "for i in \$(seq 1 10); do echo \"Line \$i\"; sleep 1; done", useRoot = false, isRunning = true, + testResult = Success(ShellResult.Success("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")), ), ) } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index e018230393..d15d34f5f7 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1085,7 +1085,7 @@ You must prepend \'Bearer\' if necessary Shell command - Shell command + Shell command action Script Script cannot be empty! Run as root @@ -1094,6 +1094,7 @@ Executing… Success Failed + Exit code: %1$d su -c %s Interact with app element diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index 83e2facf3a..c26905e2d6 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -9,6 +9,7 @@ import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.shell.ShellAdapter +import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -46,7 +47,7 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } - override fun executeWithOutput(command: String): KMResult { + override fun executeWithOutput(command: String): KMResult { if (!isRootGranted.firstBlocking()) { return SystemError.PermissionDenied(Permission.ROOT) } @@ -54,17 +55,21 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { try { val result = Shell.cmd(command).exec() + val output = result.out.joinToString("\n") + val stderr = result.err.joinToString("\n") + val exitCode = result.code + return if (result.isSuccess) { - Success(result.out.joinToString("\n")) + Success(ShellResult.Success(output, exitCode)) } else { - KMError.Exception(Exception(result.err.joinToString("\n"))) + Success(ShellResult.Error(stderr, exitCode)) } } catch (e: Exception) { return KMError.Exception(e) } } - override fun executeWithStreamingOutput(command: String): Flow> = + override fun executeWithStreamingOutput(command: String): Flow> = callbackFlow { if (!isRootGranted.firstBlocking()) { trySend(SystemError.PermissionDenied(Permission.ROOT)) @@ -80,13 +85,19 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { .to(object : CallbackList() { override fun onAddElement(s: String) { outputLines.add(s) - trySend(Success(outputLines.joinToString("\n"))) + trySend(Success(ShellResult.Success(outputLines.joinToString("\n")))) } }) .to(errorLines) .submit { result -> - if (!result.isSuccess && errorLines.isNotEmpty()) { - trySend(KMError.Exception(Exception(errorLines.joinToString("\n")))) + val output = outputLines.joinToString("\n") + val stderr = errorLines.joinToString("\n") + val exitCode = result.code + + if (result.isSuccess) { + trySend(Success(ShellResult.Success(output, exitCode))) + } else { + trySend(Success(ShellResult.Error(stderr, exitCode))) } close() } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt index a5f5419fb0..72aac53c3c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt @@ -5,6 +5,6 @@ import kotlinx.coroutines.flow.Flow interface ShellAdapter { fun execute(command: String): KMResult - fun executeWithOutput(command: String): KMResult - fun executeWithStreamingOutput(command: String): Flow> + fun executeWithOutput(command: String): KMResult + fun executeWithStreamingOutput(command: String): Flow> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt new file mode 100644 index 0000000000..3477014ea1 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt @@ -0,0 +1,29 @@ +package io.github.sds100.keymapper.system.shell + +/** + * Represents the result of a shell command execution. + * Contains both stdout and stderr output along with exit code information. + */ +sealed class ShellResult { + abstract val exitCode: Int + + /** + * Successful shell command execution. + * @param stdout The stdout output from the command + * @param exitCode The exit code of the command (0 typically means success) + */ + data class Success( + val stdout: String, + override val exitCode: Int = 0 + ) : ShellResult() + + /** + * Failed shell command execution. + * @param stderr The stderr output from the command + * @param exitCode The exit code of the command (non-zero typically means failure) + */ + data class Error( + val stderr: String, + override val exitCode: Int + ) : ShellResult() +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt index 45f6e42829..05201aa523 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt @@ -24,30 +24,34 @@ class SimpleShell @Inject constructor() : ShellAdapter { } } - override fun executeWithOutput(command: String): KMResult { + override fun executeWithOutput(command: String): KMResult { try { val process = Runtime.getRuntime().exec(prepareCommand(command)) process.waitFor() - if (process.exitValue() != 0) { - val errorLines = with(process.errorStream.bufferedReader()) { - readLines() - } - return KMError.Exception(IOException(errorLines.joinToString("\n"))) + val outputLines = with(process.inputStream.bufferedReader()) { + readLines() } - - val lines = with(process.inputStream.bufferedReader()) { + val errorLines = with(process.errorStream.bufferedReader()) { readLines() } - return Success(lines.joinToString("\n")) + val output = outputLines.joinToString("\n") + val stderr = errorLines.joinToString("\n") + val exitCode = process.exitValue() + + return if (exitCode == 0) { + Success(ShellResult.Success(output, exitCode)) + } else { + Success(ShellResult.Error(stderr, exitCode)) + } } catch (e: IOException) { return KMError.Exception(e) } } - override fun executeWithStreamingOutput(command: String): Flow> { + override fun executeWithStreamingOutput(command: String): Flow> { return flow { try { val process = Runtime.getRuntime().exec(prepareCommand(command)) @@ -59,17 +63,21 @@ class SimpleShell @Inject constructor() : ShellAdapter { var line: String? while (outputReader.readLine().also { line = it } != null) { outputLines.add(line!!) - emit(Success(outputLines.joinToString("\n"))) + emit(Success(ShellResult.Success(outputLines.joinToString("\n")))) } process.waitFor() - // Check for errors after process completes - if (process.exitValue() != 0) { - val errorLines = errorReader.readLines() - if (errorLines.isNotEmpty()) { - emit(KMError.Exception(IOException(errorLines.joinToString("\n")))) - } + // Read stderr after process completes + val errorLines = errorReader.readLines() + val stderr = errorLines.joinToString("\n") + val exitCode = process.exitValue() + + // Emit final result based on exit code + if (exitCode == 0) { + emit(Success(ShellResult.Success(outputLines.joinToString("\n"), exitCode))) + } else { + emit(Success(ShellResult.Error(stderr, exitCode))) } outputReader.close() From be0b2268d0580564d1db58b710bb7f48f2e56722 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 19 Oct 2025 23:13:04 +0200 Subject: [PATCH 09/14] #661 refactor ShellResult to be a simple data class and use it in SystemBridge --- app/version.properties | 2 +- .../actions/ExecuteShellCommandUseCase.kt | 2 +- .../base/actions/ShellCommandActionScreen.kt | 88 +++++++++---------- .../keyevent/FixKeyEventActionBottomSheet.kt | 2 +- .../keyevent/FixKeyEventActionDelegate.kt | 2 +- .../keyevent/FixKeyEventActionState.kt | 2 +- .../base/trigger/TriggerSetupBottomSheet.kt | 1 + .../base/trigger/TriggerSetupDelegate.kt | 1 + .../base/trigger/TriggerSetupState.kt | 1 + .../base/{trigger => utils}/ProModeStatus.kt | 4 +- .../utils/ui/compose/SetupRequirementRow.kt | 2 +- .../keymapper/common/models/ShellResult.kt | 22 +++++ .../keymapper/common/models/ShellResult.aidl | 3 + .../keymapper/sysbridge/ISystemBridge.aidl | 3 +- .../manager/SystemBridgeConnectionManager.kt | 9 +- .../sysbridge/service/SystemBridge.kt | 29 +++--- .../sds100/keymapper/system/root/SuAdapter.kt | 16 +--- .../keymapper/system/shell/ShellAdapter.kt | 1 + .../keymapper/system/shell/ShellResult.kt | 29 ------ .../keymapper/system/shell/SimpleShell.kt | 17 ++-- 20 files changed, 117 insertions(+), 119 deletions(-) rename base/src/main/java/io/github/sds100/keymapper/base/{trigger => utils}/ProModeStatus.kt (59%) create mode 100644 common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt create mode 100644 sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl delete mode 100644 system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt diff --git a/app/version.properties b/app/version.properties index 2be8b7d87e..5f3e886450 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=4.0.0-beta.1 -VERSION_CODE=169 +VERSION_CODE=170 VERSION_NUM=01 \ No newline at end of file diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt index ca512fd905..9fb5aa1b77 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -1,10 +1,10 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter -import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withTimeout diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index 9ea646b4bc..9a7842219f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -53,12 +53,13 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.models.isSuccess import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.launch data class ShellCommandActionState( @@ -328,50 +329,46 @@ private fun ShellCommandActionContent( when (val result = state.testResult) { null -> {} is Success -> { - when (val shellResult = result.value) { - is ShellResult.Success -> { - Text( - text = stringResource(R.string.action_shell_command_output_label), - style = MaterialTheme.typography.titleMedium, - ) - - SelectionContainer { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = shellResult.stdout, - onValueChange = {}, - readOnly = true, - minLines = 5, - maxLines = 15, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } + val shellResult = result.value + if (shellResult.isSuccess()) { + Text( + text = stringResource(R.string.action_shell_command_output_label), + style = MaterialTheme.typography.titleMedium, + ) + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = shellResult.stdOut, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) } + } else { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) - is ShellResult.Error -> { - Text( - text = stringResource(R.string.action_shell_command_test_failed), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, + SelectionContainer { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = shellResult.stdErr, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + isError = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), ) - - SelectionContainer { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = shellResult.stderr, - onValueChange = {}, - readOnly = true, - minLines = 5, - maxLines = 15, - isError = true, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } } } @@ -413,7 +410,7 @@ private fun PreviewShellCommandActionScreen() { description = "Hello world script", command = "echo 'Hello World'", useRoot = false, - testResult = Success(ShellResult.Success("Hello World\nNew line\nNew new line")), + testResult = Success(ShellResult("Hello World\nNew line\nNew new line", "", 0)), ), ) } @@ -458,8 +455,9 @@ private fun PreviewShellCommandActionScreenShellError() { command = "ls", useRoot = true, testResult = Success( - ShellResult.Error( - stderr = "ls: .: Permission denied", + ShellResult( + stdOut = "", + stdErr = "ls: .: Permission denied", exitCode = 1 ) ), @@ -478,7 +476,7 @@ private fun PreviewShellCommandActionScreenTesting() { command = "for i in \$(seq 1 10); do echo \"Line \$i\"; sleep 1; done", useRoot = false, isRunning = true, - testResult = Success(ShellResult.Success("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")), + testResult = Success(ShellResult("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", "", 0)), ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt index d206f158c1..b9c90114b6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt @@ -46,7 +46,7 @@ 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.compose.LocalCustomColorsPalette -import io.github.sds100.keymapper.base.trigger.ProModeStatus +import io.github.sds100.keymapper.base.utils.ProModeStatus import io.github.sds100.keymapper.base.utils.ui.compose.AccessibilityServiceRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt index e4eacb79bb..a2049e04cd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt @@ -4,8 +4,8 @@ import android.os.Build import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCase -import io.github.sds100.keymapper.base.trigger.ProModeStatus import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCase +import io.github.sds100.keymapper.base.utils.ProModeStatus import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt index 18f10a3cfb..5bacfc2db5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt @@ -1,6 +1,6 @@ package io.github.sds100.keymapper.base.actions.keyevent -import io.github.sds100.keymapper.base.trigger.ProModeStatus +import io.github.sds100.keymapper.base.utils.ProModeStatus sealed class FixKeyEventActionState { abstract val isAccessibilityServiceEnabled: Boolean diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt index c2d0662c61..98e417dff1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt @@ -54,6 +54,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus import io.github.sds100.keymapper.base.utils.ui.compose.AccessibilityServiceRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt index 76da0c94f7..bafbe0d640 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt @@ -4,6 +4,7 @@ import android.os.Build import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt index 95e056c65c..220b48192e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.trigger import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus sealed class TriggerSetupState { data class Volume( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeStatus.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt similarity index 59% rename from base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeStatus.kt rename to base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt index 7eec366dfe..b121b7c51e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeStatus.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt @@ -1,7 +1,7 @@ -package io.github.sds100.keymapper.base.trigger +package io.github.sds100.keymapper.base.utils enum class ProModeStatus { UNSUPPORTED, DISABLED, ENABLED, -} +} \ No newline at end of file diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt index 7afc8ebfbe..1f0706fcdc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.trigger.ProModeStatus +import io.github.sds100.keymapper.base.utils.ProModeStatus @Composable fun ProModeRequirementRow( diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt new file mode 100644 index 0000000000..c36f320f5d --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt @@ -0,0 +1,22 @@ +package io.github.sds100.keymapper.common.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents the result of a shell command execution. + * Contains both stdout and stderr output along with exit code information. + * + * @param stdOut The stdout output from the command + * @param stdErr The stderr output from the command + * @param exitCode The exit code of the command (0 typically means success) + */ +@Parcelize +data class ShellResult( + val stdOut: String, + val stdErr: String, + val exitCode: Int +) : Parcelable + +fun ShellResult.isSuccess(): Boolean = exitCode == 0 +fun ShellResult.isError(): Boolean = exitCode != 0 diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl new file mode 100644 index 0000000000..3c2ce000c6 --- /dev/null +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.common.models; + +parcelable ShellResult; diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 6f44ec255a..05f6f75c85 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -2,13 +2,14 @@ package io.github.sds100.keymapper.sysbridge; import io.github.sds100.keymapper.sysbridge.IEvdevCallback; import io.github.sds100.keymapper.common.models.EvdevDeviceHandle; +import io.github.sds100.keymapper.common.models.ShellResult; import android.view.InputEvent; interface ISystemBridge { void destroy() = 16777114; int getProcessUid() = 16777113; int getVersionCode() = 16777112; - String executeCommand(String command) = 16777111; + ShellResult executeCommand(String command) = 16777111; boolean grabEvdevDevice(String devicePath) = 1; boolean grabEvdevDeviceArray(in String[] devicePath) = 2; diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 39548be3a4..d9a4a6add5 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -16,12 +16,12 @@ import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.isSuccess import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.onFailure -import io.github.sds100.keymapper.common.utils.success import io.github.sds100.keymapper.sysbridge.ISystemBridge import io.github.sds100.keymapper.sysbridge.ktx.TAG import io.github.sds100.keymapper.sysbridge.starter.SystemBridgeStarter @@ -130,7 +130,12 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( private suspend fun restartSystemBridge(systemBridge: ISystemBridge) { starter.startSystemBridge(executeCommand = { command -> try { - systemBridge.executeCommand(command)!!.success() + val result = systemBridge.executeCommand(command)!! + if (result.isSuccess()) { + Success(result.stdOut) + } else { + KMError.Exception(Exception("Command failed with exit code ${result.exitCode}: ${result.stdErr}")) + } } catch (_: DeadObjectException) { // This exception is expected since it is killing the system bridge Success("") diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 4cfdcdf992..293dec5278 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -29,6 +29,7 @@ import android.util.Log import android.view.InputEvent import com.android.internal.telephony.ITelephony import io.github.sds100.keymapper.common.models.EvdevDeviceHandle +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.UserHandleUtils import io.github.sds100.keymapper.sysbridge.IEvdevCallback import io.github.sds100.keymapper.sysbridge.ISystemBridge @@ -508,24 +509,32 @@ internal class SystemBridge : ISystemBridge.Stub() { return false } - override fun executeCommand(command: String?): String { + override fun executeCommand(command: String?): ShellResult { command ?: throw IllegalArgumentException("command is null") Log.i(TAG, "Executing command: $command") - val process = Runtime.getRuntime().exec(command) + try { + val process = Runtime.getRuntime().exec(command) - val out = with(process.inputStream.bufferedReader()) { - readText() - } + process.waitFor() - val err = with(process.errorStream.bufferedReader()) { - readText() - } + val outputLines = with(process.inputStream.bufferedReader()) { + readLines() + } + val errorLines = with(process.errorStream.bufferedReader()) { + readLines() + } - process.waitFor() + val output = outputLines.joinToString("\n") + val stderr = errorLines.joinToString("\n") + val exitCode = process.exitValue() - return "$out\n$err" + return ShellResult(output, stderr, exitCode) + } catch (e: Exception) { + Log.e(TAG, "Error executing command: $command", e) + return ShellResult("", e.message ?: "Unknown error", -1) + } } override fun getVersionCode(): Int { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index c26905e2d6..c74f1c64d2 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.system.root import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.Shell +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -9,7 +10,6 @@ import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.shell.ShellAdapter -import io.github.sds100.keymapper.system.shell.ShellResult import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -59,11 +59,7 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { val stderr = result.err.joinToString("\n") val exitCode = result.code - return if (result.isSuccess) { - Success(ShellResult.Success(output, exitCode)) - } else { - Success(ShellResult.Error(stderr, exitCode)) - } + return Success(ShellResult(output, stderr, exitCode)) } catch (e: Exception) { return KMError.Exception(e) } @@ -85,7 +81,7 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { .to(object : CallbackList() { override fun onAddElement(s: String) { outputLines.add(s) - trySend(Success(ShellResult.Success(outputLines.joinToString("\n")))) + trySend(Success(ShellResult(outputLines.joinToString("\n"), "", 0))) } }) .to(errorLines) @@ -94,11 +90,7 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { val stderr = errorLines.joinToString("\n") val exitCode = result.code - if (result.isSuccess) { - trySend(Success(ShellResult.Success(output, exitCode))) - } else { - trySend(Success(ShellResult.Error(stderr, exitCode))) - } + trySend(Success(ShellResult(output, stderr, exitCode))) close() } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt index 72aac53c3c..830325865f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.system.shell +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMResult import kotlinx.coroutines.flow.Flow diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt deleted file mode 100644 index 3477014ea1..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellResult.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.sds100.keymapper.system.shell - -/** - * Represents the result of a shell command execution. - * Contains both stdout and stderr output along with exit code information. - */ -sealed class ShellResult { - abstract val exitCode: Int - - /** - * Successful shell command execution. - * @param stdout The stdout output from the command - * @param exitCode The exit code of the command (0 typically means success) - */ - data class Success( - val stdout: String, - override val exitCode: Int = 0 - ) : ShellResult() - - /** - * Failed shell command execution. - * @param stderr The stderr output from the command - * @param exitCode The exit code of the command (non-zero typically means failure) - */ - data class Error( - val stderr: String, - override val exitCode: Int - ) : ShellResult() -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt index 05201aa523..18fd925c05 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.system.shell +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -41,11 +42,7 @@ class SimpleShell @Inject constructor() : ShellAdapter { val stderr = errorLines.joinToString("\n") val exitCode = process.exitValue() - return if (exitCode == 0) { - Success(ShellResult.Success(output, exitCode)) - } else { - Success(ShellResult.Error(stderr, exitCode)) - } + return Success(ShellResult(output, stderr, exitCode)) } catch (e: IOException) { return KMError.Exception(e) } @@ -63,7 +60,7 @@ class SimpleShell @Inject constructor() : ShellAdapter { var line: String? while (outputReader.readLine().also { line = it } != null) { outputLines.add(line!!) - emit(Success(ShellResult.Success(outputLines.joinToString("\n")))) + emit(Success(ShellResult(outputLines.joinToString("\n"), "", 0))) } process.waitFor() @@ -73,12 +70,8 @@ class SimpleShell @Inject constructor() : ShellAdapter { val stderr = errorLines.joinToString("\n") val exitCode = process.exitValue() - // Emit final result based on exit code - if (exitCode == 0) { - emit(Success(ShellResult.Success(outputLines.joinToString("\n"), exitCode))) - } else { - emit(Success(ShellResult.Error(stderr, exitCode))) - } + // Emit final result with both stdout and stderr + emit(Success(ShellResult(outputLines.joinToString("\n"), stderr, exitCode))) outputReader.close() errorReader.close() From b3249f69c5e4bf6e05e9d8d5d33b8697dac8e31e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 19 Oct 2025 23:39:33 +0200 Subject: [PATCH 10/14] #661 add ADB as execution mode for shell command action that uses pro mode --- .../keymapper/base/actions/ActionData.kt | 5 +- .../base/actions/ActionDataEntityMapper.kt | 27 +++++- .../base/actions/ActionErrorSnapshot.kt | 23 +++++ .../keymapper/base/actions/ActionUiHelper.kt | 17 +++- .../actions/ConfigShellCommandViewModel.kt | 37 +++++++- .../actions/ExecuteShellCommandUseCase.kt | 50 +++++++--- .../base/actions/PerformActionsUseCase.kt | 2 +- .../base/actions/ShellCommandActionScreen.kt | 95 +++++++++++++++---- base/src/main/res/values/strings.xml | 8 ++ .../common/models/ShellExecutionMode.kt | 21 ++++ .../keymapper/data/entities/ActionEntity.kt | 1 + 11 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt 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 1c5eb74702..f45997f20d 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType @@ -936,14 +937,14 @@ sealed class ActionData : Comparable { data class ShellCommand( val description: String, val command: String, - val useRoot: Boolean, + val executionMode: ShellExecutionMode, val timeoutMillis: Int = 10000, // milliseconds (default 10 seconds) ) : ActionData() { override val id: ActionId = ActionId.SHELL_COMMAND override fun toString(): String { // Do not leak sensitive command info to logs. - return "ShellCommand(description=$description, useRoot=$useRoot, timeoutMs=$timeoutMillis)" + return "ShellCommand(description=$description, executionMode=$executionMode, timeoutMs=$timeoutMillis)" } } 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 5cb9b5d651..d81bfb4553 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 @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.actions import android.util.Base64 import androidx.core.net.toUri +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.NodeInteractionType @@ -656,6 +657,14 @@ object ActionDataEntityMapper { ActionId.SHELL_COMMAND -> { val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT) + val useAdb = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB) + + val executionMode = when { + useAdb -> ShellExecutionMode.ADB + useRoot -> ShellExecutionMode.ROOT + else -> ShellExecutionMode.STANDARD + } + val description = entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION) .valueOrNull() ?: return null @@ -673,7 +682,7 @@ object ActionDataEntityMapper { ActionData.ShellCommand( description = description, command = command, - useRoot = useRoot, + executionMode = executionMode, timeoutMillis = timeoutMs, ) } @@ -734,8 +743,20 @@ object ActionDataEntityMapper { flags = flags or ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI } - if (data is ActionData.ShellCommand && data.useRoot) { - flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT + if (data is ActionData.ShellCommand) { + when (data.executionMode) { + ShellExecutionMode.ROOT -> { + flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT + } + + ShellExecutionMode.ADB -> { + flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB + } + + ShellExecutionMode.STANDARD -> { + // No flag needed for standard mode + } + } } return flags 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 22cf993862..e4fd004303 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 @@ -5,6 +5,7 @@ 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 import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.firstBlocking @@ -202,6 +203,28 @@ class LazyActionErrorSnapshot( return it } + is ActionData.ShellCommand -> { + return when (action.executionMode) { + ShellExecutionMode.ROOT -> { + if (!isPermissionGranted(Permission.ROOT)) { + SystemError.PermissionDenied(Permission.ROOT) + } else { + null + } + } + + ShellExecutionMode.ADB -> { + if (!isSystemBridgeConnected) { + SystemBridgeError.Disconnected + } else { + null + } + } + + ShellExecutionMode.STANDARD -> null + } + } + else -> {} } 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 b6b7b10ca0..e2030a0f58 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 @@ -14,6 +14,7 @@ import io.github.sds100.keymapper.base.utils.ui.IconInfo import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType @@ -572,10 +573,18 @@ class ActionUiHelper( ActionData.DeviceControls -> getString(R.string.action_device_controls) is ActionData.HttpRequest -> action.description - is ActionData.ShellCommand -> if (action.useRoot) { - getString(R.string.action_shell_command_description_with_root, action.command) - } else { - action.command + is ActionData.ShellCommand -> when (action.executionMode) { + ShellExecutionMode.ROOT -> getString( + R.string.action_shell_command_description_with_root, + action.command + ) + + ShellExecutionMode.ADB -> getString( + R.string.action_shell_command_description_with_adb, + action.command + ) + + ShellExecutionMode.STANDARD -> action.command } is ActionData.InteractUiElement -> action.description diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index db28091068..fde3a371e6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -6,11 +6,17 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -20,6 +26,7 @@ import javax.inject.Inject class ConfigShellCommandViewModel @Inject constructor( private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : ViewModel() { var state: ShellCommandActionState by mutableStateOf(ShellCommandActionState()) @@ -27,11 +34,25 @@ class ConfigShellCommandViewModel @Inject constructor( private var testJob: Job? = null + init { + // Update ProModeStatus in state + viewModelScope.launch { + systemBridgeConnectionManager.connectionState.map { connectionState -> + when (connectionState) { + is io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED + is io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + } + }.collect { proModeStatus -> + state = state.copy(proModeStatus = proModeStatus) + } + } + } + fun loadAction(action: ActionData.ShellCommand) { state = state.copy( description = action.description, command = action.command, - useRoot = action.useRoot, + executionMode = action.executionMode, timeoutSeconds = action.timeoutMillis / 1000, ) } @@ -44,8 +65,8 @@ class ConfigShellCommandViewModel @Inject constructor( state = state.copy(command = newCommand) } - fun onUseRootChanged(newUseRoot: Boolean) { - state = state.copy(useRoot = newUseRoot) + fun onExecutionModeChanged(newExecutionMode: ShellExecutionMode) { + state = state.copy(executionMode = newExecutionMode) } fun onTimeoutChanged(newTimeoutSeconds: Int) { @@ -65,7 +86,7 @@ class ConfigShellCommandViewModel @Inject constructor( withTimeout(state.timeoutSeconds * 1000L) { val flow = executeShellCommandUseCase.executeWithStreamingOutput( command = state.command, - useRoot = state.useRoot, + executionMode = state.executionMode, ) flow.catch { e -> @@ -100,7 +121,7 @@ class ConfigShellCommandViewModel @Inject constructor( val action = ActionData.ShellCommand( description = state.description, command = state.command, - useRoot = state.useRoot, + executionMode = state.executionMode, timeoutMillis = state.timeoutSeconds * 1000, ) @@ -114,4 +135,10 @@ class ConfigShellCommandViewModel @Inject constructor( navigationProvider.popBackStack() } } + + fun onSetupProModeClick() { + viewModelScope.launch { + navigationProvider.navigate("shell_command_setup_pro_mode", NavDestination.ProModeSetup) + } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt index 9fb5aa1b77..71a259eb46 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -1,30 +1,43 @@ package io.github.sds100.keymapper.base.actions +import android.os.Build +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withTimeout import javax.inject.Inject class ExecuteShellCommandUseCase @Inject constructor( private val shellAdapter: ShellAdapter, private val suAdapter: SuAdapter, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) { suspend fun execute( command: String, - useRoot: Boolean, + executionMode: ShellExecutionMode, timeoutMillis: Long, - ): KMResult { + ): KMResult { return try { withTimeout(timeoutMillis) { - if (useRoot) { - suAdapter.execute(command) - } else { - shellAdapter.execute(command) + when (executionMode) { + ShellExecutionMode.STANDARD -> shellAdapter.executeWithOutput(command) + ShellExecutionMode.ROOT -> suAdapter.executeWithOutput(command) + ShellExecutionMode.ADB -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + systemBridgeConnectionManager.run { systemBridge -> + systemBridge.executeCommand(command) + } + } else { + KMError.SdkVersionTooLow(Build.VERSION_CODES.Q) + } + } } } } catch (e: TimeoutCancellationException) { @@ -34,12 +47,27 @@ class ExecuteShellCommandUseCase @Inject constructor( fun executeWithStreamingOutput( command: String, - useRoot: Boolean, + executionMode: ShellExecutionMode, ): Flow> { - return if (useRoot) { - suAdapter.executeWithStreamingOutput(command) - } else { - shellAdapter.executeWithStreamingOutput(command) + return when (executionMode) { + ShellExecutionMode.STANDARD -> shellAdapter.executeWithStreamingOutput(command) + ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput(command) + + ShellExecutionMode.ADB -> { + // ADB mode doesn't support streaming, so we execute synchronously and return a single result + flow { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val result = systemBridgeConnectionManager.run { systemBridge -> + systemBridge.executeCommand(command) + } + + emit(result) + } else { + emit(KMError.SdkVersionTooLow(Build.VERSION_CODES.Q)) + } + + } + } } } } 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 36c341d780..6434b571f0 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 @@ -910,7 +910,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.ShellCommand -> { result = executeShellCommandUseCase.execute( command = action.command, - useRoot = action.useRoot, + executionMode = action.executionMode, timeoutMillis = action.timeoutMillis.toLong(), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index 9a7842219f..da5722800c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -50,9 +50,11 @@ 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.ProModeStatus import io.github.sds100.keymapper.base.utils.getFullMessage -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.models.isSuccess import io.github.sds100.keymapper.common.utils.KMError @@ -65,13 +67,14 @@ import kotlinx.coroutines.launch data class ShellCommandActionState( val description: String = "", val command: String = "", - val useRoot: Boolean = false, + val executionMode: ShellExecutionMode = ShellExecutionMode.STANDARD, /** * UI works with seconds for user-friendliness */ val timeoutSeconds: Int = 10, val isRunning: Boolean = false, val testResult: KMResult? = null, + val proModeStatus: ProModeStatus = ProModeStatus.UNSUPPORTED, ) @Composable @@ -84,12 +87,13 @@ fun ShellCommandActionScreen( state = viewModel.state, onDescriptionChanged = viewModel::onDescriptionChanged, onCommandChanged = viewModel::onCommandChanged, - onUseRootChanged = viewModel::onUseRootChanged, + onExecutionModeChanged = viewModel::onExecutionModeChanged, onTimeoutChanged = viewModel::onTimeoutChanged, onTestClick = viewModel::onTestClick, onKillClick = viewModel::onKillClick, onDoneClick = viewModel::onDoneClick, onCancelClick = viewModel::onCancelClick, + onSetupProModeClick = viewModel::onSetupProModeClick, ) } @@ -100,12 +104,13 @@ private fun ShellCommandActionScreen( state: ShellCommandActionState, onDescriptionChanged: (String) -> Unit = {}, onCommandChanged: (String) -> Unit = {}, - onUseRootChanged: (Boolean) -> Unit = {}, + onExecutionModeChanged: (ShellExecutionMode) -> Unit = {}, onTimeoutChanged: (Int) -> Unit = {}, onTestClick: () -> Unit = {}, onKillClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, onCancelClick: () -> Unit = {}, + onSetupProModeClick: () -> Unit = {}, ) { val scrollState = rememberScrollState() val scope = rememberCoroutineScope() @@ -187,7 +192,7 @@ private fun ShellCommandActionScreen( commandError = null onCommandChanged(it) }, - onUseRootChanged = onUseRootChanged, + onExecutionModeChanged = onExecutionModeChanged, onTimeoutChanged = onTimeoutChanged, onTestClick = { if (state.command.isBlank()) { @@ -197,6 +202,7 @@ private fun ShellCommandActionScreen( } }, onKillClick = onKillClick, + onSetupProModeClick = onSetupProModeClick, ) } } @@ -209,10 +215,11 @@ private fun ShellCommandActionContent( commandError: String?, onDescriptionChanged: (String) -> Unit, onCommandChanged: (String) -> Unit, - onUseRootChanged: (Boolean) -> Unit, + onExecutionModeChanged: (ShellExecutionMode) -> Unit, onTimeoutChanged: (Int) -> Unit, onTestClick: () -> Unit, onKillClick: () -> Unit, + onSetupProModeClick: () -> Unit, ) { val context = LocalContext.current @@ -271,17 +278,52 @@ private fun ShellCommandActionContent( stepSize = 5, ) + Text( + text = stringResource(R.string.action_shell_command_execution_mode_label), + style = MaterialTheme.typography.titleMedium, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + ShellExecutionMode.STANDARD to stringResource(R.string.action_shell_command_execution_mode_standard), + ShellExecutionMode.ROOT to stringResource(R.string.action_shell_command_execution_mode_root), + ShellExecutionMode.ADB to stringResource(R.string.action_shell_command_execution_mode_adb), + ), + selectedState = state.executionMode, + onStateSelected = onExecutionModeChanged, + ) + + if (state.executionMode == ShellExecutionMode.ADB && state.proModeStatus != ProModeStatus.ENABLED) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onSetupProModeClick, + enabled = state.proModeStatus != ProModeStatus.UNSUPPORTED, + ) { + Text( + if (state.proModeStatus == ProModeStatus.UNSUPPORTED) { + stringResource(R.string.action_shell_command_setup_pro_mode_unsupported) + } else { + stringResource(R.string.action_shell_command_setup_pro_mode) + } + ) + } + } + + if (state.executionMode == ShellExecutionMode.ADB && state.isRunning) { + Text( + text = stringResource(R.string.action_shell_command_adb_streaming_warning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - CheckBoxText( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_shell_command_use_root_label), - isChecked = state.useRoot, - onCheckedChange = onUseRootChanged, - ) + Spacer(modifier = Modifier.weight(1f)) Button( onClick = onTestClick, @@ -396,9 +438,9 @@ private fun ShellCommandActionContent( ) } } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } } @Preview @@ -409,7 +451,7 @@ private fun PreviewShellCommandActionScreen() { state = ShellCommandActionState( description = "Hello world script", command = "echo 'Hello World'", - useRoot = false, + executionMode = ShellExecutionMode.STANDARD, testResult = Success(ShellResult("Hello World\nNew line\nNew new line", "", 0)), ), ) @@ -424,7 +466,7 @@ private fun PreviewShellCommandActionScreenEmpty() { state = ShellCommandActionState( description = "", command = "", - useRoot = true, + executionMode = ShellExecutionMode.ROOT, ), ) } @@ -438,7 +480,7 @@ private fun PreviewShellCommandActionScreenError() { state = ShellCommandActionState( description = "Read secret file", command = "cat /root/secret.txt", - useRoot = true, + executionMode = ShellExecutionMode.ROOT, testResult = SystemError.PermissionDenied(Permission.ROOT), ), ) @@ -453,7 +495,7 @@ private fun PreviewShellCommandActionScreenShellError() { state = ShellCommandActionState( description = "", command = "ls", - useRoot = true, + executionMode = ShellExecutionMode.ROOT, testResult = Success( ShellResult( stdOut = "", @@ -474,10 +516,25 @@ private fun PreviewShellCommandActionScreenTesting() { state = ShellCommandActionState( description = "Count to 10", command = "for i in \$(seq 1 10); do echo \"Line \$i\"; sleep 1; done", - useRoot = false, + executionMode = ShellExecutionMode.STANDARD, isRunning = true, testResult = Success(ShellResult("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", "", 0)), ), ) } } + +@Preview +@Composable +private fun PreviewShellCommandActionScreenProModeUnsupported() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "ADB command example", + command = "echo 'Hello from ADB'", + executionMode = ShellExecutionMode.ADB, + proModeStatus = ProModeStatus.UNSUPPORTED, + ), + ) + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index d15d34f5f7..7c16c93965 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1089,6 +1089,13 @@ Script Script cannot be empty! Run as root + Standard + Root + ADB + Execution Mode + ADB mode does not support streaming output + Setup PRO Mode + Setup PRO Mode (Unsupported) Test Output Executing… @@ -1096,6 +1103,7 @@ Failed Exit code: %1$d su -c %s + adb shell %s Interact with app element Key Mapper can detect and interact with app elements like menus, tabs, buttons and checkboxes. You need to record yourself interacting with the app so that Key Mapper knows what you want to do. diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt new file mode 100644 index 0000000000..cf5e94e262 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt @@ -0,0 +1,21 @@ +package io.github.sds100.keymapper.common.models + +/** + * Represents the execution mode for shell commands. + */ +enum class ShellExecutionMode { + /** + * Execute using the standard shell (non-root) + */ + STANDARD, + + /** + * Execute using root privileges (su) + */ + ROOT, + + /** + * Execute using ADB/system bridge (Pro mode) + */ + ADB +} \ No newline at end of file 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 3b2ab89696..0156dd6608 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 @@ -130,6 +130,7 @@ data class ActionEntity( const val ACTION_FLAG_REPEAT = 4 const val ACTION_FLAG_HOLD_DOWN = 8 const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16 + const val ACTION_FLAG_SHELL_COMMAND_USE_ADB = 32 const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour" const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour" From af42c9d1797a89b42899cde06a614a721489e7d4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 19 Oct 2025 23:53:24 +0200 Subject: [PATCH 11/14] #661 save shell script so progress isn't lost when navigating back to the screen --- .../actions/ConfigShellCommandViewModel.kt | 36 +++++++++++++++++++ .../io/github/sds100/keymapper/data/Keys.kt | 2 ++ 2 files changed, 38 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index fde3a371e6..00221ac5b8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import android.util.Base64 import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -12,6 +13,8 @@ import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -27,6 +30,7 @@ class ConfigShellCommandViewModel @Inject constructor( private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val navigationProvider: NavigationProvider, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val preferenceRepository: PreferenceRepository, ) : ViewModel() { var state: ShellCommandActionState by mutableStateOf(ShellCommandActionState()) @@ -46,6 +50,9 @@ class ConfigShellCommandViewModel @Inject constructor( state = state.copy(proModeStatus = proModeStatus) } } + + // Load saved script text + loadScriptText() } fun loadAction(action: ActionData.ShellCommand) { @@ -63,6 +70,7 @@ class ConfigShellCommandViewModel @Inject constructor( fun onCommandChanged(newCommand: String) { state = state.copy(command = newCommand) + saveScriptText(newCommand) } fun onExecutionModeChanged(newExecutionMode: ShellExecutionMode) { @@ -125,12 +133,18 @@ class ConfigShellCommandViewModel @Inject constructor( timeoutMillis = state.timeoutSeconds * 1000, ) + // Save script text before navigating away + saveScriptText(state.command) + viewModelScope.launch { navigationProvider.popBackStackWithResult(Json.encodeToString(action)) } } fun onCancelClick() { + // Save script text before navigating away + saveScriptText(state.command) + viewModelScope.launch { navigationProvider.popBackStack() } @@ -141,4 +155,26 @@ class ConfigShellCommandViewModel @Inject constructor( navigationProvider.navigate("shell_command_setup_pro_mode", NavDestination.ProModeSetup) } } + + private fun saveScriptText(scriptText: String) { + viewModelScope.launch { + val encodedText = Base64.encodeToString(scriptText.toByteArray(), Base64.DEFAULT).trim() + preferenceRepository.set(Keys.shellCommandScriptText, encodedText) + } + } + + private fun loadScriptText() { + viewModelScope.launch { + preferenceRepository.get(Keys.shellCommandScriptText).collect { savedScriptText -> + if (savedScriptText != null && state.command.isEmpty()) { + try { + val decodedText = String(Base64.decode(savedScriptText, Base64.DEFAULT)) + state = state.copy(command = decodedText) + } catch (e: Exception) { + // If decoding fails, ignore the saved text + } + } + } + } + } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 4c4cdd628f..ac629a73b0 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -137,4 +137,6 @@ object Keys { val keyEventActionsUseSystemBridge = booleanPreferencesKey("key_key_event_actions_use_system_bridge") + + val shellCommandScriptText = stringPreferencesKey("key_shell_command_script_text") } From ab4d5d802de2cceba882f8a4cfad87cf3b9045c7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 20 Oct 2025 00:10:51 +0200 Subject: [PATCH 12/14] fix: show error message for SMS system feature unavailable --- .../java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index a647c43435..e73988a785 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -65,7 +65,7 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { PackageManager.FEATURE_BLUETOOTH -> resourceProvider.getString(R.string.error_system_feature_bluetooth_unsupported) PackageManager.FEATURE_DEVICE_ADMIN -> resourceProvider.getString(R.string.error_system_feature_device_admin_unsupported) PackageManager.FEATURE_CAMERA_FLASH -> resourceProvider.getString(R.string.error_system_feature_camera_flash_unsupported) - PackageManager.FEATURE_TELEPHONY, PackageManager.FEATURE_TELEPHONY_DATA -> resourceProvider.getString( + PackageManager.FEATURE_TELEPHONY, PackageManager.FEATURE_TELEPHONY_DATA, PackageManager.FEATURE_TELEPHONY_MESSAGING -> resourceProvider.getString( R.string.error_system_feature_telephony_unsupported, ) From 34317bc20fec33f85cd19f95f37ef1fb66af5162 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 20 Oct 2025 00:16:47 +0200 Subject: [PATCH 13/14] fix: check for shizuku permission before granting permissions with shizuku --- .../keymapper/system/permissions/AndroidPermissionAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index 237b3bfbf9..cfa5e5dcc8 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -164,7 +164,7 @@ class AndroidPermissionAdapter @Inject constructor( KMError.Exception(Exception("Failed to grant permission with system bridge")) } } - } else if (shizukuAdapter.isStarted.value) { + } else if (shizukuAdapter.isStarted.value && isGranted(Permission.SHIZUKU)) { val userId = Process.myUserHandle()!!.getIdentifier() PermissionManagerApis.grantPermission( From a7d30941ab7adfa037af59899038556a41a18bc6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 20 Oct 2025 01:14:58 +0200 Subject: [PATCH 14/14] #661 fix streaming output when testing shell action --- app/version.properties | 2 +- .../keymapper/base/actions/ActionUiHelper.kt | 9 +- .../actions/ConfigShellCommandViewModel.kt | 71 +++-- .../actions/ExecuteShellCommandUseCase.kt | 28 +- .../base/actions/ShellCommandActionScreen.kt | 298 ++++++++++++++---- base/src/main/res/values/strings.xml | 8 +- .../sysbridge/service/SystemBridge.kt | 15 +- .../sds100/keymapper/system/root/SuAdapter.kt | 87 +++-- .../keymapper/system/shell/ShellAdapter.kt | 2 +- .../keymapper/system/shell/SimpleShell.kt | 38 ++- 10 files changed, 391 insertions(+), 167 deletions(-) diff --git a/app/version.properties b/app/version.properties index 5f3e886450..c7b0e10608 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=4.0.0-beta.1 -VERSION_CODE=170 +VERSION_CODE=171 VERSION_NUM=01 \ No newline at end of file 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 e2030a0f58..241149359d 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 @@ -576,15 +576,18 @@ class ActionUiHelper( is ActionData.ShellCommand -> when (action.executionMode) { ShellExecutionMode.ROOT -> getString( R.string.action_shell_command_description_with_root, - action.command + action.description ) ShellExecutionMode.ADB -> getString( R.string.action_shell_command_description_with_adb, - action.command + action.description ) - ShellExecutionMode.STANDARD -> action.command + ShellExecutionMode.STANDARD -> getString( + R.string.action_shell_command_description_with_standard, + action.description + ) } is ActionData.InteractUiElement -> action.description diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index 00221ac5b8..b1c360062b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import android.os.Build import android.util.Base64 import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -12,14 +13,18 @@ import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -40,14 +45,16 @@ class ConfigShellCommandViewModel @Inject constructor( init { // Update ProModeStatus in state - viewModelScope.launch { - systemBridgeConnectionManager.connectionState.map { connectionState -> - when (connectionState) { - is io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED - is io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + viewModelScope.launch { + systemBridgeConnectionManager.connectionState.map { connectionState -> + when (connectionState) { + is SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + } + }.collect { proModeStatus -> + state = state.copy(proModeStatus = proModeStatus) } - }.collect { proModeStatus -> - state = state.copy(proModeStatus = proModeStatus) } } @@ -92,22 +99,7 @@ class ConfigShellCommandViewModel @Inject constructor( testJob = viewModelScope.launch { try { withTimeout(state.timeoutSeconds * 1000L) { - val flow = executeShellCommandUseCase.executeWithStreamingOutput( - command = state.command, - executionMode = state.executionMode, - ) - - flow.catch { e -> - state = state.copy( - isRunning = false, - testResult = KMError.Exception(e as? Exception ?: Exception(e.message)), - ) - }.collect { result -> - state = state.copy( - isRunning = false, - testResult = result, - ) - } + testCommand() } } catch (e: TimeoutCancellationException) { state = state.copy( @@ -118,6 +110,37 @@ class ConfigShellCommandViewModel @Inject constructor( } } + private suspend fun testCommand() { + val flowResult = executeShellCommandUseCase.executeWithStreamingOutput( + command = state.command, + executionMode = state.executionMode, + ) + + when (flowResult) { + is KMError -> { + state = state.copy( + isRunning = false, + testResult = flowResult + ) + } + + is Success -> { + val flow = flowResult.value + + flow.onCompletion { + state = state.copy(isRunning = false) + }.catch { e -> + state = state.copy( + isRunning = false, + testResult = KMError.Exception(Exception(e.message)), + ) + }.collect { shellResult -> + state = state.copy(isRunning = true, testResult = Success(shellResult)) + } + } + } + } + fun onKillClick() { testJob?.cancel() state = state.copy( @@ -144,7 +167,7 @@ class ConfigShellCommandViewModel @Inject constructor( fun onCancelClick() { // Save script text before navigating away saveScriptText(state.command) - + viewModelScope.launch { navigationProvider.popBackStack() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt index 71a259eb46..f5d522e6d0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -5,12 +5,15 @@ import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import javax.inject.Inject @@ -45,27 +48,28 @@ class ExecuteShellCommandUseCase @Inject constructor( } } - fun executeWithStreamingOutput( + suspend fun executeWithStreamingOutput( command: String, executionMode: ShellExecutionMode, - ): Flow> { + ): KMResult> { return when (executionMode) { ShellExecutionMode.STANDARD -> shellAdapter.executeWithStreamingOutput(command) ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput(command) ShellExecutionMode.ADB -> { - // ADB mode doesn't support streaming, so we execute synchronously and return a single result - flow { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val result = systemBridgeConnectionManager.run { systemBridge -> + // ADB mode doesn't support streaming + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val result = withContext(Dispatchers.IO) { + systemBridgeConnectionManager.run { systemBridge -> systemBridge.executeCommand(command) } - - emit(result) - } else { - emit(KMError.SdkVersionTooLow(Build.VERSION_CODES.Q)) } - + when (result) { + is KMError -> result + is Success -> Success(flowOf(result.value)) + } + } else { + KMError.SdkVersionTooLow(Build.VERSION_CODES.Q) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index da5722800c..11109207c1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -2,15 +2,16 @@ 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.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding 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.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -24,14 +25,16 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -44,6 +47,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview @@ -172,7 +176,7 @@ private fun ShellCommandActionScreen( val startPadding = innerPadding.calculateStartPadding(layoutDirection) val endPadding = innerPadding.calculateEndPadding(layoutDirection) - ShellCommandActionContent( + Column( modifier = Modifier .fillMaxSize() .padding( @@ -181,34 +185,82 @@ private fun ShellCommandActionScreen( start = startPadding, end = endPadding, ), - state = state, - descriptionError = descriptionError, - commandError = commandError, - onDescriptionChanged = { - descriptionError = null - onDescriptionChanged(it) - }, - onCommandChanged = { - commandError = null - onCommandChanged(it) - }, - onExecutionModeChanged = onExecutionModeChanged, - onTimeoutChanged = onTimeoutChanged, - onTestClick = { - if (state.command.isBlank()) { - commandError = commandEmptyErrorString - } else { - onTestClick() + ) { + val pagerState = rememberPagerState(pageCount = { 2 }, initialPage = 0) + + PrimaryTabRow( + selectedTabIndex = pagerState.targetPage, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Tab( + selected = pagerState.targetPage == 0, + onClick = { + scope.launch { + pagerState.animateScrollToPage(0) + } + }, + text = { Text(stringResource(R.string.action_shell_command_tab_configuration)) }, + ) + Tab( + selected = pagerState.targetPage == 1, + onClick = { + scope.launch { + pagerState.animateScrollToPage(1) + } + }, + text = { Text(stringResource(R.string.action_shell_command_tab_output)) }, + ) + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + contentPadding = PaddingValues(16.dp), + pageSpacing = 16.dp + ) { pageIndex -> + when (pageIndex) { + 0 -> ShellCommandConfigurationContent( + modifier = Modifier.fillMaxSize(), + state = state, + descriptionError = descriptionError, + commandError = commandError, + onDescriptionChanged = { + descriptionError = null + onDescriptionChanged(it) + }, + onCommandChanged = { + commandError = null + onCommandChanged(it) + }, + onExecutionModeChanged = onExecutionModeChanged, + onTimeoutChanged = onTimeoutChanged, + onTestClick = { + if (state.command.isBlank()) { + commandError = commandEmptyErrorString + } else { + onTestClick() + scope.launch { + pagerState.animateScrollToPage(1) // Switch to output tab + } + } + }, + onSetupProModeClick = onSetupProModeClick, + ) + + 1 -> ShellCommandOutputContent( + modifier = Modifier.fillMaxSize(), + state = state, + onKillClick = onKillClick, + ) } - }, - onKillClick = onKillClick, - onSetupProModeClick = onSetupProModeClick, - ) + } + } } } @Composable -private fun ShellCommandActionContent( +private fun ShellCommandConfigurationContent( modifier: Modifier = Modifier, state: ShellCommandActionState, descriptionError: String?, @@ -218,15 +270,12 @@ private fun ShellCommandActionContent( onExecutionModeChanged: (ShellExecutionMode) -> Unit, onTimeoutChanged: (Int) -> Unit, onTestClick: () -> Unit, - onKillClick: () -> Unit, onSetupProModeClick: () -> Unit, ) { - val context = LocalContext.current - + val keyboardController = LocalSoftwareKeyboardController.current Column( modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( @@ -310,37 +359,41 @@ private fun ShellCommandActionContent( } } - if (state.executionMode == ShellExecutionMode.ADB && state.isRunning) { + Button( + modifier = Modifier.align(Alignment.End), + onClick = { + keyboardController?.hide() + onTestClick() + }, + enabled = !state.isRunning && (state.executionMode != ShellExecutionMode.ADB + || (state.executionMode == ShellExecutionMode.ADB + && state.proModeStatus == ProModeStatus.ENABLED)), + ) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.action_shell_command_adb_streaming_warning), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.isRunning) { + stringResource(R.string.action_shell_command_testing) + } else { + stringResource(R.string.action_shell_command_test_button) + } ) } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = Modifier.weight(1f)) - - Button( - onClick = onTestClick, - enabled = !state.isRunning, - ) { - Icon(Icons.Rounded.PlayArrow, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text( - if (state.isRunning) { - stringResource(R.string.action_shell_command_testing) - } else { - stringResource(R.string.action_shell_command_test_button) - } - ) - } - } +@Composable +private fun ShellCommandOutputContent( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + onKillClick: () -> Unit, +) { + val context = LocalContext.current + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { if (state.isRunning) { OutlinedButton( modifier = Modifier.fillMaxWidth(), @@ -359,17 +412,27 @@ private fun ShellCommandActionContent( LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), ) - } - if (state.testResult != null) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.outlineVariant, - ) + if (state.executionMode == ShellExecutionMode.ADB) { + Text( + text = stringResource(R.string.action_shell_command_adb_streaming_warning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } when (val result = state.testResult) { - null -> {} + null -> { + if (!state.isRunning) { + Text( + text = stringResource(R.string.action_shell_command_no_output), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + is Success -> { val shellResult = result.value if (shellResult.isSuccess()) { @@ -438,8 +501,6 @@ private fun ShellCommandActionContent( ) } } - - Spacer(modifier = Modifier.height(16.dp)) } } @@ -452,7 +513,6 @@ private fun PreviewShellCommandActionScreen() { description = "Hello world script", command = "echo 'Hello World'", executionMode = ShellExecutionMode.STANDARD, - testResult = Success(ShellResult("Hello World\nNew line\nNew new line", "", 0)), ), ) } @@ -538,3 +598,105 @@ private fun PreviewShellCommandActionScreenProModeUnsupported() { ) } } + +@Preview +@Composable +private fun PreviewShellCommandOutputSuccess() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Hello world script", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + testResult = Success(ShellResult("Hello World\nNew line\nNew new line", "", 0)), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputError() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + executionMode = ShellExecutionMode.ROOT, + testResult = SystemError.PermissionDenied(Permission.ROOT), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputShellError() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "List files", + command = "ls", + executionMode = ShellExecutionMode.ROOT, + testResult = Success( + ShellResult( + stdOut = "", + stdErr = "ls: .: Permission denied", + exitCode = 1 + ) + ), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputRunning() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Count to 10", + command = "for i in $(seq 1 10); do echo \"Line \$i\"; sleep 1; done", + executionMode = ShellExecutionMode.STANDARD, + isRunning = true, + testResult = Success( + ShellResult( + "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", + "", + 0 + ) + ), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputEmpty() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "No output yet", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + ), + onKillClick = {}, + ) + } + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 7c16c93965..0c54699076 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1096,14 +1096,18 @@ ADB mode does not support streaming output Setup PRO Mode Setup PRO Mode (Unsupported) + Configuration + Output + No output yet. Click Test to run the command. Test Output Executing… Success Failed Exit code: %1$d - su -c %s - adb shell %s + Execute with root: %s + Execute with ADB: %s + Execute: %s Interact with app element Key Mapper can detect and interact with app elements like menus, tabs, buttons and checkboxes. You need to record yourself interacting with the app so that Key Mapper knows what you want to do. diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 293dec5278..49d1d1af6e 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -515,22 +515,21 @@ internal class SystemBridge : ISystemBridge.Stub() { Log.i(TAG, "Executing command: $command") try { - val process = Runtime.getRuntime().exec(command) + // Execute through sh -c to properly handle multi-line commands and shell syntax + val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command)) process.waitFor() - val outputLines = with(process.inputStream.bufferedReader()) { - readLines() + val stdout = with(process.inputStream.bufferedReader()) { + readText() } - val errorLines = with(process.errorStream.bufferedReader()) { - readLines() + val stderr = with(process.errorStream.bufferedReader()) { + readText() } - val output = outputLines.joinToString("\n") - val stderr = errorLines.joinToString("\n") val exitCode = process.exitValue() - return ShellResult(output, stderr, exitCode) + return ShellResult(stdout, stderr, exitCode) } catch (e: Exception) { Log.e(TAG, "Error executing command: $command", e) return ShellResult("", e.message ?: "Unknown error", -1) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index c74f1c64d2..16f889f9ed 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -10,11 +10,14 @@ import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update import timber.log.Timber import javax.inject.Inject @@ -65,41 +68,61 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } - override fun executeWithStreamingOutput(command: String): Flow> = - callbackFlow { - if (!isRootGranted.firstBlocking()) { - trySend(SystemError.PermissionDenied(Permission.ROOT)) - close() - return@callbackFlow + override suspend fun executeWithStreamingOutput(command: String): KMResult> { + if (!isRootGranted.firstBlocking()) { + return SystemError.PermissionDenied(Permission.ROOT) + } + + val flow = callbackFlow { + val stdout = StringBuilder() + val stderr = StringBuilder() + + val outputCallback = object : CallbackList() { + override fun onAddElement(s: String) { + stdout.appendLine(s) + + trySendBlocking( + ShellResult( + stdout.toString(), + stderr.toString(), + 0 + ) + ) + } } - try { - val outputLines = mutableListOf() - val errorLines = mutableListOf() - - Shell.cmd(command) - .to(object : CallbackList() { - override fun onAddElement(s: String) { - outputLines.add(s) - trySend(Success(ShellResult(outputLines.joinToString("\n"), "", 0))) - } - }) - .to(errorLines) - .submit { result -> - val output = outputLines.joinToString("\n") - val stderr = errorLines.joinToString("\n") - val exitCode = result.code - - trySend(Success(ShellResult(output, stderr, exitCode))) - close() - } - - awaitClose { } - } catch (e: Exception) { - trySend(KMError.Exception(e)) - close() + val errorCallback = object : CallbackList() { + override fun onAddElement(s: String) { + stderr.appendLine(s) + + trySendBlocking( + ShellResult( + stdout.toString(), + stderr.toString(), + 0 + ) + ) + } } - } + + Shell.cmd(command) + .to(outputCallback, errorCallback) + .submit { result -> + trySendBlocking( + ShellResult( + stdout.toString(), + stderr.toString(), + result.code + ) + ) + close() + } + + awaitClose { } + }.flowOn(Dispatchers.IO) + + return Success(flow) + } fun invalidateIsRooted() { try { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt index 830325865f..09a354d21f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt @@ -7,5 +7,5 @@ import kotlinx.coroutines.flow.Flow interface ShellAdapter { fun execute(command: String): KMResult fun executeWithOutput(command: String): KMResult - fun executeWithStreamingOutput(command: String): Flow> + suspend fun executeWithStreamingOutput(command: String): KMResult> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt index 18fd925c05..6689e05a14 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/SimpleShell.kt @@ -48,37 +48,43 @@ class SimpleShell @Inject constructor() : ShellAdapter { } } - override fun executeWithStreamingOutput(command: String): Flow> { - return flow { - try { - val process = Runtime.getRuntime().exec(prepareCommand(command)) + override suspend fun executeWithStreamingOutput(command: String): KMResult> { + return try { + val process = Runtime.getRuntime().exec(prepareCommand(command)) + + val flow = flow { val outputReader = process.inputStream.bufferedReader() val errorReader = process.errorStream.bufferedReader() - val outputLines = mutableListOf() // Read output line by line - var line: String? + val stdout = StringBuilder() + + var line: String? = null + while (outputReader.readLine().also { line = it } != null) { - outputLines.add(line!!) - emit(Success(ShellResult(outputLines.joinToString("\n"), "", 0))) + if (line != null) { + stdout.appendLine(line) + } + + emit(ShellResult(stdout.toString(), "", 0)) } process.waitFor() - // Read stderr after process completes - val errorLines = errorReader.readLines() - val stderr = errorLines.joinToString("\n") + val stderr = errorReader.readText() val exitCode = process.exitValue() // Emit final result with both stdout and stderr - emit(Success(ShellResult(outputLines.joinToString("\n"), stderr, exitCode))) + emit(ShellResult(stdout.toString(), stderr, exitCode)) outputReader.close() errorReader.close() - } catch (e: IOException) { - emit(KMError.Exception(e)) - } - }.flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) + + Success(flow) + } catch (e: IOException) { + KMError.Exception(e) + } } private fun prepareCommand(command: String): Array {