Skip to content

Commit 86cd243

Browse files
committed
#661 feat: add timeout and description to Shell command action
1 parent 7634f99 commit 86cd243

18 files changed

Lines changed: 562 additions & 211 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,13 @@ fun BaseMainNavHost(
7171
composable<NavDestination.ConfigShellCommand> { backStackEntry ->
7272
val viewModel: ConfigShellCommandViewModel = hiltViewModel()
7373

74+
backStackEntry.handleRouteArgs<NavDestination.ConfigShellCommand> { destination ->
75+
destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) }
76+
}
77+
7478
ShellCommandActionScreen(
75-
state = viewModel.state,
76-
onCommandChanged = viewModel::onCommandChanged,
77-
onUseRootChanged = viewModel::onUseRootChanged,
78-
onTestClick = viewModel::onTestClick,
79-
onDoneClick = viewModel::onDoneClick,
80-
onCancelClick = viewModel::onCancelClick,
79+
modifier = Modifier.fillMaxSize(),
80+
viewModel = viewModel
8181
)
8282
}
8383

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,14 +934,16 @@ sealed class ActionData : Comparable<ActionData> {
934934

935935
@Serializable
936936
data class ShellCommand(
937+
val description: String,
937938
val command: String,
938939
val useRoot: Boolean,
940+
val timeoutMs: Int = 10000, // milliseconds (default 10 seconds)
939941
) : ActionData() {
940942
override val id: ActionId = ActionId.SHELL_COMMAND
941943

942944
override fun toString(): String {
943945
// Do not leak sensitive command info to logs.
944-
return "ShellCommand(useRoot=$useRoot)"
946+
return "ShellCommand(description=$description, useRoot=$useRoot, timeoutMs=$timeoutMs)"
945947
}
946948
}
947949

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

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

3+
import android.util.Base64
34
import androidx.core.net.toUri
45
import io.github.sds100.keymapper.common.utils.KMError
56
import io.github.sds100.keymapper.common.utils.KMResult
@@ -655,10 +656,25 @@ object ActionDataEntityMapper {
655656

656657
ActionId.SHELL_COMMAND -> {
657658
val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT)
659+
val description =
660+
entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION)
661+
.valueOrNull() ?: return null
662+
663+
val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT)
664+
.valueOrNull()?.toIntOrNull() ?: 10000
665+
666+
// Decode Base64 command
667+
val command = try {
668+
String(Base64.decode(entity.data, Base64.DEFAULT))
669+
} catch (e: Exception) {
670+
return null
671+
}
658672

659673
ActionData.ShellCommand(
660-
command = entity.data,
674+
description = description,
675+
command = command,
661676
useRoot = useRoot,
677+
timeoutMs = timeoutMs,
662678
)
663679
}
664680

@@ -744,7 +760,11 @@ object ActionDataEntityMapper {
744760
}
745761

746762
is ActionData.InteractUiElement -> data.description
747-
is ActionData.ShellCommand -> data.command
763+
is ActionData.ShellCommand -> Base64.encodeToString(
764+
data.command.toByteArray(),
765+
Base64.DEFAULT
766+
).trim() // Trim to remove trailing newline added by Base64.DEFAULT
767+
is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!!
748768
is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!!
749769
is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!!
750770
is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!!
@@ -1004,6 +1024,11 @@ object ActionDataEntityMapper {
10041024
add(EntityExtra(ActionEntity.EXTRA_MOVE_CURSOR_DIRECTION, directionString))
10051025
}
10061026

1027+
is ActionData.ShellCommand -> listOf(
1028+
EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION, data.description),
1029+
EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMs.toString()),
1030+
)
1031+
10071032
else -> emptyList()
10081033
}
10091034

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

Lines changed: 5 additions & 4 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
@@ -869,16 +870,16 @@ object ActionUtils {
869870
ActionId.URL -> Icons.Outlined.Link
870871
ActionId.INTENT -> Icons.Outlined.DataObject
871872
ActionId.PHONE_CALL -> Icons.Outlined.Call
872-
ActionId.SEND_SMS -> Icons.Outlined.Message
873-
ActionId.COMPOSE_SMS -> Icons.Outlined.Message
873+
ActionId.SEND_SMS -> Icons.AutoMirrored.Outlined.Message
874+
ActionId.COMPOSE_SMS -> Icons.AutoMirrored.Outlined.Message
874875
ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp
875876
ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll
876877
ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll
877878
ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call
878879
ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd
879880
ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice
880881
ActionId.HTTP_REQUEST -> Icons.Outlined.Http
881-
ActionId.SHELL_COMMAND -> Icons.Outlined.DataObject
882+
ActionId.SHELL_COMMAND -> Icons.Rounded.Terminal
882883
ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement
883884
ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous
884885
ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit

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

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,43 @@ package io.github.sds100.keymapper.base.actions
33
import androidx.compose.runtime.getValue
44
import androidx.compose.runtime.mutableStateOf
55
import androidx.compose.runtime.setValue
6-
import androidx.lifecycle.SavedStateHandle
76
import androidx.lifecycle.ViewModel
87
import androidx.lifecycle.viewModelScope
98
import dagger.hilt.android.lifecycle.HiltViewModel
10-
import io.github.sds100.keymapper.base.utils.getFullMessage
119
import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider
12-
import io.github.sds100.keymapper.base.utils.navigation.popBackStackWithResult
13-
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
1410
import io.github.sds100.keymapper.common.utils.KMError
15-
import io.github.sds100.keymapper.common.utils.State
16-
import io.github.sds100.keymapper.common.utils.Success
17-
import io.github.sds100.keymapper.system.root.SuAdapter
18-
import io.github.sds100.keymapper.system.shell.ShellAdapter
11+
import kotlinx.coroutines.Job
12+
import kotlinx.coroutines.TimeoutCancellationException
13+
import kotlinx.coroutines.flow.catch
1914
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.withTimeout
2016
import kotlinx.serialization.json.Json
2117
import javax.inject.Inject
2218

2319
@HiltViewModel
2420
class ConfigShellCommandViewModel @Inject constructor(
25-
savedStateHandle: SavedStateHandle,
26-
private val shellAdapter: ShellAdapter,
27-
private val suAdapter: SuAdapter,
21+
private val executeShellCommandUseCase: ExecuteShellCommandUseCase,
2822
private val navigationProvider: NavigationProvider,
29-
private val resourceProvider: ResourceProvider,
3023
) : ViewModel() {
3124

32-
private val oldAction: ActionData.ShellCommand? =
33-
savedStateHandle.get<ActionData.ShellCommand?>("action")
34-
35-
var state: ShellCommandActionState by mutableStateOf(
36-
ShellCommandActionState(
37-
command = oldAction?.command ?: "",
38-
useRoot = oldAction?.useRoot ?: false,
39-
testResult = null,
40-
),
41-
)
25+
var state: ShellCommandActionState by mutableStateOf(ShellCommandActionState())
4226
private set
4327

28+
private var testJob: Job? = null
29+
30+
fun loadAction(action: ActionData.ShellCommand) {
31+
state = state.copy(
32+
description = action.description,
33+
command = action.command,
34+
useRoot = action.useRoot,
35+
timeoutSeconds = action.timeoutMs / 1000,
36+
)
37+
}
38+
39+
fun onDescriptionChanged(newDescription: String) {
40+
state = state.copy(description = newDescription)
41+
}
42+
4443
fun onCommandChanged(newCommand: String) {
4544
state = state.copy(command = newCommand)
4645
}
@@ -49,29 +48,61 @@ class ConfigShellCommandViewModel @Inject constructor(
4948
state = state.copy(useRoot = newUseRoot)
5049
}
5150

51+
fun onTimeoutChanged(newTimeoutSeconds: Int) {
52+
state = state.copy(timeoutSeconds = newTimeoutSeconds)
53+
}
54+
5255
fun onTestClick() {
53-
viewModelScope.launch {
54-
state = state.copy(testResult = State.Loading)
56+
// Cancel any existing test
57+
testJob?.cancel()
5558

56-
val result = if (state.useRoot) {
57-
suAdapter.executeWithOutput(state.command)
58-
} else {
59-
shellAdapter.executeWithOutput(state.command)
60-
}
59+
state = state.copy(
60+
isRunning = true,
61+
testResult = null,
62+
)
63+
64+
testJob = viewModelScope.launch {
65+
try {
66+
withTimeout(state.timeoutSeconds * 1000L) {
67+
val flow = executeShellCommandUseCase.executeWithStreamingOutput(
68+
command = state.command,
69+
useRoot = state.useRoot,
70+
)
6171

62-
state = state.copy(
63-
testResult = when (result) {
64-
is Success -> State.Data(result.data)
65-
is KMError -> State.Data(result.getFullMessage(resourceProvider))
66-
},
67-
)
72+
flow.catch { e ->
73+
state = state.copy(
74+
isRunning = false,
75+
testResult = KMError.Exception(e as? Exception ?: Exception(e.message)),
76+
)
77+
}.collect { result ->
78+
state = state.copy(
79+
isRunning = false,
80+
testResult = result,
81+
)
82+
}
83+
}
84+
} catch (e: TimeoutCancellationException) {
85+
state = state.copy(
86+
isRunning = false,
87+
testResult = KMError.ShellCommandTimeout(state.timeoutSeconds * 1000),
88+
)
89+
}
6890
}
6991
}
7092

93+
fun onKillClick() {
94+
testJob?.cancel()
95+
state = state.copy(
96+
isRunning = false,
97+
)
98+
}
99+
71100
fun onDoneClick() {
72101
val action = ActionData.ShellCommand(
102+
description = state.description,
73103
command = state.command,
74104
useRoot = state.useRoot,
105+
timeoutMs = state.timeoutSeconds * 1000,
75106
)
76107

77108
viewModelScope.launch {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,7 @@ class CreateActionDelegate(
872872

873873
return navigate(
874874
"config_shell_command_action",
875-
NavDestination.ConfigShellCommand(oldAction),
875+
NavDestination.ConfigShellCommand(oldAction?.let { Json.encodeToString(oldAction) }),
876876
)
877877
}
878878

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.github.sds100.keymapper.base.actions
2+
3+
import io.github.sds100.keymapper.common.utils.KMError
4+
import io.github.sds100.keymapper.common.utils.KMResult
5+
import io.github.sds100.keymapper.system.root.SuAdapter
6+
import io.github.sds100.keymapper.system.shell.ShellAdapter
7+
import kotlinx.coroutines.TimeoutCancellationException
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.withTimeout
10+
import javax.inject.Inject
11+
12+
class ExecuteShellCommandUseCase @Inject constructor(
13+
private val shellAdapter: ShellAdapter,
14+
private val suAdapter: SuAdapter,
15+
) {
16+
suspend fun execute(
17+
command: String,
18+
useRoot: Boolean,
19+
timeoutMs: Long,
20+
): KMResult<Unit> {
21+
return try {
22+
withTimeout(timeoutMs) {
23+
if (useRoot) {
24+
suAdapter.execute(command)
25+
} else {
26+
shellAdapter.execute(command)
27+
}
28+
}
29+
} catch (e: TimeoutCancellationException) {
30+
KMError.ShellCommandTimeout(timeoutMs.toInt())
31+
}
32+
}
33+
34+
fun executeWithStreamingOutput(
35+
command: String,
36+
useRoot: Boolean,
37+
): Flow<KMResult<String>> {
38+
return if (useRoot) {
39+
suAdapter.executeWithStreamingOutput(command)
40+
} else {
41+
shellAdapter.executeWithStreamingOutput(command)
42+
}
43+
}
44+
}
45+

0 commit comments

Comments
 (0)