From 7286b8a24a28f985183555590ac86a5c20f2343e Mon Sep 17 00:00:00 2001 From: Maxim Leshchenko Date: Thu, 24 Jun 2021 23:53:00 +0300 Subject: [PATCH] Add ability to run commands from Android 11 power menu --- AndroidManifest.xml | 7 + build.gradle | 4 + res/values/strings.xml | 1 + .../RunCommandControlsProviderService.kt | 194 ++++++++++++++++++ .../RunCommandPlugin/RunCommandPlugin.java | 19 ++ 5 files changed, 225 insertions(+) create mode 100644 src/org/kde/kdeconnect/Plugins/RunCommandPlugin/RunCommandControlsProviderService.kt 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);