[network/kdeconnect-android] src/main: Support to show the output of commands run on the connected device
Albert Vaca Cintora <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit c620f96e256967b3bd709a6ba2a705f517c9c795 by Albert Vaca Cintora, on behalf of Johann Specht.
Committed on 30/07/2026 at 07:57.
Pushed by albertvaka into branch 'master'.
Support to show the output of commands run on the connected device
Releated PR on network/kdeconnect-kde [#920](https://invent.kde.org/network/kdeconnect-kde/-/merge_requests/920)
When a command run gets triggered, the app will display the Output of the command.
M +3 -0 src/main/java/org/kde/kdeconnect/Device.kt
M +8 -4 src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
M +74 -4 src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
M +95 -0 src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
M +3 -0 src/main/res/values/strings.xml
https://invent.kde.org/network/kdeconnect-android/-/commit/c620f96e256967b3bd709a6ba2a705f517c9c795
diff --git a/src/main/java/org/kde/kdeconnect/Device.kt b/src/main/java/org/kde/kdeconnect/Device.kt
index d6378419f..9e35c26bb 100644
--- a/src/main/java/org/kde/kdeconnect/Device.kt
+++ b/src/main/java/org/kde/kdeconnect/Device.kt
@@ -132,6 +132,9 @@ class Device : PacketReceiver {
fun supportsPacketType(type: String): Boolean =
NetworkPacket.PROTOCOL_PACKET_TYPES.contains(type) || deviceInfo.incomingCapabilities?.contains(type) ?: true
+ fun canSendPacketType(type: String): Boolean =
+ NetworkPacket.PROTOCOL_PACKET_TYPES.contains(type) || deviceInfo.outgoingCapabilities?.contains(type) ?: true
+
fun interface PluginsChangedListener {
fun onPluginsChanged(device: Device)
}
diff --git a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
index bd8503f27..ee9f2297c 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
@@ -51,7 +51,7 @@ class RunCommandActivity : AppCompatActivity() {
Log.e("RunCommand", "Error parsing JSON", e)
}
}
- commandList.sortBy { it.name }
+ commandList.sortBy { it.name.lowercase() }
val device = getInstance().getDevice(deviceId) ?: return
setContent {
@@ -92,7 +92,6 @@ class RunCommandActivity : AppCompatActivity() {
toast.show()
}
-
private fun updateList() {
commandList.removeAll(commandList)
val plugin =
@@ -105,7 +104,7 @@ class RunCommandActivity : AppCompatActivity() {
Log.e("RunCommand", "Error parsing JSON", e)
}
}
- commandList.sortBy { it.name }
+ commandList.sortBy { it.name.lowercase() }
}
override fun onResume() {
@@ -117,4 +116,9 @@ class RunCommandActivity : AppCompatActivity() {
return
}
}
-}
\ No newline at end of file
+}
+
+data class RunCommandOutput(
+ var string: String,
+ val isCommand: Boolean
+)
\ No newline at end of file
diff --git a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
index dbd56120c..3eb57fabe 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
@@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2015 Albert Vaca Cintora <[email protected]>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-*/
+ */
package org.kde.kdeconnect.plugins.runcommand;
@@ -17,6 +17,8 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.compose.runtime.MutableState;
+import androidx.compose.runtime.snapshots.SnapshotStateList;
import androidx.preference.PreferenceManager;
import org.apache.commons.collections4.iterators.IteratorIterable;
@@ -34,22 +36,26 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
import kotlin.Unit;
+import kotlin.jvm.functions.Function1;
@PluginFactory.LoadablePlugin
public class RunCommandPlugin extends Plugin {
private final static String PACKET_TYPE_RUNCOMMAND = "kdeconnect.runcommand";
+ public final static String PACKET_TYPE_RUNCOMMAND_OUTPUT = "kdeconnect.runcommand.output";
private final static String PACKET_TYPE_RUNCOMMAND_REQUEST = "kdeconnect.runcommand.request";
public final static String KEY_COMMANDS_PREFERENCE = "commands_preference_";
private final ArrayList<JSONObject> commandList = new ArrayList<>();
private final ArrayList<CommandsChangedCallback> callbacks = new ArrayList<>();
private final ArrayList<CommandEntry> commandItems = new ArrayList<>();
+ private final SnapshotStateList<RunCommandOutput> output = new SnapshotStateList<>();
private SharedPreferences sharedPreferences;
-
private boolean canAddCommand;
public void addCommandsUpdatedCallback(CommandsChangedCallback newCallback) {
@@ -64,6 +70,36 @@ public class RunCommandPlugin extends Plugin {
void update();
}
+ public MutableState<Boolean> commandRunning = new MutableState<>() {
+ private boolean value = false;
+
+ @Override
+ public Boolean getValue() {
+ return value;
+ }
+
+ @Override
+ public void setValue(Boolean aBoolean) {
+ value = aBoolean;
+ }
+
+ // You need to override these, but they are not being used
+ @Override
+ public Boolean component1() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public Function1<Boolean, Unit> component2() {
+ return null;
+ }
+ };
+
+ public SnapshotStateList<RunCommandOutput> getOutput() {
+ return output;
+ }
+
public ArrayList<JSONObject> getCommandList() {
return commandList;
}
@@ -112,7 +148,6 @@ public class RunCommandPlugin extends Plugin {
@Override
public boolean onPacketReceived(@NonNull NetworkPacket np) {
-
if (np.has("commandList")) {
commandList.clear();
try {
@@ -161,6 +196,34 @@ public class RunCommandPlugin extends Plugin {
canAddCommand = np.getBoolean("canAddCommand", false);
+ return true;
+ } else if (np.has("stdout")) {
+ List<String> stdOut = np.getStringList("stdout");
+ List<String> stdErr = np.getStringList("stderr");
+ assert stdOut != null;
+ assert stdErr != null;
+ for (String line : stdOut) {
+ Log.d("STDOUT", "Line:" + line);
+ output.add(new RunCommandOutput(line, false));
+ }
+ for (String line : stdErr) {
+ Log.d("STDERR", "Line:" + line);
+ output.add(new RunCommandOutput(line, false));
+ }
+
+ return true;
+ } else if (np.has("commandFinished")) {
+ commandRunning.setValue(false);
+
+ RunCommandOutput newCommand = new RunCommandOutput(">", true);
+ if (Objects.equals(output.get(output.size() - 1), newCommand)) {
+ return true;
+ }
+
+ output.removeAll(output.stream().filter(output -> output.getString().equals(">")).collect(Collectors.toList()));
+
+ output.add(newCommand);
+
return true;
}
return false;
@@ -168,7 +231,7 @@ public class RunCommandPlugin extends Plugin {
@Override
public @NonNull String[] getSupportedPacketTypes() {
- return new String[]{PACKET_TYPE_RUNCOMMAND};
+ return new String[]{PACKET_TYPE_RUNCOMMAND, PACKET_TYPE_RUNCOMMAND_OUTPUT};
}
@Override
@@ -177,9 +240,11 @@ public class RunCommandPlugin extends Plugin {
}
public void runCommand(String cmdKey) {
+ Log.d("RunCommand", "Sending " + cmdKey);
NetworkPacket np = new NetworkPacket(PACKET_TYPE_RUNCOMMAND_REQUEST);
np.set("key", cmdKey);
getDevice().sendPacket(np);
+ commandRunning.setValue(true);
}
private void requestCommandList() {
@@ -198,4 +263,9 @@ public class RunCommandPlugin extends Plugin {
getDevice().sendPacket(np);
}
+ void sendStop() {
+ NetworkPacket np = new NetworkPacket(PACKET_TYPE_RUNCOMMAND_REQUEST);
+ np.set("stop", true);
+ getDevice().sendPacket(np);
+ }
}
diff --git a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
index 9d276cd20..a8e536cfc 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
@@ -11,16 +11,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -28,6 +33,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,6 +50,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -67,6 +75,7 @@ fun RunCommandScreen(
val context = LocalContext.current
var showDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
+ val outputList = remember { plugin.output }
DisposableEffect(plugin) {
val callback = RunCommandPlugin.CommandsChangedCallback {
@@ -132,6 +141,11 @@ fun RunCommandScreen(
.padding(it)
.fillMaxSize()
) {
+ if (device.canSendPacketType(RunCommandPlugin.PACKET_TYPE_RUNCOMMAND_OUTPUT)) {
+ item {
+ OutputCard(outputList, plugin)
+ }
+ }
items(commandList) { command ->
var menuExpanded by remember { mutableStateOf(false) }
var pressOffset by remember { mutableStateOf(IntOffset.Zero) }
@@ -217,6 +231,87 @@ fun RunCommandScreen(
}
}
+@Composable
+private fun OutputCard(
+ outputList: SnapshotStateList<RunCommandOutput>,
+ plugin: RunCommandPlugin
+) {
+ val state = rememberLazyListState()
+ val coroutineScope = rememberCoroutineScope()
+ val showStopButton by remember { plugin.commandRunning }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(300.dp)
+ .padding(horizontal = 20.dp, vertical = 10.dp)
+ ) {
+ if (outputList.isNotEmpty()) {
+ Box {
+ LazyColumn(
+ modifier = Modifier
+ .padding(horizontal = 15.dp, vertical = 5.dp)
+ .fillMaxWidth(),
+ state = state
+ ) {
+ items(outputList) { text ->
+ Text(
+ modifier = Modifier
+ .fillMaxWidth(),
+ text = text.string,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (text.isCommand) FontWeight.ExtraBold else FontWeight.Normal
+ )
+ }
+ }
+ if (showStopButton) {
+ Column(
+ modifier = Modifier.padding(5.dp).fillMaxSize(),
+ horizontalAlignment = Alignment.End,
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ IconButton(onClick = { plugin.sendStop() }) {
+ CircularProgressIndicator()
+ Icon(
+ painterResource(R.drawable.ic_stop),
+ stringResource(R.string.runcommand_stop)
+ )
+ }
+ }
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(20.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.runcommand_output_no_output),
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.ExtraBold,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = stringResource(R.string.runcommand_output_no_output_desc),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(outputList.size) {
+ if (outputList.isNotEmpty()) {
+ coroutineScope.launch {
+ state.animateScrollToItem(outputList.size - 1)
+ }
+ }
+ }
+}
+
@Composable
fun RunCommandAppBar(name: String, onBackPressedDispatcher: OnBackPressedDispatcher) {
KdeTopAppBar(
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 0cbdf41a6..dc38b0e18 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -656,5 +656,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="pref_presenter_enable_volume_keys_summary">On standby not supported on Android version between 13 and 15</string>
<string name="presenter_volume_keys_foreground_tip">You can leave the screen on to use the volume keys to go to the previous/next slide</string>
<string name="presenter_volume_keys_tip">You can leave the screen on or lock your device to use the volume keys to go to the previous/next slide</string>
+ <string name="runcommand_output_no_output_desc">The output from the commands will appear here.</string>
+ <string name="runcommand_output_no_output">No commands executed</string>
+ <string name="runcommand_stop">Stop the current command</string>
</resources>