From 801b6451edf5ae5a6984c2b3a9891291e1269c04 Mon Sep 17 00:00:00 2001 From: Daniel Weigl Date: Tue, 9 Mar 2021 16:47:15 +0000 Subject: [PATCH] Accept data via Intent and pass them on to the host as keystrokes Adds new functionality to the MousepadPlugin to accept data via Intent and pass them on to the host as keystrokes via the existing MousePadPlugin.PACKET_TYPE_MOUSEPAD_REQUEST PackageType eg. to easily send OTP codes from the phone to the Desktop --- AndroidManifest.xml | 14 ++ res/layout/activity_sendkeystrokes.xml | 49 +++++ res/values/strings.xml | 20 ++ res/xml/mousepadplugin_preferences.xml | 24 +++ .../LongSummaryPreferenceCategory.java | 35 ++++ .../kdeconnect/Helpers/SafeTextChecker.java | 42 ++++ .../SendKeystrokesToHostActivity.java | 179 ++++++++++++++++++ .../Helpers/SafeTextCheckerTest.java | 36 ++++ 8 files changed, 399 insertions(+) create mode 100644 res/layout/activity_sendkeystrokes.xml create mode 100644 src/org/kde/kdeconnect/Helpers/LongSummaryPreferenceCategory.java create mode 100644 src/org/kde/kdeconnect/Helpers/SafeTextChecker.java create mode 100644 src/org/kde/kdeconnect/Plugins/MousePadPlugin/SendKeystrokesToHostActivity.java create mode 100644 tests/org/kde/kdeconnect/Helpers/SafeTextCheckerTest.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 3dd0eacb..2bf6199c 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -255,6 +255,20 @@ android:name="android.support.PARENT_ACTIVITY" android:value="org.kde.kdeconnect.UserInterface.MainActivity" /> + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 3c6119a5..ed15944d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -110,6 +110,26 @@ strong stronger + + Send keystrokes to + Send keystrokes to host + Sending keystrokes is disabled - enable it in the settings + + Sent %1s to device %2s + + This module allows other apps to share text segments as keystrokes which will get send to the connected host + Send Keystrokes + Enable Keystrokes sending + + Send safe text immediately + Send short only-numeric strings without confirmation + + + Send as keystrokes + category_send_keystrokes + pref_sendkeystrokes_enabled + pref_send_safe_text_immediately + Connected devices Available devices Remembered devices diff --git a/res/xml/mousepadplugin_preferences.xml b/res/xml/mousepadplugin_preferences.xml index fa5806cb..7a2eaa71 100644 --- a/res/xml/mousepadplugin_preferences.xml +++ b/res/xml/mousepadplugin_preferences.xml @@ -46,4 +46,28 @@ android:defaultValue="false" android:key="@string/mousepad_scroll_direction" android:title="@string/mousepad_scroll_direction_title" /> + + + + + + + + + \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Helpers/LongSummaryPreferenceCategory.java b/src/org/kde/kdeconnect/Helpers/LongSummaryPreferenceCategory.java new file mode 100644 index 00000000..fd738b5b --- /dev/null +++ b/src/org/kde/kdeconnect/Helpers/LongSummaryPreferenceCategory.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2021 Daniel Weigl + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Helpers; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceViewHolder; + +// the default Preference Category only shows a one-line summary +public class LongSummaryPreferenceCategory extends PreferenceCategory { + public LongSummaryPreferenceCategory(Context ctx, AttributeSet attrs, int defStyle) { + super(ctx, attrs, defStyle); + } + + public LongSummaryPreferenceCategory(Context ctx, AttributeSet attrs) { + super(ctx, attrs); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + TextView summary = (TextView) holder.findViewById(android.R.id.summary); + summary.setMaxLines(3); + summary.setSingleLine(false); + } + +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Helpers/SafeTextChecker.java b/src/org/kde/kdeconnect/Helpers/SafeTextChecker.java new file mode 100644 index 00000000..66ef6fd5 --- /dev/null +++ b/src/org/kde/kdeconnect/Helpers/SafeTextChecker.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2021 Daniel Weigl + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Helpers; + +public class SafeTextChecker { + + private final String safeChars; + private final Integer maxLength; + + public SafeTextChecker(String safeChars, Integer maxLength) { + this.safeChars = safeChars; + this.maxLength = maxLength; + } + + + // is used by the SendKeystrokes functionality to evaluate if a to-be-send text is safe for + // sending without user confirmation + // only allow sending text that can not harm any connected desktop (like "format c:\n" / "rm -rf\n",...) + public boolean isSafe(String content) { + if (content == null) { + return false; + } + + if (content.length() > maxLength) { + return false; + } + + for (int i = 0; i < content.length(); i++) { + String charAtPos = content.substring(i, i + 1); + if (!safeChars.contains(charAtPos)) { + return false; + } + } + + // we are happy with the string + return true; + } +} diff --git a/src/org/kde/kdeconnect/Plugins/MousePadPlugin/SendKeystrokesToHostActivity.java b/src/org/kde/kdeconnect/Plugins/MousePadPlugin/SendKeystrokesToHostActivity.java new file mode 100644 index 00000000..ff6474ec --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/MousePadPlugin/SendKeystrokesToHostActivity.java @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2021 Daniel Weigl + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Plugins.MousePadPlugin; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; + +import org.kde.kdeconnect.BackgroundService; +import org.kde.kdeconnect.Device; +import org.kde.kdeconnect.Helpers.SafeTextChecker; +import org.kde.kdeconnect.NetworkPacket; +import org.kde.kdeconnect.UserInterface.List.EntryItem; +import org.kde.kdeconnect.UserInterface.List.ListAdapter; +import org.kde.kdeconnect.UserInterface.List.SectionItem; +import org.kde.kdeconnect.UserInterface.ThemeUtil; +import org.kde.kdeconnect_tp.R; +import org.kde.kdeconnect_tp.databinding.ActivitySendkeystrokesBinding; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class SendKeystrokesToHostActivity extends AppCompatActivity { + + // text with these length and content can be send without user confirmation. + // more or less chosen arbitrarily, so that we allow short PINS and TANS without interruption (if only one device is connected) + // but also be on the safe side, so that apps cant send any harmful content + public static final int MAX_SAFE_LENGTH = 8; + public static final String SAFE_CHARS = "1234567890"; + + + private ActivitySendkeystrokesBinding binding; + private boolean contentIsOkay; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ThemeUtil.setUserPreferredTheme(this); + + binding = ActivitySendkeystrokesBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + } + + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1) // needed for this.getReferrer() + @Override + protected void onStart() { + super.onStart(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + if (!prefs.getBoolean(getString(R.string.pref_sendkeystrokes_enabled), true)) { + Toast.makeText(getApplicationContext(), R.string.sendkeystrokes_disabled_toast, Toast.LENGTH_LONG).show(); + finish(); + } else { + final Intent intent = getIntent(); + String type = intent.getType(); + + if ("text/x-keystrokes".equals(type)) { + String toSend = intent.getStringExtra(Intent.EXTRA_TEXT); + binding.textToSend.setText(toSend); + + // if the preference send_safe_text_immediately is true, we will check if exactly one + // device is connected and send the text to it without user confirmation, to make sending of + // short and safe text like PINs/TANs very fluent + // + // (contentIsOkay gets used in updateComputerList again) + if (prefs.getBoolean(getString(R.string.pref_send_safe_text_immediately), true)) { + SafeTextChecker safeTextChecker = new SafeTextChecker(SAFE_CHARS, MAX_SAFE_LENGTH); + contentIsOkay = safeTextChecker.isSafe(toSend); + } else { + contentIsOkay = false; + } + + // If we trust the sending app, check if there is only one device paired / reachable... + if (contentIsOkay) { + List reachableDevices = BackgroundService.getInstance().getDevices().values().stream() + .filter(Device::isReachable) + .limit(2) // we only need the first two; if its more than one, we need to show the user the device-selection + .collect(Collectors.toList()); + + // if its exactly one just send the text to it + if (reachableDevices.size() == 1) { + // send the text and close this activity + sendKeys(reachableDevices.get(0)); + this.finish(); + return; + } + } + + + // subscribe to new connected devices + BackgroundService.RunCommand(this, service -> { + service.onNetworkChange(); + service.addDeviceListChangedCallback("SendKeystrokesToHostActivity", this::updateComputerList); + }); + + // list all currently connected devices + updateComputerList(); + + } else { + Toast.makeText(getApplicationContext(), R.string.sendkeystrokes_wrong_data, Toast.LENGTH_LONG).show(); + finish(); + } + } + } + + @Override + protected void onStop() { + BackgroundService.RunCommand(this, service -> service.removeDeviceListChangedCallback("SendKeystrokesToHostActivity")); + super.onStop(); + } + + private void sendKeys(Device deviceId) { + String toSend; + if (binding.textToSend.getText() != null && (toSend = binding.textToSend.getText().toString().trim()).length() > 0) { + final NetworkPacket np = new NetworkPacket(MousePadPlugin.PACKET_TYPE_MOUSEPAD_REQUEST); + np.set("key", toSend); + BackgroundService.RunWithPlugin(this, deviceId.getDeviceId(), MousePadPlugin.class, plugin -> plugin.sendKeyboardPacket(np)); + Toast.makeText( + getApplicationContext(), + getString(R.string.sendkeystrokes_sent_text, toSend, deviceId.getName()), + Toast.LENGTH_SHORT + ).show(); + + } + } + + + private void updateComputerList() { + BackgroundService.RunCommand(this, service -> { + + Collection devices = service.getDevices().values(); + final ArrayList devicesList = new ArrayList<>(); + final ArrayList items = new ArrayList<>(); + + SectionItem section = new SectionItem(getString(R.string.sendkeystrokes_send_to)); + items.add(section); + + for (Device d : devices) { + if (d.isReachable() && d.isPaired()) { + devicesList.add(d); + items.add(new EntryItem(d.getName())); + section.isEmpty = false; + } + } + runOnUiThread(() -> { + binding.devicesList.setAdapter(new ListAdapter(SendKeystrokesToHostActivity.this, items)); + binding.devicesList.setOnItemClickListener((adapterView, view, i, l) -> { + Device device = devicesList.get(i - 1); // NOTE: -1 because of the title! + sendKeys(device); + this.finish(); // close the activity + }); + }); + + // only one device is connected and we trust the text to send -> send it and close the activity. + // Usually we already check it in `onStart` - but if the BackgroundService was not started/connected to the host + // it will not have the deviceList in memory. Use this callback as second chance (but it will flicker a bit, because the activity might + // already been visible and get closed again quickly) + if (devicesList.size() == 1 && contentIsOkay) { + Device device = devicesList.get(0); + sendKeys(device); + this.finish(); // close the activity + } + }); + } +} + diff --git a/tests/org/kde/kdeconnect/Helpers/SafeTextCheckerTest.java b/tests/org/kde/kdeconnect/Helpers/SafeTextCheckerTest.java new file mode 100644 index 00000000..c73f47bc --- /dev/null +++ b/tests/org/kde/kdeconnect/Helpers/SafeTextCheckerTest.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2021 Daniel Weigl + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Helpers; + +import junit.framework.TestCase; + +public class SafeTextCheckerTest extends TestCase { + + public void testSafeTextChecker() { + SafeTextChecker safeTextChecker = new SafeTextChecker("1234567890", 8); + assertIsOkay("123456", safeTextChecker); + assertIsOkay("123", safeTextChecker); + assertIsOkay("12345678", safeTextChecker); + assertIsOkay("", safeTextChecker); + + assertIsNotOkay(null, safeTextChecker); + assertIsNotOkay("123456789", safeTextChecker); + assertIsNotOkay("123o", safeTextChecker); + assertIsNotOkay("O123", safeTextChecker); // its a O not a 0 + assertIsNotOkay("o", safeTextChecker); + assertIsNotOkay(" ", safeTextChecker); + assertIsNotOkay("12345678 ", safeTextChecker); + } + + private void assertIsOkay(String text, SafeTextChecker stc) { + assertTrue(text + " should be okay", stc.isSafe(text)); + } + + private void assertIsNotOkay(String text, SafeTextChecker stc) { + assertFalse(text + " should not be okay", stc.isSafe(text)); + } +} \ No newline at end of file