diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index aa8df343..02ac75cb 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -396,6 +396,13 @@
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
index d9f79408..73f27a72 100644
--- a/build.gradle
+++ b/build.gradle
@@ -198,6 +198,10 @@ dependencies {
testImplementation 'org.powermock:powermock-api-mockito2:2.0.0'
testImplementation 'org.mockito:mockito-core:2.23.0'
testImplementation 'org.skyscreamer:jsonassert:1.3.0'
+
+ // For device controls
+ implementation 'org.reactivestreams:reactive-streams:1.0.3'
+ implementation 'io.reactivex.rxjava2:rxjava:2.2.0'
}
repositories {
diff --git a/res/values/strings.xml b/res/values/strings.xml
index b4f4f7eb..bdc40e32 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -534,5 +534,6 @@
Everyone else who has contributed to KDE Connect over the years
Send clipboard
+ Tap to execute
diff --git a/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandControlsProviderService.kt b/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandControlsProviderService.kt
new file mode 100644
index 00000000..9ffef234
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandControlsProviderService.kt
@@ -0,0 +1,194 @@
+/*
+ * SPDX-FileCopyrightText: 2021 Maxim Leshchenko
+ *
+ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+ */
+
+package org.kde.kdeconnect.Plugins.RunCommandPlugin
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.SharedPreferences
+import android.graphics.drawable.Icon
+import android.service.controls.Control
+import android.service.controls.ControlsProviderService
+import android.service.controls.actions.CommandAction
+import android.service.controls.actions.ControlAction
+import android.service.controls.templates.StatelessTemplate
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.preference.PreferenceManager
+import io.reactivex.Flowable
+import io.reactivex.processors.ReplayProcessor
+import org.json.JSONArray
+import org.json.JSONException
+import org.kde.kdeconnect.BackgroundService
+import org.kde.kdeconnect.Device
+import org.kde.kdeconnect.UserInterface.MainActivity
+import org.kde.kdeconnect_tp.R
+import org.reactivestreams.FlowAdapters
+import java.util.*
+import java.util.concurrent.Flow
+import java.util.function.Consumer
+
+private class CommandEntryWithDevice(name: String, cmd: String, key: String, val device: Device) : CommandEntry(name, cmd, key)
+
+@RequiresApi(30)
+class RunCommandControlsProviderService : ControlsProviderService() {
+ private lateinit var updatePublisher: ReplayProcessor
+ private lateinit var sharedPreferences: SharedPreferences
+
+ override fun createPublisherForAllAvailable(): Flow.Publisher {
+ return FlowAdapters.toFlowPublisher(Flowable.fromIterable(getAllCommandsList().map { commandEntry ->
+ Control.StatelessBuilder(commandEntry.device.deviceId + "-" + commandEntry.key, getIntent(commandEntry.device))
+ .setTitle(commandEntry.name)
+ .setSubtitle(commandEntry.command)
+ .setStructure(commandEntry.device.name)
+ .setCustomIcon(Icon.createWithResource(this, R.drawable.run_command_plugin_icon_24dp))
+ .build()
+ }))
+ }
+
+ override fun createPublisherFor(controlIds: MutableList): Flow.Publisher {
+ updatePublisher = ReplayProcessor.create()
+
+ for (controlId in controlIds) {
+ val commandEntry = getCommandByControlId(controlId)
+ if (commandEntry != null && commandEntry.device.isReachable) {
+ updatePublisher.onNext(Control.StatefulBuilder(controlId, getIntent(commandEntry.device))
+ .setTitle(commandEntry.name)
+ .setSubtitle(commandEntry.command)
+ .setStructure(commandEntry.device.name)
+ .setStatus(Control.STATUS_OK)
+ .setStatusText(getString(R.string.tap_to_execute))
+ .setControlTemplate(StatelessTemplate(commandEntry.key))
+ .setCustomIcon(Icon.createWithResource(this, R.drawable.run_command_plugin_icon_24dp))
+ .build())
+ } else {
+ if (commandEntry != null && commandEntry.device.isPaired && !commandEntry.device.isReachable) {
+ updatePublisher.onNext(Control.StatefulBuilder(controlId, getIntent(commandEntry.device))
+ .setTitle(commandEntry.name)
+ .setSubtitle(commandEntry.command)
+ .setStructure(commandEntry.device.name)
+ .setStatus(Control.STATUS_DISABLED)
+ .setControlTemplate(StatelessTemplate(commandEntry.key))
+ .setCustomIcon(Icon.createWithResource(this, R.drawable.run_command_plugin_icon_24dp))
+ .build())
+ } else {
+ updatePublisher.onNext(Control.StatefulBuilder(controlId, getIntent(commandEntry?.device))
+ .setStatus(Control.STATUS_NOT_FOUND)
+ .build())
+ }
+ }
+ }
+
+ return FlowAdapters.toFlowPublisher(updatePublisher)
+ }
+
+ override fun performControlAction(controlId: String, action: ControlAction, consumer: Consumer) {
+ if (!this::updatePublisher.isInitialized) {
+ updatePublisher = ReplayProcessor.create()
+ }
+
+ if (action is CommandAction) {
+ val commandEntry = getCommandByControlId(controlId)
+ if (commandEntry != null) {
+ val plugin = BackgroundService.getInstance().getDevice(controlId.split("-")[0]).getPlugin(RunCommandPlugin::class.java)
+ if (plugin != null) {
+ plugin.runCommand(commandEntry.key)
+ consumer.accept(ControlAction.RESPONSE_OK)
+ } else {
+ consumer.accept(ControlAction.RESPONSE_FAIL)
+ }
+
+ updatePublisher.onNext(Control.StatefulBuilder(controlId, getIntent(commandEntry.device))
+ .setTitle(commandEntry.name)
+ .setSubtitle(commandEntry.command)
+ .setStructure(commandEntry.device.name)
+ .setStatus(Control.STATUS_OK)
+ .setStatusText(getString(R.string.tap_to_execute))
+ .setControlTemplate(StatelessTemplate(commandEntry.key))
+ .setCustomIcon(Icon.createWithResource(this, R.drawable.run_command_plugin_icon_24dp))
+ .build())
+ }
+ }
+ }
+
+ private fun getSavedCommandsList(device: Device): List {
+ if (!this::sharedPreferences.isInitialized) {
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
+ }
+
+ val commandList = mutableListOf()
+
+ return try {
+ val jsonArray = JSONArray(sharedPreferences.getString(RunCommandPlugin.KEY_COMMANDS_PREFERENCE + device.deviceId, "[]"))
+
+ for (index in 0 until jsonArray.length()) {
+ val jsonObject = jsonArray.getJSONObject(index)
+ commandList.add(CommandEntryWithDevice(jsonObject.getString("name"), jsonObject.getString("command"), jsonObject.getString("key"), device))
+ }
+
+ commandList
+ } catch (error: JSONException) {
+ Log.e("RunCommand", "Error parsing JSON", error)
+ listOf()
+ }
+ }
+
+ private fun getAllCommandsList(): List {
+ val commandList = mutableListOf()
+
+ for (device in BackgroundService.getInstance().devices.values) {
+ if (!device.isReachable) {
+ commandList.addAll(getSavedCommandsList(device))
+ continue
+ } else if (!device.isPaired) {
+ continue
+ }
+
+ val plugin = device.getPlugin(RunCommandPlugin::class.java)
+ if (plugin != null) {
+ for (jsonObject in plugin.commandList) {
+ try {
+ commandList.add(CommandEntryWithDevice(jsonObject.getString("name"), jsonObject.getString("command"), jsonObject.getString("key"), device))
+ } catch (error: JSONException) {
+ Log.e("RunCommand", "Error parsing JSON", error)
+ }
+ }
+ }
+ }
+
+ return Collections.unmodifiableList(commandList)
+ }
+
+ private fun getCommandByControlId(controlId: String): CommandEntryWithDevice? {
+ val controlIdParts = controlId.split("-")
+ val device = BackgroundService.getInstance().getDevice(controlIdParts[0])
+
+ if (!device.isPaired) return null
+
+ val commandList = if (device.isReachable) {
+ device?.getPlugin(RunCommandPlugin::class.java)?.commandList?.map { jsonObject ->
+ CommandEntryWithDevice(jsonObject.getString("name"), jsonObject.getString("command"), jsonObject.getString("key"), device)
+ }
+ } else {
+ getSavedCommandsList(device)
+ }
+
+ return commandList?.find { command ->
+ try {
+ command.key == controlIdParts[1]
+ } catch (error: JSONException) {
+ Log.e("RunCommand", "Error parsing JSON", error)
+ false
+ }
+ }
+ }
+
+ private fun getIntent(device: Device?): PendingIntent {
+ val intent = Intent(Intent.ACTION_MAIN).setClass(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.putExtra(MainActivity.EXTRA_DEVICE_ID, device?.deviceId)
+ return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ }
+}
\ No newline at end of file
diff --git a/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandPlugin.java b/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandPlugin.java
index 375e0e59..d763dec2 100644
--- a/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandPlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandPlugin.java
@@ -9,9 +9,12 @@ package org.kde.kdeconnect.Plugins.RunCommandPlugin;
import android.app.Activity;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
+import android.os.Build;
import android.util.Log;
+import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.NetworkPacket;
@@ -25,17 +28,21 @@ import java.util.Comparator;
import java.util.Iterator;
import androidx.core.content.ContextCompat;
+import androidx.preference.PreferenceManager;
@PluginFactory.LoadablePlugin
public class RunCommandPlugin extends Plugin {
private final static String PACKET_TYPE_RUNCOMMAND = "kdeconnect.runcommand";
private final static String PACKET_TYPE_RUNCOMMAND_REQUEST = "kdeconnect.runcommand.request";
+ public final static String KEY_COMMANDS_PREFERENCE = "commands_preference_";
private final ArrayList commandList = new ArrayList<>();
private final ArrayList callbacks = new ArrayList<>();
private final ArrayList commandItems = new ArrayList<>();
+ private SharedPreferences sharedPreferences;
+
private boolean canAddCommand;
public void addCommandsUpdatedCallback(CommandsChangedCallback newCallback) {
@@ -75,6 +82,7 @@ public class RunCommandPlugin extends Plugin {
@Override
public boolean onCreate() {
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context);
requestCommandList();
return true;
}
@@ -109,6 +117,17 @@ public class RunCommandPlugin extends Plugin {
Collections.sort(commandItems, Comparator.comparing(CommandEntry::getName));
+ // Used only by RunCommandControlsProviderService to display controls correctly even when device is not available
+ if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ JSONArray array = new JSONArray();
+
+ for (JSONObject command : commandList) {
+ array.put(command);
+ }
+
+ sharedPreferences.edit().putString(KEY_COMMANDS_PREFERENCE + device.getDeviceId(), array.toString()).apply();
+ }
+
Intent updateWidget = new Intent(context, RunCommandWidget.class);
context.sendBroadcast(updateWidget);