Skip to content

Commit beb0747

Browse files
authored
Merge pull request #1850 from keymapperorg/copilot/add-shell-command-execution
Add shell command action with root mode support and test functionality
2 parents 0205e30 + a7d3094 commit beb0747

File tree

38 files changed

+1521
-74
lines changed

38 files changed

+1521
-74
lines changed

app/version.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
VERSION_NAME=4.0.0-beta.1
2-
VERSION_CODE=169
2+
VERSION_CODE=171
33
VERSION_NUM=01

base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import androidx.navigation.compose.NavHost
1818
import androidx.navigation.compose.composable
1919
import io.github.sds100.keymapper.base.actions.ChooseActionScreen
2020
import io.github.sds100.keymapper.base.actions.ChooseActionViewModel
21+
import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel
22+
import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen
2123
import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen
2224
import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel
2325
import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen
@@ -66,6 +68,19 @@ fun BaseMainNavHost(
6668
)
6769
}
6870

71+
composable<NavDestination.ConfigShellCommand> { backStackEntry ->
72+
val viewModel: ConfigShellCommandViewModel = hiltViewModel()
73+
74+
backStackEntry.handleRouteArgs<NavDestination.ConfigShellCommand> { destination ->
75+
destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) }
76+
}
77+
78+
ShellCommandActionScreen(
79+
modifier = Modifier.fillMaxSize(),
80+
viewModel = viewModel
81+
)
82+
}
83+
6984
composable<NavDestination.ChooseConstraint> {
7085
val viewModel: ChooseConstraintViewModel = hiltViewModel()
7186

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.sds100.keymapper.base.actions
22

3+
import io.github.sds100.keymapper.common.models.ShellExecutionMode
34
import io.github.sds100.keymapper.common.utils.NodeInteractionType
45
import io.github.sds100.keymapper.common.utils.Orientation
56
import io.github.sds100.keymapper.common.utils.PinchScreenType
@@ -932,6 +933,21 @@ sealed class ActionData : Comparable<ActionData> {
932933
}
933934
}
934935

936+
@Serializable
937+
data class ShellCommand(
938+
val description: String,
939+
val command: String,
940+
val executionMode: ShellExecutionMode,
941+
val timeoutMillis: Int = 10000, // milliseconds (default 10 seconds)
942+
) : ActionData() {
943+
override val id: ActionId = ActionId.SHELL_COMMAND
944+
945+
override fun toString(): String {
946+
// Do not leak sensitive command info to logs.
947+
return "ShellCommand(description=$description, executionMode=$executionMode, timeoutMs=$timeoutMillis)"
948+
}
949+
}
950+
935951
@Serializable
936952
data class InteractUiElement(
937953
val description: String,

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.github.sds100.keymapper.base.actions
22

3+
import android.util.Base64
34
import androidx.core.net.toUri
5+
import io.github.sds100.keymapper.common.models.ShellExecutionMode
46
import io.github.sds100.keymapper.common.utils.KMError
57
import io.github.sds100.keymapper.common.utils.KMResult
68
import io.github.sds100.keymapper.common.utils.NodeInteractionType
@@ -47,6 +49,7 @@ object ActionDataEntityMapper {
4749
}
4850

4951
ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT
52+
ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND
5053
}
5154

5255
return when (actionId) {
@@ -652,6 +655,38 @@ object ActionDataEntityMapper {
652655
ActionData.MoveCursor(moveType = type, direction = direction)
653656
}
654657

658+
ActionId.SHELL_COMMAND -> {
659+
val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT)
660+
val useAdb = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB)
661+
662+
val executionMode = when {
663+
useAdb -> ShellExecutionMode.ADB
664+
useRoot -> ShellExecutionMode.ROOT
665+
else -> ShellExecutionMode.STANDARD
666+
}
667+
668+
val description =
669+
entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION)
670+
.valueOrNull() ?: return null
671+
672+
val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT)
673+
.valueOrNull()?.toIntOrNull() ?: 10000
674+
675+
// Decode Base64 command
676+
val command = try {
677+
String(Base64.decode(entity.data, Base64.DEFAULT))
678+
} catch (e: Exception) {
679+
return null
680+
}
681+
682+
ActionData.ShellCommand(
683+
description = description,
684+
command = command,
685+
executionMode = executionMode,
686+
timeoutMillis = timeoutMs,
687+
)
688+
}
689+
655690
ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp
656691
ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp
657692
}
@@ -679,6 +714,7 @@ object ActionDataEntityMapper {
679714
is ActionData.Url -> ActionEntity.Type.URL
680715
is ActionData.Sound -> ActionEntity.Type.SOUND
681716
is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT
717+
is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND
682718
else -> ActionEntity.Type.SYSTEM_ACTION
683719
}
684720

@@ -691,6 +727,8 @@ object ActionDataEntityMapper {
691727
}
692728

693729
private fun getFlags(data: ActionData): Int {
730+
var flags = 0
731+
694732
val showVolumeUiFlag = when (data) {
695733
is ActionData.Volume.Stream -> data.showVolumeUi
696734
is ActionData.Volume.Up -> data.showVolumeUi
@@ -702,10 +740,26 @@ object ActionDataEntityMapper {
702740
}
703741

704742
if (showVolumeUiFlag) {
705-
return ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI
706-
} else {
707-
return 0
743+
flags = flags or ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI
744+
}
745+
746+
if (data is ActionData.ShellCommand) {
747+
when (data.executionMode) {
748+
ShellExecutionMode.ROOT -> {
749+
flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT
750+
}
751+
752+
ShellExecutionMode.ADB -> {
753+
flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB
754+
}
755+
756+
ShellExecutionMode.STANDARD -> {
757+
// No flag needed for standard mode
758+
}
759+
}
708760
}
761+
762+
return flags
709763
}
710764

711765
private fun getDataString(data: ActionData): String = when (data) {
@@ -727,6 +781,11 @@ object ActionDataEntityMapper {
727781
}
728782

729783
is ActionData.InteractUiElement -> data.description
784+
is ActionData.ShellCommand -> Base64.encodeToString(
785+
data.command.toByteArray(),
786+
Base64.DEFAULT
787+
).trim() // Trim to remove trailing newline added by Base64.DEFAULT
788+
is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!!
730789
is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!!
731790
is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!!
732791
is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!!
@@ -986,6 +1045,11 @@ object ActionDataEntityMapper {
9861045
add(EntityExtra(ActionEntity.EXTRA_MOVE_CURSOR_DIRECTION, directionString))
9871046
}
9881047

1048+
is ActionData.ShellCommand -> listOf(
1049+
EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION, data.description),
1050+
EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()),
1051+
)
1052+
9891053
else -> emptyList()
9901054
}
9911055

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.github.sds100.keymapper.base.actions.sound.SoundsManager
55
import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper
66
import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface
77
import io.github.sds100.keymapper.common.BuildConfigProvider
8+
import io.github.sds100.keymapper.common.models.ShellExecutionMode
89
import io.github.sds100.keymapper.common.utils.Constants
910
import io.github.sds100.keymapper.common.utils.KMError
1011
import io.github.sds100.keymapper.common.utils.firstBlocking
@@ -202,6 +203,28 @@ class LazyActionErrorSnapshot(
202203
return it
203204
}
204205

206+
is ActionData.ShellCommand -> {
207+
return when (action.executionMode) {
208+
ShellExecutionMode.ROOT -> {
209+
if (!isPermissionGranted(Permission.ROOT)) {
210+
SystemError.PermissionDenied(Permission.ROOT)
211+
} else {
212+
null
213+
}
214+
}
215+
216+
ShellExecutionMode.ADB -> {
217+
if (!isSystemBridgeConnected) {
218+
SystemBridgeError.Disconnected
219+
} else {
220+
null
221+
}
222+
}
223+
224+
ShellExecutionMode.STANDARD -> null
225+
}
226+
}
227+
205228
else -> {}
206229
}
207230

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ enum class ActionId {
1111
PINCH_SCREEN,
1212
URL,
1313
HTTP_REQUEST,
14+
SHELL_COMMAND,
1415
INTENT,
1516
PHONE_CALL,
1617
INTERACT_UI_ELEMENT,

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.github.sds100.keymapper.base.utils.ui.IconInfo
1414
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
1515
import io.github.sds100.keymapper.base.utils.ui.TintType
1616
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
17+
import io.github.sds100.keymapper.common.models.ShellExecutionMode
1718
import io.github.sds100.keymapper.common.utils.InputDeviceUtils
1819
import io.github.sds100.keymapper.common.utils.Orientation
1920
import io.github.sds100.keymapper.common.utils.PinchScreenType
@@ -572,6 +573,23 @@ class ActionUiHelper(
572573
ActionData.DeviceControls -> getString(R.string.action_device_controls)
573574
is ActionData.HttpRequest -> action.description
574575

576+
is ActionData.ShellCommand -> when (action.executionMode) {
577+
ShellExecutionMode.ROOT -> getString(
578+
R.string.action_shell_command_description_with_root,
579+
action.description
580+
)
581+
582+
ShellExecutionMode.ADB -> getString(
583+
R.string.action_shell_command_description_with_adb,
584+
action.description
585+
)
586+
587+
ShellExecutionMode.STANDARD -> getString(
588+
R.string.action_shell_command_description_with_standard,
589+
action.description
590+
)
591+
}
592+
575593
is ActionData.InteractUiElement -> action.description
576594

577595
ActionData.ClearRecentApp -> getString(R.string.action_clear_recent_app)

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.annotation.RequiresApi
77
import androidx.annotation.StringRes
88
import androidx.compose.material.icons.Icons
99
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
10+
import androidx.compose.material.icons.automirrored.outlined.Message
1011
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
1112
import androidx.compose.material.icons.automirrored.outlined.ShortText
1213
import androidx.compose.material.icons.automirrored.outlined.Undo
@@ -40,7 +41,6 @@ import androidx.compose.material.icons.outlined.Keyboard
4041
import androidx.compose.material.icons.outlined.KeyboardHide
4142
import androidx.compose.material.icons.outlined.Link
4243
import androidx.compose.material.icons.outlined.Lock
43-
import androidx.compose.material.icons.outlined.Message
4444
import androidx.compose.material.icons.outlined.MoreVert
4545
import androidx.compose.material.icons.outlined.Nfc
4646
import androidx.compose.material.icons.outlined.NotStarted
@@ -72,6 +72,7 @@ import androidx.compose.material.icons.rounded.BluetoothDisabled
7272
import androidx.compose.material.icons.rounded.ContentCopy
7373
import androidx.compose.material.icons.rounded.ContentCut
7474
import androidx.compose.material.icons.rounded.ContentPaste
75+
import androidx.compose.material.icons.rounded.Terminal
7576
import androidx.compose.material.icons.rounded.Wifi
7677
import androidx.compose.material.icons.rounded.WifiOff
7778
import androidx.compose.ui.graphics.vector.ImageVector
@@ -126,6 +127,7 @@ object ActionUtils {
126127
ActionId.INTENT -> ActionCategory.APPS
127128
ActionId.URL -> ActionCategory.APPS
128129
ActionId.HTTP_REQUEST -> ActionCategory.APPS
130+
ActionId.SHELL_COMMAND -> ActionCategory.APPS
129131

130132
ActionId.TOGGLE_WIFI -> ActionCategory.CONNECTIVITY
131133
ActionId.ENABLE_WIFI -> ActionCategory.CONNECTIVITY
@@ -368,6 +370,7 @@ object ActionUtils {
368370
ActionId.COMPOSE_SMS -> R.string.action_compose_sms
369371
ActionId.DEVICE_CONTROLS -> R.string.action_device_controls
370372
ActionId.HTTP_REQUEST -> R.string.action_http_request
373+
ActionId.SHELL_COMMAND -> R.string.action_shell_command
371374
ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title
372375
ActionId.FORCE_STOP_APP -> R.string.action_force_stop_app
373376
ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app
@@ -867,15 +870,16 @@ object ActionUtils {
867870
ActionId.URL -> Icons.Outlined.Link
868871
ActionId.INTENT -> Icons.Outlined.DataObject
869872
ActionId.PHONE_CALL -> Icons.Outlined.Call
870-
ActionId.SEND_SMS -> Icons.Outlined.Message
871-
ActionId.COMPOSE_SMS -> Icons.Outlined.Message
873+
ActionId.SEND_SMS -> Icons.AutoMirrored.Outlined.Message
874+
ActionId.COMPOSE_SMS -> Icons.AutoMirrored.Outlined.Message
872875
ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp
873876
ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll
874877
ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll
875878
ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call
876879
ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd
877880
ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice
878881
ActionId.HTTP_REQUEST -> Icons.Outlined.Http
882+
ActionId.SHELL_COMMAND -> Icons.Rounded.Terminal
879883
ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement
880884
ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous
881885
ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit
@@ -924,6 +928,7 @@ fun ActionData.isEditable(): Boolean = when (this) {
924928
is ActionData.SendSms,
925929
is ActionData.ComposeSms,
926930
is ActionData.HttpRequest,
931+
is ActionData.ShellCommand,
927932
is ActionData.InteractUiElement,
928933
is ActionData.MoveCursor,
929934
-> true

0 commit comments

Comments
 (0)