File indexing completed on 2024-12-22 04:41:40

0001 /*
0002  * SPDX-FileCopyrightText: 2017 Holger Kaelberer <holger.k@elberer.de>
0003  *
0004  * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005  */
0006 
0007 package org.kde.kdeconnect.Plugins.RemoteKeyboardPlugin;
0008 
0009 import android.app.Activity;
0010 import android.content.SharedPreferences;
0011 import android.os.SystemClock;
0012 import android.preference.PreferenceManager;
0013 import android.provider.Settings;
0014 import android.util.Log;
0015 import android.util.SparseIntArray;
0016 import android.view.KeyEvent;
0017 import android.view.inputmethod.EditorInfo;
0018 import android.view.inputmethod.ExtractedText;
0019 import android.view.inputmethod.ExtractedTextRequest;
0020 import android.view.inputmethod.InputConnection;
0021 
0022 import androidx.annotation.DrawableRes;
0023 import androidx.annotation.NonNull;
0024 import androidx.core.util.Pair;
0025 import androidx.fragment.app.DialogFragment;
0026 
0027 import org.kde.kdeconnect.NetworkPacket;
0028 import org.kde.kdeconnect.Plugins.Plugin;
0029 import org.kde.kdeconnect.Plugins.PluginFactory;
0030 import org.kde.kdeconnect.UserInterface.MainActivity;
0031 import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
0032 import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment;
0033 import org.kde.kdeconnect_tp.R;
0034 
0035 import java.util.ArrayList;
0036 import java.util.concurrent.locks.ReentrantLock;
0037 
0038 @PluginFactory.LoadablePlugin
0039 public class RemoteKeyboardPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener {
0040 
0041     private final static String PACKET_TYPE_MOUSEPAD_REQUEST = "kdeconnect.mousepad.request";
0042     private final static String PACKET_TYPE_MOUSEPAD_ECHO = "kdeconnect.mousepad.echo";
0043     private final static String PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE = "kdeconnect.mousepad.keyboardstate";
0044 
0045     /**
0046      * Track and expose plugin instances to allow for a 'connected'-indicator in the IME:
0047      */
0048     private static final ArrayList<RemoteKeyboardPlugin> instances = new ArrayList<>();
0049     private static final ReentrantLock instancesLock = new ReentrantLock(true);
0050 
0051     private static ArrayList<RemoteKeyboardPlugin> getInstances() {
0052         return instances;
0053     }
0054 
0055     public static ArrayList<RemoteKeyboardPlugin> acquireInstances() {
0056         instancesLock.lock();
0057         return getInstances();
0058     }
0059 
0060     public static ArrayList<RemoteKeyboardPlugin> releaseInstances() {
0061         instancesLock.unlock();
0062         return getInstances();
0063     }
0064 
0065     public static boolean isConnected() {
0066         return instances.size() > 0;
0067     }
0068 
0069     private static final SparseIntArray specialKeyMap = new SparseIntArray();
0070 
0071     static {
0072         int i = 0;
0073         specialKeyMap.put(++i, KeyEvent.KEYCODE_DEL);              // 1
0074         specialKeyMap.put(++i, KeyEvent.KEYCODE_TAB);              // 2
0075         ++i; //specialKeyMap.put(++i, KeyEvent.KEYCODE_ENTER, 12); // 3 is not used
0076         specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_LEFT);        // 4
0077         specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_UP);          // 5
0078         specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_RIGHT);       // 6
0079         specialKeyMap.put(++i, KeyEvent.KEYCODE_DPAD_DOWN);        // 7
0080         specialKeyMap.put(++i, KeyEvent.KEYCODE_PAGE_UP);          // 8
0081         specialKeyMap.put(++i, KeyEvent.KEYCODE_PAGE_DOWN);        // 9
0082         specialKeyMap.put(++i, KeyEvent.KEYCODE_MOVE_HOME);    // 10
0083         specialKeyMap.put(++i, KeyEvent.KEYCODE_MOVE_END);     // 11
0084         specialKeyMap.put(++i, KeyEvent.KEYCODE_ENTER); // 12
0085         specialKeyMap.put(++i, KeyEvent.KEYCODE_FORWARD_DEL);  // 13
0086         specialKeyMap.put(++i, KeyEvent.KEYCODE_ESCAPE);       // 14
0087         specialKeyMap.put(++i, KeyEvent.KEYCODE_SYSRQ);        // 15
0088         specialKeyMap.put(++i, KeyEvent.KEYCODE_SCROLL_LOCK);  // 16
0089         ++i;           // 17
0090         ++i;           // 18
0091         ++i;           // 19
0092         ++i;           // 20
0093         specialKeyMap.put(++i, KeyEvent.KEYCODE_F1);           // 21
0094         specialKeyMap.put(++i, KeyEvent.KEYCODE_F2);           // 22
0095         specialKeyMap.put(++i, KeyEvent.KEYCODE_F3);           // 23
0096         specialKeyMap.put(++i, KeyEvent.KEYCODE_F4);           // 24
0097         specialKeyMap.put(++i, KeyEvent.KEYCODE_F5);           // 25
0098         specialKeyMap.put(++i, KeyEvent.KEYCODE_F6);           // 26
0099         specialKeyMap.put(++i, KeyEvent.KEYCODE_F7);           // 27
0100         specialKeyMap.put(++i, KeyEvent.KEYCODE_F8);           // 28
0101         specialKeyMap.put(++i, KeyEvent.KEYCODE_F9);           // 29
0102         specialKeyMap.put(++i, KeyEvent.KEYCODE_F10);          // 30
0103         specialKeyMap.put(++i, KeyEvent.KEYCODE_F11);          // 31
0104         specialKeyMap.put(++i, KeyEvent.KEYCODE_F12);          // 21
0105     }
0106 
0107     @Override
0108     public boolean onCreate() {
0109         Log.d("RemoteKeyboardPlugin", "Creating for device " + device.getName());
0110         acquireInstances();
0111         try {
0112             instances.add(this);
0113         } finally {
0114             releaseInstances();
0115         }
0116         if (RemoteKeyboardService.instance != null)
0117             RemoteKeyboardService.instance.handler.post(() -> RemoteKeyboardService.instance.updateInputView());
0118 
0119         PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this);
0120 
0121         final boolean editingOnly = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.remotekeyboard_editing_only), true);
0122         final boolean visible = RemoteKeyboardService.instance != null && RemoteKeyboardService.instance.visible;
0123         notifyKeyboardState(!editingOnly || visible);
0124 
0125         return true;
0126     }
0127 
0128     @Override
0129     public void onDestroy() {
0130         acquireInstances();
0131         try {
0132             if (instances.contains(this)) {
0133                 instances.remove(this);
0134                 if (instances.size() < 1 && RemoteKeyboardService.instance != null)
0135                     RemoteKeyboardService.instance.handler.post(() -> RemoteKeyboardService.instance.updateInputView());
0136             }
0137         } finally {
0138             releaseInstances();
0139         }
0140 
0141         Log.d("RemoteKeyboardPlugin", "Destroying for device " + device.getName());
0142     }
0143 
0144     @Override
0145     public @NonNull String getDisplayName() {
0146         return context.getString(R.string.pref_plugin_remotekeyboard);
0147     }
0148 
0149     @Override
0150     public @NonNull String getDescription() {
0151         return context.getString(R.string.pref_plugin_remotekeyboard_desc);
0152     }
0153 
0154     @Override
0155     public @DrawableRes int getIcon() {
0156         return R.drawable.ic_action_keyboard_24dp;
0157     }
0158 
0159     @Override
0160     public boolean hasSettings() {
0161         return true;
0162     }
0163 
0164     @Override
0165     public PluginSettingsFragment getSettingsFragment(Activity activity) {
0166         return PluginSettingsFragment.newInstance(getPluginKey(), R.xml.remotekeyboardplugin_preferences);
0167     }
0168 
0169     @Override
0170     public @NonNull String[] getSupportedPacketTypes() {
0171         return new String[]{PACKET_TYPE_MOUSEPAD_REQUEST};
0172     }
0173 
0174     @Override
0175     public @NonNull String[] getOutgoingPacketTypes() {
0176         return new String[]{PACKET_TYPE_MOUSEPAD_ECHO, PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE};
0177     }
0178 
0179     private boolean isValidSpecialKey(int key) {
0180         return (specialKeyMap.get(key, 0) > 0);
0181     }
0182 
0183     private int getCharPos(ExtractedText extractedText, char ch, boolean forward) {
0184         int pos = -1;
0185         if (extractedText != null) {
0186             if (!forward)  // backward
0187                 pos = extractedText.text.toString().lastIndexOf(" ", extractedText.selectionEnd - 2);
0188             else
0189                 pos = extractedText.text.toString().indexOf(" ", extractedText.selectionEnd + 1);
0190             return pos;
0191         }
0192         return pos;
0193     }
0194 
0195     private int currentTextLength(ExtractedText extractedText) {
0196         if (extractedText != null)
0197             return extractedText.text.length();
0198         return -1;
0199     }
0200 
0201     private int currentCursorPos(ExtractedText extractedText) {
0202         if (extractedText != null)
0203             return extractedText.selectionEnd;
0204         return -1;
0205     }
0206 
0207     private Pair<Integer, Integer> currentSelection(ExtractedText extractedText) {
0208         if (extractedText != null)
0209             return new Pair<>(extractedText.selectionStart, extractedText.selectionEnd);
0210         return new Pair<>(-1, -1);
0211     }
0212 
0213     private boolean handleSpecialKey(int key, boolean shift, boolean ctrl, boolean alt) {
0214         int keyEvent = specialKeyMap.get(key, 0);
0215         if (keyEvent == 0)
0216             return false;
0217         InputConnection inputConn = RemoteKeyboardService.instance.getCurrentInputConnection();
0218 //        Log.d("RemoteKeyboardPlugin", "Handling special key " + key + " translated to " + keyEvent + " shift=" + shift + " ctrl=" + ctrl + " alt=" + alt);
0219 
0220         // special sequences:
0221         if (ctrl && (keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT)) {
0222             // Ctrl + right -> next word
0223             ExtractedText extractedText = inputConn.getExtractedText(new ExtractedTextRequest(), 0);
0224             int pos = getCharPos(extractedText, ' ', keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT);
0225             if (pos == -1)
0226                 pos = currentTextLength(extractedText);
0227             else
0228                 pos++;
0229             int startPos = pos;
0230             int endPos = pos;
0231             if (shift) { // Shift -> select word (otherwise jump)
0232                 Pair<Integer, Integer> sel = currentSelection(extractedText);
0233                 int cursor = currentCursorPos(extractedText);
0234 //                Log.d("RemoteKeyboardPlugin", "Selection (to right): " + sel.first + " / " + sel.second + " cursor: " + cursor);
0235                 startPos = cursor;
0236                 if (sel.first < cursor ||   // active selection from left to right -> grow
0237                         sel.first > sel.second) // active selection from right to left -> shrink
0238                     startPos = sel.first;
0239             }
0240             inputConn.setSelection(startPos, endPos);
0241         } else if (ctrl && keyEvent == KeyEvent.KEYCODE_DPAD_LEFT) {
0242             // Ctrl + left -> previous word
0243             ExtractedText extractedText = inputConn.getExtractedText(new ExtractedTextRequest(), 0);
0244             int pos = getCharPos(extractedText, ' ', keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT);
0245             if (pos == -1)
0246                 pos = 0;
0247             else
0248                 pos++;
0249             int startPos = pos;
0250             int endPos = pos;
0251             if (shift) {
0252                 Pair<Integer, Integer> sel = currentSelection(extractedText);
0253                 int cursor = currentCursorPos(extractedText);
0254 //                Log.d("RemoteKeyboardPlugin", "Selection (to left): " + sel.first + " / " + sel.second + " cursor: " + cursor);
0255                 startPos = cursor;
0256                 if (cursor < sel.first ||    // active selection from right to left -> grow
0257                         sel.first < sel.second)  // active selection from right to left -> shrink
0258                     startPos = sel.first;
0259             }
0260             inputConn.setSelection(startPos, endPos);
0261         } else if (shift
0262                 && (keyEvent == KeyEvent.KEYCODE_DPAD_LEFT
0263                 || keyEvent == KeyEvent.KEYCODE_DPAD_RIGHT
0264                 || keyEvent == KeyEvent.KEYCODE_DPAD_UP
0265                 || keyEvent == KeyEvent.KEYCODE_DPAD_DOWN
0266                 || keyEvent == KeyEvent.KEYCODE_MOVE_HOME
0267                 || keyEvent == KeyEvent.KEYCODE_MOVE_END)) {
0268             // Shift + up/down/left/right/home/end
0269             long now = SystemClock.uptimeMillis();
0270             inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0));
0271             inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyEvent, 0, KeyEvent.META_SHIFT_LEFT_ON));
0272             inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, keyEvent, 0, KeyEvent.META_SHIFT_LEFT_ON));
0273             inputConn.sendKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0));
0274         } else if (keyEvent == KeyEvent.KEYCODE_NUMPAD_ENTER
0275                 || keyEvent == KeyEvent.KEYCODE_ENTER) {
0276             // Enter key
0277             EditorInfo editorInfo = RemoteKeyboardService.instance.getCurrentInputEditorInfo();
0278 //            Log.d("RemoteKeyboardPlugin", "Enter: " + editorInfo.imeOptions);
0279             if (editorInfo != null
0280                     && (((editorInfo.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) == 0)
0281                     || ctrl)) {  // Ctrl+Return overrides IME_FLAG_NO_ENTER_ACTION (FIXME: make configurable?)
0282                 // check for special DONE/GO/etc actions first:
0283                 int[] actions = {EditorInfo.IME_ACTION_GO, EditorInfo.IME_ACTION_NEXT,
0284                         EditorInfo.IME_ACTION_SEND, EditorInfo.IME_ACTION_SEARCH,
0285                         EditorInfo.IME_ACTION_DONE};  // note: DONE should be last or we might hide the ime instead of "go"
0286                 for (int action : actions) {
0287                     if ((editorInfo.imeOptions & action) == action) {
0288 //                        Log.d("RemoteKeyboardPlugin", "Enter-action: " + actions[i]);
0289                         inputConn.performEditorAction(action);
0290                         return true;
0291                     }
0292                 }
0293             } else {
0294                 // else: fall back to regular Enter-event:
0295 //                Log.d("RemoteKeyboardPlugin", "Enter: normal keypress");
0296                 inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyEvent));
0297                 inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyEvent));
0298             }
0299         } else {
0300             // default handling:
0301             inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyEvent));
0302             inputConn.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyEvent));
0303         }
0304 
0305         return true;
0306     }
0307 
0308     private boolean handleVisibleKey(String key, boolean shift, boolean ctrl, boolean alt) {
0309 //        Log.d("RemoteKeyboardPlugin", "Handling visible key " + key + " shift=" + shift + " ctrl=" + ctrl + " alt=" + alt + " " + key.equalsIgnoreCase("c") + " " + key.length());
0310 
0311         if (key.isEmpty())
0312             return false;
0313 
0314         InputConnection inputConn = RemoteKeyboardService.instance.getCurrentInputConnection();
0315         if (inputConn == null)
0316             return false;
0317 
0318         // ctrl+c/v/x
0319         if (key.equalsIgnoreCase("c") && ctrl) {
0320             return inputConn.performContextMenuAction(android.R.id.copy);
0321         } else if (key.equalsIgnoreCase("v") && ctrl)
0322             return inputConn.performContextMenuAction(android.R.id.paste);
0323         else if (key.equalsIgnoreCase("x") && ctrl)
0324             return inputConn.performContextMenuAction(android.R.id.cut);
0325         else if (key.equalsIgnoreCase("a") && ctrl)
0326             return inputConn.performContextMenuAction(android.R.id.selectAll);
0327 
0328 //        Log.d("RemoteKeyboardPlugin", "Committing visible key '" + key + "'");
0329         inputConn.commitText(key, key.length());
0330         return true;
0331     }
0332 
0333     private boolean handleEvent(NetworkPacket np) {
0334         if (np.has("specialKey") && isValidSpecialKey(np.getInt("specialKey")))
0335             return handleSpecialKey(np.getInt("specialKey"), np.getBoolean("shift"),
0336                     np.getBoolean("ctrl"), np.getBoolean("alt"));
0337 
0338         // try visible key
0339         return handleVisibleKey(np.getString("key"), np.getBoolean("shift"),
0340                 np.getBoolean("ctrl"), np.getBoolean("alt"));
0341     }
0342 
0343 
0344     public enum MousePadPacketType {
0345         Keyboard,
0346         Mouse,
0347     };
0348 
0349     public static MousePadPacketType getMousePadPacketType(NetworkPacket np) {
0350         if (np.has("key") || np.has("specialKey")) {
0351             return MousePadPacketType.Keyboard;
0352         } else {
0353             return MousePadPacketType.Mouse;
0354         }
0355     }
0356 
0357     @Override
0358     public boolean onPacketReceived(@NonNull NetworkPacket np) {
0359 
0360         if (!np.getType().equals(PACKET_TYPE_MOUSEPAD_REQUEST)) {
0361             Log.e("RemoteKeyboardPlugin", "Invalid packet type for RemoteKeyboardPlugin: "+np.getType());
0362             return false;
0363         }
0364 
0365         if (getMousePadPacketType(np) != MousePadPacketType.Keyboard) {
0366             return false; // This packet will be handled by the MouseReceiverPlugin instead, silently ignore
0367         }
0368 
0369         if (RemoteKeyboardService.instance == null) {
0370             Log.i("RemoteKeyboardPlugin", "Remote keyboard is not the currently selected input method, dropping key");
0371             return false;
0372         }
0373 
0374         if (!RemoteKeyboardService.instance.visible &&
0375                 PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.remotekeyboard_editing_only), true)) {
0376             Log.i("RemoteKeyboardPlugin", "Remote keyboard is currently not visible, dropping key");
0377             return false;
0378         }
0379 
0380         if (!handleEvent(np)) {
0381             Log.i("RemoteKeyboardPlugin", "Could not handle event!");
0382             return false;
0383         }
0384 
0385         if (np.getBoolean("sendAck")) {
0386             NetworkPacket reply = new NetworkPacket(PACKET_TYPE_MOUSEPAD_ECHO);
0387             reply.set("key", np.getString("key"));
0388             if (np.has("specialKey"))
0389                 reply.set("specialKey", np.getInt("specialKey"));
0390             if (np.has("shift"))
0391                 reply.set("shift", np.getBoolean("shift"));
0392             if (np.has("ctrl"))
0393                 reply.set("ctrl", np.getBoolean("ctrl"));
0394             if (np.has("alt"))
0395                 reply.set("alt", np.getBoolean("alt"));
0396             reply.set("isAck", true);
0397             device.sendPacket(reply);
0398         }
0399 
0400         return true;
0401     }
0402 
0403     public void notifyKeyboardState(boolean state) {
0404         Log.d("RemoteKeyboardPlugin", "Keyboardstate changed to " + state);
0405         NetworkPacket np = new NetworkPacket(PACKET_TYPE_MOUSEPAD_KEYBOARDSTATE);
0406         np.set("state", state);
0407         device.sendPacket(np);
0408     }
0409 
0410     String getDeviceId() {
0411         return device.getDeviceId();
0412     }
0413 
0414     @Override
0415     public boolean checkRequiredPermissions() {
0416         return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ENABLED_INPUT_METHODS).contains("org.kde.kdeconnect_tp");
0417     }
0418 
0419     @Override
0420     public @NonNull DialogFragment getPermissionExplanationDialog() {
0421         return new StartActivityAlertDialogFragment.Builder()
0422                 .setTitle(R.string.pref_plugin_remotekeyboard)
0423                 .setMessage(R.string.no_permissions_remotekeyboard)
0424                 .setPositiveButton(R.string.open_settings)
0425                 .setNegativeButton(R.string.cancel)
0426                 .setIntentAction(Settings.ACTION_INPUT_METHOD_SETTINGS)
0427                 .setStartForResult(true)
0428                 .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD)
0429                 .create();
0430     }
0431 
0432     @Override
0433     public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
0434         if (key.equals(context.getString(R.string.remotekeyboard_editing_only))) {
0435             final boolean editingOnly = sharedPreferences.getBoolean(context.getString(R.string.remotekeyboard_editing_only), true);
0436             final boolean visible = RemoteKeyboardService.instance != null && RemoteKeyboardService.instance.visible;
0437             notifyKeyboardState(!editingOnly || visible);
0438         }
0439     }
0440 }