[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>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.