2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 01:51:47 +00:00

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
This commit is contained in:
Daniel Weigl 2021-03-09 16:47:15 +00:00 committed by Simon Redman
parent f41360a7fc
commit 801b6451ed
8 changed files with 399 additions and 0 deletions

View File

@ -255,6 +255,20 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.kde.kdeconnect.UserInterface.MainActivity" /> android:value="org.kde.kdeconnect.UserInterface.MainActivity" />
</activity> </activity>
<activity
android:name="org.kde.kdeconnect.Plugins.MousePadPlugin.SendKeystrokesToHostActivity"
android:label="@string/pref_plugin_mousepad_send_keystrokes"
android:parentActivityName="org.kde.kdeconnect.UserInterface.MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.kde.kdeconnect.UserInterface.MainActivity" />
<!-- Accept data with "text/x-keystrokes" to send the text to the connected host and emulate keystrokes -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/x-keystrokes"/>
</intent-filter>
</activity>
<activity <activity
android:name="org.kde.kdeconnect.Plugins.PresenterPlugin.PresenterActivity" android:name="org.kde.kdeconnect.Plugins.PresenterPlugin.PresenterActivity"
android:configChanges="orientation|keyboardHidden|screenSize" android:configChanges="orientation|keyboardHidden|screenSize"

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/sendkeystrokes_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
android:orientation="vertical"
android:padding="4dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/textToSend"
style="@style/Widget.MaterialComponents.TextInputEditText.FilledBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:cursorVisible="true"
android:hint="@string/sendkeystrokes_textbox_hint"
android:inputType="text"
android:lines="1"
android:maxLines="1"
android:scrollHorizontally="true"
android:text="" />
</com.google.android.material.textfield.TextInputLayout>
<ListView
android:id="@+id/devices_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:addStatesFromChildren="true"
android:divider="@null"
android:dividerHeight="0dp"
android:orientation="vertical"
android:paddingTop="16dip"
tools:context=".MainActivity" />
</LinearLayout>

View File

@ -110,6 +110,26 @@
<item>strong</item> <item>strong</item>
<item>stronger</item> <item>stronger</item>
</string-array> </string-array>
<string name="sendkeystrokes_send_to">Send keystrokes to</string>
<string name="sendkeystrokes_textbox_hint">Send keystrokes to host</string>
<string name="sendkeystrokes_disabled_toast">Sending keystrokes is disabled - enable it in the settings</string>
<string name="sendkeystrokes_wrong_data"><![CDATA[Invalid mime type - needs to be \'text/x-keystrokes\']]></string>
<string name="sendkeystrokes_sent_text">Sent %1s to device %2s</string>
<string name="sendkeystrokes_pref_category_summary">This module allows other apps to share text segments as keystrokes which will get send to the connected host</string>
<string name="sendkeystrokes_pref_category_title">Send Keystrokes</string>
<string name="sendkeystrokes_pref_enabled">Enable Keystrokes sending</string>
<string name="sendkeystrokes_pref_enabled_summary"><![CDATA[Listen for data with mime type \'text/x-keystrokes\']]></string>
<string name="sendkeystrokes_safe_text_enabled">Send safe text immediately</string>
<string name="sendkeystrokes_safe_text_enabled_summary">Send short only-numeric strings without confirmation</string>
<string name="pref_plugin_mousepad_send_keystrokes">Send as keystrokes</string>
<string name="sendkeystrokes_pref_category" translatable="false">category_send_keystrokes</string>
<string name="pref_sendkeystrokes_enabled" translatable="false">pref_sendkeystrokes_enabled</string>
<string name="pref_send_safe_text_immediately" translatable="false">pref_send_safe_text_immediately</string>
<string name="category_connected_devices">Connected devices</string> <string name="category_connected_devices">Connected devices</string>
<string name="category_not_paired_devices">Available devices</string> <string name="category_not_paired_devices">Available devices</string>
<string name="category_remembered_devices">Remembered devices</string> <string name="category_remembered_devices">Remembered devices</string>

View File

@ -46,4 +46,28 @@
android:defaultValue="false" android:defaultValue="false"
android:key="@string/mousepad_scroll_direction" android:key="@string/mousepad_scroll_direction"
android:title="@string/mousepad_scroll_direction_title" /> android:title="@string/mousepad_scroll_direction_title" />
<org.kde.kdeconnect.Helpers.LongSummaryPreferenceCategory
android:key="@string/sendkeystrokes_pref_category"
android:summary="@string/sendkeystrokes_pref_category_summary"
android:title="@string/sendkeystrokes_pref_category_title">
<CheckBoxPreference
android:id="@+id/pref_keystrokes_enable"
android:defaultValue="true"
android:key="@string/pref_sendkeystrokes_enabled"
android:title="@string/sendkeystrokes_pref_enabled"
android:summary="@string/sendkeystrokes_pref_enabled_summary"
/>
<CheckBoxPreference
android:id="@+id/pref_send_safe_text_immediately"
android:defaultValue="true"
android:key="@string/pref_send_safe_text_immediately"
android:title="@string/sendkeystrokes_safe_text_enabled"
android:summary="@string/sendkeystrokes_safe_text_enabled_summary"
/>
</org.kde.kdeconnect.Helpers.LongSummaryPreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* 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);
}
}

View File

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* 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;
}
}

View File

@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* 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<Device> 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<Device> devices = service.getDevices().values();
final ArrayList<Device> devicesList = new ArrayList<>();
final ArrayList<ListAdapter.Item> 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
}
});
}
}

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* 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));
}
}