[network/kdeconnect-android] src/main/java/org/kde/kdeconnect/plugins/runcommand: Allow seeing the output of multiple commands at once

Albert Vaca Cintora <[email protected]> Tue, 4 Aug 2026 12:57:04 +0000 (UTC)
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit c8a59f0d6f2d34911abac4a96ff07ab493abf0af by Albert Vaca Cintora.
Committed on 04/08/2026 at 12:56.
Pushed by albertvaka into branch 'master'.

Allow seeing the output of multiple commands at once

M  +22   -2    src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
M  +49   -41   src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
M  +6    -1    src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt

https://invent.kde.org/network/kdeconnect-android/-/commit/c8a59f0d6f2d34911abac4a96ff07ab493abf0af

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 ee9f2297c..d46dae4e2 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandActivity.kt
@@ -13,7 +13,9 @@ import android.util.Log
 import android.widget.Toast
 import androidx.activity.compose.setContent
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.platform.Clipboard
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.toClipEntry
@@ -118,7 +120,25 @@ class RunCommandActivity : AppCompatActivity() {
     }
 }
 
+/**
+ * Kotlin's `mutableStateOf` isn't directly callable from Java (its default arguments aren't
+ * exposed as Java overloads), so this is a small wrapper to be called from Java.
+ */
+fun mutableBooleanStateFor(initial: Boolean): MutableState<Boolean> = mutableStateOf(initial)
+
+enum class RunCommandStatus {
+    STDERR,
+    STDOUT,
+    COMMAND_RUNNING,
+    COMMAND_SUCCESSFUL,
+    COMMAND_FAILED,
+}
+
 data class RunCommandOutput(
-    var string: String,
+    val commandStatus: RunCommandStatus,
+    val string: String,
+    val id: Int,
+) {
     val isCommand: Boolean
-)
\ No newline at end of file
+        get() = commandStatus == RunCommandStatus.COMMAND_RUNNING || commandStatus == RunCommandStatus.COMMAND_SUCCESSFUL || commandStatus == RunCommandStatus.COMMAND_FAILED
+}
\ 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 3eb57fabe..593015cbf 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandPlugin.java
@@ -35,12 +35,13 @@ import org.kde.kdeconnect_tp.R;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
+import java.util.Map;
+import java.util.Set;
 
 import kotlin.Unit;
-import kotlin.jvm.functions.Function1;
 
 @PluginFactory.LoadablePlugin
 public class RunCommandPlugin extends Plugin {
@@ -55,6 +56,13 @@ public class RunCommandPlugin extends Plugin {
     private final ArrayList<CommandEntry> commandItems = new ArrayList<>();
     private final SnapshotStateList<RunCommandOutput> output = new SnapshotStateList<>();
 
+    // Ids of the commands that are currently running on the remote device. Currently only
+    // used to show/hide the stop button (when this is empty).
+    private final Set<Integer> runningProcesses = new LinkedHashSet<>();
+    // Position in `output` of the "$ command" line for each running/finished command id, so
+    // we can go back and update its color once we know whether it succeeded or failed.
+    private final Map<Integer, Integer> commandLineIndexById = new HashMap<>();
+
     private SharedPreferences sharedPreferences;
     private boolean canAddCommand;
 
@@ -70,31 +78,8 @@ 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;
-        }
-    };
+    // Replace with mutableStateOf when we migrate this class to Kotlin.
+    public final MutableState<Boolean> commandRunning = RunCommandActivityKt.mutableBooleanStateFor(false);
 
     public SnapshotStateList<RunCommandOutput> getOutput() {
         return output;
@@ -197,33 +182,57 @@ public class RunCommandPlugin extends Plugin {
             canAddCommand = np.getBoolean("canAddCommand", false);
 
             return true;
-        } else if (np.has("stdout")) {
+        } else if (np.has("commandStarted")) {
+            int id = np.getInt("id");
+            String command = np.getString("command");
+
+            Log.i("RunCommandPlugin", "commandStarted " + id);
+
+            runningProcesses.add(id);
+            commandRunning.setValue(true);
+
+            output.add(new RunCommandOutput(RunCommandStatus.COMMAND_RUNNING, "$ " + command, id));
+            commandLineIndexById.put(id, output.size() - 1);
+
+            return true;
+        } else if (np.has("commandOutput")) {
             List<String> stdOut = np.getStringList("stdout");
             List<String> stdErr = np.getStringList("stderr");
+            int id = np.getInt("id");
             assert stdOut != null;
             assert stdErr != null;
             for (String line : stdOut) {
                 Log.d("STDOUT", "Line:" + line);
-                output.add(new RunCommandOutput(line, false));
+                output.add(new RunCommandOutput(RunCommandStatus.STDOUT, line, id));
             }
             for (String line : stdErr) {
                 Log.d("STDERR", "Line:" + line);
-                output.add(new RunCommandOutput(line, false));
+                output.add(new RunCommandOutput(RunCommandStatus.STDERR, line, id));
             }
 
             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;
+            int id = np.getInt("id");
+            boolean success = np.getBoolean("success", true);
+
+            Log.i("RunCommandPlugin", "commandFinished " + id);
+            runningProcesses.remove(id);
+            commandRunning.setValue(!runningProcesses.isEmpty());
+
+            Integer index = commandLineIndexById.remove(id);
+            if (index != null && index < output.size()) {
+                RunCommandOutput commandLine = output.get(index);
+                // Build a brand-new instance instead of mutating commandLine in place: SnapshotStateList
+                // doesn't notify Compose when calling output.set() with the same (mutated) reference.
+                // When we migrate this class to Kotlin this can be simplified to commandLine.copy() where
+                // we only change its commandStatus.
+                output.set(index, new RunCommandOutput(
+                        success ? RunCommandStatus.COMMAND_SUCCESSFUL : RunCommandStatus.COMMAND_FAILED,
+                        commandLine.getString(),
+                        id
+                ));
             }
 
-            output.removeAll(output.stream().filter(output -> output.getString().equals(">")).collect(Collectors.toList()));
-
-            output.add(newCommand);
-
             return true;
         }
         return false;
@@ -244,7 +253,6 @@ public class RunCommandPlugin extends Plugin {
         NetworkPacket np = new NetworkPacket(PACKET_TYPE_RUNCOMMAND_REQUEST);
         np.set("key", cmdKey);
         getDevice().sendPacket(np);
-        commandRunning.setValue(true);
     }
 
     private void requestCommandList() {
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 a8e536cfc..8c9e0f1f2 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/runcommand/RunCommandScreen.kt
@@ -260,7 +260,12 @@ private fun OutputCard(
                                 .fillMaxWidth(),
                             text = text.string,
                             style = MaterialTheme.typography.bodyLarge,
-                            fontWeight = if (text.isCommand) FontWeight.ExtraBold else FontWeight.Normal
+                            fontWeight = if (text.isCommand) FontWeight.ExtraBold else FontWeight.Normal,
+                            color = when (text.commandStatus) {
+                                RunCommandStatus.COMMAND_SUCCESSFUL -> Color(0xFF2E7D32)
+                                RunCommandStatus.COMMAND_FAILED -> MaterialTheme.colorScheme.error
+                                else -> Color.Unspecified
+                            }
                         )
                     }
                 }