diff --git a/app/version.properties b/app/version.properties index 2be8b7d87e..c7b0e10608 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=171 VERSION_NUM=01 \ No newline at end of file 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 a01c77b354..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 @@ -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 @@ -66,6 +68,19 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val viewModel: ConfigShellCommandViewModel = hiltViewModel() + + backStackEntry.handleRouteArgs { destination -> + destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } + } + + ShellCommandActionScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel + ) + } + 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..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 @@ -932,6 +933,21 @@ sealed class ActionData : Comparable { } } + @Serializable + data class ShellCommand( + val description: String, + val command: String, + 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, executionMode=$executionMode, timeoutMs=$timeoutMillis)" + } + } + @Serializable data class InteractUiElement( val description: String, 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..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 @@ -1,6 +1,8 @@ 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 @@ -47,6 +49,7 @@ object ActionDataEntityMapper { } ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT + ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND } return when (actionId) { @@ -652,6 +655,38 @@ object ActionDataEntityMapper { ActionData.MoveCursor(moveType = type, direction = direction) } + 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 + + 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( + description = description, + command = command, + executionMode = executionMode, + timeoutMillis = timeoutMs, + ) + } + ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp } @@ -679,6 +714,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 +727,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 +740,26 @@ 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) { + 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 } private fun getDataString(data: ActionData): String = when (data) { @@ -727,6 +781,11 @@ object ActionDataEntityMapper { } is ActionData.InteractUiElement -> data.description + is ActionData.ShellCommand -> Base64.encodeToString( + data.command.toByteArray(), + Base64.DEFAULT + ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + 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]!! @@ -986,6 +1045,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.timeoutMillis.toString()), + ) + else -> emptyList() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index 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/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/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index eedac43621..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 @@ -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,6 +573,23 @@ class ActionUiHelper( ActionData.DeviceControls -> getString(R.string.action_device_controls) is ActionData.HttpRequest -> action.description + is ActionData.ShellCommand -> when (action.executionMode) { + ShellExecutionMode.ROOT -> getString( + R.string.action_shell_command_description_with_root, + action.description + ) + + ShellExecutionMode.ADB -> getString( + R.string.action_shell_command_description_with_adb, + action.description + ) + + ShellExecutionMode.STANDARD -> getString( + R.string.action_shell_command_description_with_standard, + action.description + ) + } + is ActionData.InteractUiElement -> action.description ActionData.ClearRecentApp -> getString(R.string.action_clear_recent_app) 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..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 @@ -126,6 +127,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 +370,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 @@ -867,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 @@ -876,6 +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.Rounded.Terminal ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit @@ -924,6 +928,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..b1c360062b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -0,0 +1,203 @@ +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 +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.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 +import javax.inject.Inject + +@HiltViewModel +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()) + private set + + private var testJob: Job? = null + + init { + // Update ProModeStatus in state + 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) + } + } + } + + // Load saved script text + loadScriptText() + } + + fun loadAction(action: ActionData.ShellCommand) { + state = state.copy( + description = action.description, + command = action.command, + executionMode = action.executionMode, + timeoutSeconds = action.timeoutMillis / 1000, + ) + } + + fun onDescriptionChanged(newDescription: String) { + state = state.copy(description = newDescription) + } + + fun onCommandChanged(newCommand: String) { + state = state.copy(command = newCommand) + saveScriptText(newCommand) + } + + fun onExecutionModeChanged(newExecutionMode: ShellExecutionMode) { + state = state.copy(executionMode = newExecutionMode) + } + + fun onTimeoutChanged(newTimeoutSeconds: Int) { + state = state.copy(timeoutSeconds = newTimeoutSeconds) + } + + fun onTestClick() { + testJob?.cancel() + + state = state.copy( + isRunning = true, + testResult = null, + ) + + testJob = viewModelScope.launch { + try { + withTimeout(state.timeoutSeconds * 1000L) { + testCommand() + } + } catch (e: TimeoutCancellationException) { + state = state.copy( + isRunning = false, + testResult = KMError.ShellCommandTimeout(state.timeoutSeconds * 1000), + ) + } + } + } + + 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( + isRunning = false, + ) + } + + fun onDoneClick() { + val action = ActionData.ShellCommand( + description = state.description, + command = state.command, + executionMode = state.executionMode, + 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() + } + } + + fun onSetupProModeClick() { + viewModelScope.launch { + 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/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..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 @@ -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?.let { Json.encodeToString(oldAction) }), + ) + } + ActionId.INTERACT_UI_ELEMENT -> { val oldAction = oldData as? ActionData.InteractUiElement 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..f5d522e6d0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -0,0 +1,78 @@ +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.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.flowOf +import kotlinx.coroutines.withContext +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, + executionMode: ShellExecutionMode, + timeoutMillis: Long, + ): KMResult { + return try { + withTimeout(timeoutMillis) { + 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) { + KMError.ShellCommandTimeout(timeoutMillis.toInt()) + } + } + + suspend fun executeWithStreamingOutput( + command: String, + executionMode: ShellExecutionMode, + ): KMResult> { + return when (executionMode) { + ShellExecutionMode.STANDARD -> shellAdapter.executeWithStreamingOutput(command) + ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput(command) + + ShellExecutionMode.ADB -> { + // 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) + } + } + 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/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index dd5fe8d4d9..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 @@ -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}") } } @@ -905,6 +907,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.ShellCommand -> { + result = executeShellCommandUseCase.execute( + command = action.command, + executionMode = action.executionMode, + timeoutMillis = action.timeoutMillis.toLong(), + ) + } + is ActionData.InteractUiElement -> { if (service.activeWindowPackage.first() != action.packageName) { result = KMError.UiElementNotFound @@ -1024,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 new file mode 100644 index 0000000000..11109207c1 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -0,0 +1,702 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.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 +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.ButtonDefaults +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 +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 +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.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 +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.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 +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 kotlinx.coroutines.launch + +data class ShellCommandActionState( + val description: String = "", + val command: String = "", + 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 +fun ShellCommandActionScreen( + modifier: Modifier = Modifier, + viewModel: ConfigShellCommandViewModel +) { + ShellCommandActionScreen( + modifier = modifier, + state = viewModel.state, + onDescriptionChanged = viewModel::onDescriptionChanged, + onCommandChanged = viewModel::onCommandChanged, + onExecutionModeChanged = viewModel::onExecutionModeChanged, + onTimeoutChanged = viewModel::onTimeoutChanged, + onTestClick = viewModel::onTestClick, + onKillClick = viewModel::onKillClick, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + onSetupProModeClick = viewModel::onSetupProModeClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShellCommandActionScreen( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + onDescriptionChanged: (String) -> Unit = {}, + onCommandChanged: (String) -> Unit = {}, + onExecutionModeChanged: (ShellExecutionMode) -> Unit = {}, + onTimeoutChanged: (Int) -> Unit = {}, + onTestClick: () -> Unit = {}, + onKillClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onSetupProModeClick: () -> 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)) }, + ) + }, + 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(), + ) + }, + actions = { + IconButton(onClick = onCancelClick) { + Icon(Icons.Rounded.Close, stringResource(R.string.neg_cancel)) + } + }, + ) + }, + ) { innerPadding -> + + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + 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, + ) + } + } + } + } +} + +@Composable +private fun ShellCommandConfigurationContent( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + descriptionError: String?, + commandError: String?, + onDescriptionChanged: (String) -> Unit, + onCommandChanged: (String) -> Unit, + onExecutionModeChanged: (ShellExecutionMode) -> Unit, + onTimeoutChanged: (Int) -> Unit, + onTestClick: () -> Unit, + onSetupProModeClick: () -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + 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, + ) + } + }, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + 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, + ) + + 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) + } + ) + } + } + + 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( + 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(), + onClick = onKillClick, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + 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(), + ) + + 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 -> { + 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()) { + 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, + ) + + 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 -> { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + Text( + text = result.getFullMessage(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreen() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Hello world script", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenEmpty() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "", + command = "", + executionMode = ShellExecutionMode.ROOT, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + executionMode = ShellExecutionMode.ROOT, + testResult = SystemError.PermissionDenied(Permission.ROOT), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenShellError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "", + command = "ls", + executionMode = ShellExecutionMode.ROOT, + testResult = Success( + ShellResult( + stdOut = "", + stdErr = "ls: .: Permission denied", + exitCode = 1 + ) + ), + ), + ) + } +} + +@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", + 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, + ), + ) + } +} + +@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/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/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 0191702757..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, ) @@ -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/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/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index 48f23a5c14..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 @@ -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 actionJson: String?) : + 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/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/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index a07b5f8b13..0c54699076 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 @@ -1082,6 +1084,31 @@ Authorization header (optional) You must prepend \'Bearer\' if necessary + Shell command + Shell command action + 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) + Configuration + Output + No output yet. Click Test to run the command. + Test + Output + Executing… + Success + Failed + Exit code: %1$d + 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. Start recording @@ -1740,5 +1767,7 @@ Test SMS Sent successfully! + Description + Timeout 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()) + } +} 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/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/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/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") } 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..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 @@ -86,6 +86,9 @@ 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" + 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" @@ -126,6 +129,8 @@ 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 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" @@ -177,6 +182,7 @@ data class ActionEntity( COMPOSE_SMS, SOUND, INTERACT_UI_ELEMENT, + SHELL_COMMAND, } constructor( 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..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 @@ -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,31 @@ 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 { + // Execute through sh -c to properly handle multi-line commands and shell syntax + val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command)) - val out = with(process.inputStream.bufferedReader()) { - readText() - } + process.waitFor() - val err = with(process.errorStream.bufferedReader()) { - readText() - } + val stdout = with(process.inputStream.bufferedReader()) { + readText() + } + val stderr = with(process.errorStream.bufferedReader()) { + readText() + } - process.waitFor() + val exitCode = process.exitValue() - return "$out\n$err" + return ShellResult(stdout, 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/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index b7a433f287..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( @@ -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 4b3f65eac3..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 @@ -1,14 +1,23 @@ 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 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 @@ -27,17 +36,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) { @@ -45,6 +50,80 @@ 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() + + val output = result.out.joinToString("\n") + val stderr = result.err.joinToString("\n") + val exitCode = result.code + + return Success(ShellResult(output, stderr, exitCode)) + } catch (e: Exception) { + return KMError.Exception(e) + } + } + + 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 + ) + ) + } + } + + 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 { // Close the shell so a new one is started without root permission. @@ -63,9 +142,8 @@ class SuAdapterImpl @Inject constructor() : SuAdapter { } } -interface SuAdapter { +interface SuAdapter : ShellAdapter { val isRootGranted: StateFlow fun requestPermission() - fun execute(command: String, block: Boolean = false): 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..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 @@ -1,9 +1,11 @@ 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 interface ShellAdapter { - fun run(vararg command: String, waitFor: Boolean = false): Boolean - fun execute(command: String): KMResult<*> - fun executeWithOutput(command: String): KMResult + fun execute(command: String): KMResult + fun executeWithOutput(command: String): KMResult + 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 7771765349..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 @@ -1,32 +1,23 @@ 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 +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) { @@ -34,26 +25,70 @@ class SimpleShell @Inject constructor() : ShellAdapter { } } - override fun executeWithOutput(command: String): KMResult { + override fun executeWithOutput(command: String): KMResult { try { - val process = Runtime.getRuntime().exec(command) + 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 Success(ShellResult(output, stderr, exitCode)) } catch (e: IOException) { return KMError.Exception(e) } } + + 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() + + // Read output line by line + val stdout = StringBuilder() + + var line: String? = null + + while (outputReader.readLine().also { line = it } != null) { + if (line != null) { + stdout.appendLine(line) + } + + emit(ShellResult(stdout.toString(), "", 0)) + } + + process.waitFor() + + val stderr = errorReader.readText() + val exitCode = process.exitValue() + + // Emit final result with both stdout and stderr + emit(ShellResult(stdout.toString(), stderr, exitCode)) + + outputReader.close() + errorReader.close() + }.flowOn(Dispatchers.IO) + + Success(flow) + } catch (e: IOException) { + KMError.Exception(e) + } + } + + 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