mirror of
https://github.com/KDE/kdeconnect-android
synced 2025-09-05 16:45:08 +00:00
429 lines
18 KiB
Java
429 lines
18 KiB
Java
/*
|
|
* SPDX-FileCopyrightText: 2017 Holger Kaelberer <holger.k@elberer.de>
|
|
*
|
|
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
|
*/
|
|
|
|
package org.kde.kdeconnect.Plugins.RemoteKeyboardPlugin;
|
|
|
|
import android.app.Activity;
|
|
import android.content.SharedPreferences;
|
|
import android.graphics.drawable.Drawable;
|
|
import android.os.SystemClock;
|
|
import android.preference.PreferenceManager;
|
|
import android.provider.Settings;
|
|
import android.util.Log;
|
|
import android.util.SparseIntArray;
|
|
import android.view.KeyEvent;
|
|
import android.view.inputmethod.EditorInfo;
|
|
import android.view.inputmethod.ExtractedText;
|
|
import android.view.inputmethod.ExtractedTextRequest;
|
|
import android.view.inputmethod.InputConnection;
|
|
|
|
import org.kde.kdeconnect.NetworkPacket;
|
|
import org.kde.kdeconnect.Plugins.Plugin;
|
|
import org.kde.kdeconnect.Plugins.PluginFactory;
|
|
import org.kde.kdeconnect.UserInterface.MainActivity;
|
|
import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment;
|
|
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
|
|
import org.kde.kdeconnect_tp.R;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.concurrent.locks.ReentrantLock;
|
|
|
|
import androidx.core.content.ContextCompat;
|
|
import androidx.core.util.Pair;
|
|
import androidx.fragment.app.DialogFragment;
|
|
|
|
@PluginFactory.LoadablePlugin
|
|
public class RemoteKeyboardPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener {
|
|
|
|
private final static String PACKET_TYPE_MOUSEPAD_REQUEST = "kdeconnect.mousepad.request";
|
|
private final static String PACKET_TYPE_MOUSEPAD_ECHO = "kdeconnect.mousepad.echo";
|
|
private final static String PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE = "kdeconnect.mousepad.keyboardstate";
|
|
|
|
/**
|
|
* Track and expose plugin instances to allow for a 'connected'-indicator in the IME:
|
|
*/
|
|
private static final ArrayList<RemoteKeyboardPlugin> instances = new ArrayList<>();
|
|
private static final ReentrantLock instancesLock = new ReentrantLock(true);
|
|
|
|
private static ArrayList<RemoteKeyboardPlugin> getInstances() {
|
|
return instances;
|
|
}
|
|
|
|
public static ArrayList<RemoteKeyboardPlugin> acquireInstances() {
|
|
instancesLock.lock();
|
|
return getInstances();
|
|
}
|
|
|
|
public static ArrayList<RemoteKeyboardPlugin> releaseInstances() {
|
|
instancesLock.unlock();
|
|
return getInstances();
|
|
}
|
|
|
|
public static boolean isConnected() {
|
|
return instances.size() > 0;
|
|
}
|
|
|
|
private static final SparseIntArray specialKeyMap = new SparseIntArray();
|
|
|
|
static {
|
|
int i = 0;
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_DEL); // 1
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_TAB); // 2
|
|
++i; //specialKeyMap.put(++i, KeyEvent.KEYCODE_ENTER, 12); // 3 is not used
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_LEFT); // 4
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_UP); // 5
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_RIGHT); // 6
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_DOWN); // 7
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_PAGE_UP); // 8
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_PAGE_DOWN); // 9
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_MOVE_HOME); // 10
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_MOVE_END); // 11
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_ENTER); // 12
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_FORWARD_DEL); // 13
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_ESCAPE); // 14
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_SYSRQ); // 15
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_SCROLL_LOCK); // 16
|
|
++i; // 17
|
|
++i; // 18
|
|
++i; // 19
|
|
++i; // 20
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F1); // 21
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F2); // 22
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F3); // 23
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F4); // 24
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F5); // 25
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F6); // 26
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F7); // 27
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F8); // 28
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F9); // 29
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F10); // 30
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F11); // 31
|
|
specialKeyMap.put(++i, KeyEvent.KEYCODE_F12); // 21
|
|
}
|
|
|
|
@Override
|
|
public boolean onCreate() {
|
|
Log.d("RemoteKeyboardPlugin", "Creating for device " + device.getName());
|
|
acquireInstances();
|
|
try {
|
|
instances.add(this);
|
|
} finally {
|
|
releaseInstances();
|
|
}
|
|
if (RemoteKeyboardService.instance != null)
|
|
RemoteKeyboardService.instance.handler.post(() -> RemoteKeyboardService.instance.updateInputView());
|
|
|
|
PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this);
|
|
|
|
final boolean editingOnly = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.remotekeyboard_editing_only), true);
|
|
final boolean visible = RemoteKeyboardService.instance != null && RemoteKeyboardService.instance.visible;
|
|
notifyKeyboardState(!editingOnly || visible);
|
|
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public void onDestroy() {
|
|
acquireInstances();
|
|
try {
|
|
if (instances.contains(this)) {
|
|
instances.remove(this);
|
|
if (instances.size() < 1 && RemoteKeyboardService.instance != null)
|
|
RemoteKeyboardService.instance.handler.post(() -> RemoteKeyboardService.instance.updateInputView());
|
|
}
|
|
} finally {
|
|
releaseInstances();
|
|
}
|
|
|
|
Log.d("RemoteKeyboardPlugin", "Destroying for device " + device.getName());
|
|
}
|
|
|
|
@Override
|
|
public String getDisplayName() {
|
|
return context.getString(R.string.pref_plugin_remotekeyboard);
|
|
}
|
|
|
|
@Override
|
|
public String getDescription() {
|
|
return context.getString(R.string.pref_plugin_remotekeyboard_desc);
|
|
}
|
|
|
|
@Override
|
|
public Drawable getIcon() {
|
|
return ContextCompat.getDrawable(context, R.drawable.ic_action_keyboard_24dp);
|
|
}
|
|
|
|
@Override
|
|
public boolean hasSettings() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public PluginSettingsFragment getSettingsFragment(Activity activity) {
|
|
return PluginSettingsFragment.newInstance(getPluginKey(), R.xml.remotekeyboardplugin_preferences);
|
|
}
|
|
|
|
@Override
|
|
public boolean hasMainActivity() {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public String[] getSupportedPacketTypes() {
|
|
return new String[]{PACKET_TYPE_MOUSEPAD_REQUEST};
|
|
}
|
|
|
|
@Override
|
|
public String[] getOutgoingPacketTypes() {
|
|
return new String[]{PACKET_TYPE_MOUSEPAD_ECHO, PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE};
|
|
}
|
|
|
|
private boolean isValidSpecialKey(int key) {
|
|
return (specialKeyMap.get(key, 0) > 0);
|
|
}
|
|
|
|
private int getCharPos(ExtractedText extractedText, char ch, boolean forward) {
|
|
int pos = -1;
|
|
if (extractedText != null) {
|
|
if (!forward) // backward
|
|
pos = extractedText.text.toString().lastIndexOf(" ", extractedText.selectionEnd - 2);
|
|
else
|
|
pos = extractedText.text.toString().indexOf(" ", extractedText.selectionEnd + 1);
|
|
return pos;
|
|
}
|
|
return pos;
|
|
}
|
|
|
|
private int currentTextLength(ExtractedText extractedText) {
|
|
if (extractedText != null)
|
|
return extractedText.text.length();
|
|
return -1;
|
|
}
|
|
|
|
private int currentCursorPos(ExtractedText extractedText) {
|
|
if (extractedText != null)
|
|
return extractedText.selectionEnd;
|
|
return -1;
|
|
}
|
|
|
|
private Pair<Integer, Integer> currentSelection(ExtractedText extractedText) {
|
|
if (extractedText != null)
|
|
return new Pair<>(extractedText.selectionStart, extractedText.selectionEnd);
|
|
return new Pair<>(-1, -1);
|
|
}
|
|
|
|
private boolean handleSpecialKey(int key, boolean shift, boolean ctrl, boolean alt) {
|
|
int keyEvent = specialKeyMap.get(key, 0);
|
|
if (keyEvent == 0)
|
|
return false;
|
|
InputConnection inputConn = RemoteKeyboardService.instance.getCurrentInputConnection();
|
|
// Log.d("RemoteKeyboardPlugin", "Handling special key " + key + " translated to " + keyEvent + " shift=" + shift + " ctrl=" + ctrl + " alt=" + alt);
|
|
|
|
// special sequences:
|
|
if (ctrl && (keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT)) {
|
|
// Ctrl + right -> next word
|
|
ExtractedText extractedText = inputConn.getExtractedText(new ExtractedTextRequest(), 0);
|
|
int pos = getCharPos(extractedText, ' ', keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT);
|
|
if (pos == -1)
|
|
pos = currentTextLength(extractedText);
|
|
else
|
|
pos++;
|
|
int startPos = pos;
|
|
int endPos = pos;
|
|
if (shift) { // Shift -> select word (otherwise jump)
|
|
Pair<Integer, Integer> sel = currentSelection(extractedText);
|
|
int cursor = currentCursorPos(extractedText);
|
|
// Log.d("RemoteKeyboardPlugin", "Selection (to right): " + sel.first + " / " + sel.second + " cursor: " + cursor);
|
|
startPos = cursor;
|
|
if (sel.first < cursor || // active selection from left to right -> grow
|
|
sel.first > sel.second) // active selection from right to left -> shrink
|
|
startPos = sel.first;
|
|
}
|
|
inputConn.setSelection(startPos, endPos);
|
|
} else if (ctrl && keyEvent == KeyEvent.KEYCODE_DPAD_LEFT) {
|
|
// Ctrl + left -> previous word
|
|
ExtractedText extractedText = inputConn.getExtractedText(new ExtractedTextRequest(), 0);
|
|
int pos = getCharPos(extractedText, ' ', keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT);
|
|
if (pos == -1)
|
|
pos = 0;
|
|
else
|
|
pos++;
|
|
int startPos = pos;
|
|
int endPos = pos;
|
|
if (shift) {
|
|
Pair<Integer, Integer> sel = currentSelection(extractedText);
|
|
int cursor = currentCursorPos(extractedText);
|
|
// Log.d("RemoteKeyboardPlugin", "Selection (to left): " + sel.first + " / " + sel.second + " cursor: " + cursor);
|
|
startPos = cursor;
|
|
if (cursor < sel.first || // active selection from right to left -> grow
|
|
sel.first < sel.second) // active selection from right to left -> shrink
|
|
startPos = sel.first;
|
|
}
|
|
inputConn.setSelection(startPos, endPos);
|
|
} else if (shift
|
|
&& (keyEvent == KeyEvent.KEYCODE_DPAD_LEFT
|
|
|| keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT
|
|
|| keyEvent == KeyEvent.KEYCODE_DPAD_UP
|
|
|| keyEvent == KeyEvent.KEYCODE_DPAD_DOWN
|
|
|| keyEvent == KeyEvent.KEYCODE_MOVE_HOME
|
|
|| keyEvent == KeyEvent.KEYCODE_MOVE_END)) {
|
|
// Shift + up/down/left/right/home/end
|
|
long now = SystemClock.uptimeMillis();
|
|
inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0));
|
|
inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyEvent, 0, KeyEvent.META_SHIFT_LEFT_ON));
|
|
inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, keyEvent, 0, KeyEvent.META_SHIFT_LEFT_ON));
|
|
inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0));
|
|
} else if (keyEvent == KeyEvent.KEYCODE_NUMPAD_ENTER
|
|
|| keyEvent == KeyEvent.KEYCODE_ENTER) {
|
|
// Enter key
|
|
EditorInfo editorInfo = RemoteKeyboardService.instance.getCurrentInputEditorInfo();
|
|
// Log.d("RemoteKeyboardPlugin", "Enter: " + editorInfo.imeOptions);
|
|
if (editorInfo != null
|
|
&& (((editorInfo.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) == 0)
|
|
|| ctrl)) { // Ctrl+Return overrides IME_FLAG_NO_ENTER_ACTION (FIXME: make configurable?)
|
|
// check for special DONE/GO/etc actions first:
|
|
int[] actions = {EditorInfo.IME_ACTION_GO, EditorInfo.IME_ACTION_NEXT,
|
|
EditorInfo.IME_ACTION_SEND, EditorInfo.IME_ACTION_SEARCH,
|
|
EditorInfo.IME_ACTION_DONE}; // note: DONE should be last or we might hide the ime instead of "go"
|
|
for (int action : actions) {
|
|
if ((editorInfo.imeOptions & action) == action) {
|
|
// Log.d("RemoteKeyboardPlugin", "Enter-action: " + actions[i]);
|
|
inputConn.performEditorAction(action);
|
|
return true;
|
|
}
|
|
}
|
|
} else {
|
|
// else: fall back to regular Enter-event:
|
|
// Log.d("RemoteKeyboardPlugin", "Enter: normal keypress");
|
|
inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyEvent));
|
|
inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyEvent));
|
|
}
|
|
} else {
|
|
// default handling:
|
|
inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyEvent));
|
|
inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyEvent));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private boolean handleVisibleKey(String key, boolean shift, boolean ctrl, boolean alt) {
|
|
// Log.d("RemoteKeyboardPlugin", "Handling visible key " + key + " shift=" + shift + " ctrl=" + ctrl + " alt=" + alt + " " + key.equalsIgnoreCase("c") + " " + key.length());
|
|
|
|
if (key.isEmpty())
|
|
return false;
|
|
|
|
InputConnection inputConn = RemoteKeyboardService.instance.getCurrentInputConnection();
|
|
if (inputConn == null)
|
|
return false;
|
|
|
|
// ctrl+c/v/x
|
|
if (key.equalsIgnoreCase("c") && ctrl) {
|
|
return inputConn.performContextMenuAction(android.R.id.copy);
|
|
} else if (key.equalsIgnoreCase("v") && ctrl)
|
|
return inputConn.performContextMenuAction(android.R.id.paste);
|
|
else if (key.equalsIgnoreCase("x") && ctrl)
|
|
return inputConn.performContextMenuAction(android.R.id.cut);
|
|
else if (key.equalsIgnoreCase("a") && ctrl)
|
|
return inputConn.performContextMenuAction(android.R.id.selectAll);
|
|
|
|
// Log.d("RemoteKeyboardPlugin", "Committing visible key '" + key + "'");
|
|
inputConn.commitText(key, key.length());
|
|
return true;
|
|
}
|
|
|
|
private boolean handleEvent(NetworkPacket np) {
|
|
if (np.has("specialKey") && isValidSpecialKey(np.getInt("specialKey")))
|
|
return handleSpecialKey(np.getInt("specialKey"), np.getBoolean("shift"),
|
|
np.getBoolean("ctrl"), np.getBoolean("alt"));
|
|
|
|
// try visible key
|
|
return handleVisibleKey(np.getString("key"), np.getBoolean("shift"),
|
|
np.getBoolean("ctrl"), np.getBoolean("alt"));
|
|
}
|
|
|
|
@Override
|
|
public boolean onPacketReceived(NetworkPacket np) {
|
|
|
|
if (!np.getType().equals(PACKET_TYPE_MOUSEPAD_REQUEST)
|
|
|| (!np.has("key") && !np.has("specialKey"))) { // expect at least key OR specialKey
|
|
Log.e("RemoteKeyboardPlugin", "Invalid package for remotekeyboard plugin!");
|
|
return false;
|
|
}
|
|
|
|
if (RemoteKeyboardService.instance == null) {
|
|
Log.i("RemoteKeyboardPlugin", "Remote keyboard is not the currently selected input method, dropping key");
|
|
return false;
|
|
}
|
|
|
|
if (!RemoteKeyboardService.instance.visible &&
|
|
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.remotekeyboard_editing_only), true)) {
|
|
Log.i("RemoteKeyboardPlugin", "Remote keyboard is currently not visible, dropping key");
|
|
return false;
|
|
}
|
|
|
|
if (!handleEvent(np)) {
|
|
Log.i("RemoteKeyboardPlugin", "Could not handle event!");
|
|
return false;
|
|
}
|
|
|
|
if (np.getBoolean("sendAck")) {
|
|
NetworkPacket reply = new NetworkPacket(PACKET_TYPE_MOUSEPAD_ECHO);
|
|
reply.set("key", np.getString("key"));
|
|
if (np.has("specialKey"))
|
|
reply.set("specialKey", np.getInt("specialKey"));
|
|
if (np.has("shift"))
|
|
reply.set("shift", np.getBoolean("shift"));
|
|
if (np.has("ctrl"))
|
|
reply.set("ctrl", np.getBoolean("ctrl"));
|
|
if (np.has("alt"))
|
|
reply.set("alt", np.getBoolean("alt"));
|
|
reply.set("isAck", true);
|
|
device.sendPacket(reply);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public void notifyKeyboardState(boolean state) {
|
|
Log.d("RemoteKeyboardPlugin", "Keyboardstate changed to " + state);
|
|
NetworkPacket np = new NetworkPacket(PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE);
|
|
np.set("state", state);
|
|
device.sendPacket(np);
|
|
}
|
|
|
|
String getDeviceId() {
|
|
return device.getDeviceId();
|
|
}
|
|
|
|
@Override
|
|
public boolean checkRequiredPermissions() {
|
|
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ENABLED_INPUT_METHODS).contains("org.kde.kdeconnect_tp");
|
|
}
|
|
|
|
@Override
|
|
public DialogFragment getPermissionExplanationDialog() {
|
|
return new StartActivityAlertDialogFragment.Builder()
|
|
.setTitle(R.string.pref_plugin_remotekeyboard)
|
|
.setMessage(R.string.no_permissions_remotekeyboard)
|
|
.setPositiveButton(R.string.open_settings)
|
|
.setNegativeButton(R.string.cancel)
|
|
.setIntentAction(Settings.ACTION_INPUT_METHOD_SETTINGS)
|
|
.setStartForResult(true)
|
|
.setRequestCode(MainActivity.RESULT_NEEDS_RELOAD)
|
|
.create();
|
|
}
|
|
|
|
@Override
|
|
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
|
|
if (key.equals(context.getString(R.string.remotekeyboard_editing_only))) {
|
|
final boolean editingOnly = sharedPreferences.getBoolean(context.getString(R.string.remotekeyboard_editing_only), true);
|
|
final boolean visible = RemoteKeyboardService.instance != null && RemoteKeyboardService.instance.visible;
|
|
notifyKeyboardState(!editingOnly || visible);
|
|
}
|
|
}
|
|
}
|