diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 6b072507..c1ef8316 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -334,6 +334,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/mouse_pointer_clicked.xml b/res/drawable/mouse_pointer_clicked.xml
new file mode 100644
index 00000000..013aedc8
--- /dev/null
+++ b/res/drawable/mouse_pointer_clicked.xml
@@ -0,0 +1,31 @@
+
+
+
+-
+
+
+
+
+
+
+
+-
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/mouse_receiver_cursor.xml b/res/layout/mouse_receiver_cursor.xml
new file mode 100644
index 00000000..af8b4b10
--- /dev/null
+++ b/res/layout/mouse_receiver_cursor.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 4dc8b30a..0ff1a105 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -132,6 +132,9 @@
pref_sendkeystrokes_enabled
pref_send_safe_text_immediately
+ Receive remote mouse movement
+ Mouse receiver
+ You need to enable Accessibility Service
Connected devices
Available devices
Remembered devices
diff --git a/res/xml/mouse_receiver_service.xml b/res/xml/mouse_receiver_service.xml
new file mode 100644
index 00000000..e51ed685
--- /dev/null
+++ b/res/xml/mouse_receiver_service.xml
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverPlugin.java b/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverPlugin.java
new file mode 100644
index 00000000..38ee357a
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverPlugin.java
@@ -0,0 +1,138 @@
+/*
+ * SPDX-FileCopyrightText: 2021 SohnyBohny
+ *
+ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+ */
+
+package org.kde.kdeconnect.Plugins.MouseReceiverPlugin;
+
+import android.os.Build;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.RequiresApi;
+import androidx.fragment.app.DialogFragment;
+
+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_tp.R;
+
+@PluginFactory.LoadablePlugin
+@RequiresApi(api = Build.VERSION_CODES.N)
+public class MouseReceiverPlugin extends Plugin {
+ private final static String PACKET_TYPE_MOUSEPAD_REQUEST = "kdeconnect.mousepad.request";
+
+ @Override
+ public boolean onCreate() {
+ Log.e("MouseReceiverPlugin", "onCreate()");
+ return super.onCreate();
+ }
+
+ @Override
+ public boolean checkRequiredPermissions() {
+ return MouseReceiverService.instance != null;
+ }
+
+ @Override
+ public DialogFragment getPermissionExplanationDialog() {
+ return new StartActivityAlertDialogFragment.Builder()
+ .setTitle(R.string.mouse_receiver_plugin_description)
+ .setMessage(R.string.mouse_receiver_no_permissions)
+ .setPositiveButton(R.string.open_settings)
+ .setNegativeButton(R.string.cancel)
+ .setIntentAction(Settings.ACTION_ACCESSIBILITY_SETTINGS)
+ .setStartForResult(true)
+ .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD)
+ .create();
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.e("MouseReceiverPlugin", "onDestroy()");
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onPacketReceived(NetworkPacket np) {
+ if (!np.getType().equals(PACKET_TYPE_MOUSEPAD_REQUEST)) {
+ Log.e("MouseReceiverPlugin", "cannot receive packets of type: " + np.getType());
+ return false;
+ }
+
+ double dx = np.getDouble("dx", 0);
+ double dy = np.getDouble("dy", 0);
+
+ boolean isSingleClick = np.getBoolean("singleclick", false);
+ boolean isDoubleClick = np.getBoolean("doubleclick", false);
+ boolean isMiddleClick = np.getBoolean("middleclick", false);
+ boolean isRightClick = np.getBoolean("rightclick", false);
+ boolean isSingleHold = np.getBoolean("singlehold", false);
+ boolean isScroll = np.getBoolean("scroll", false);
+
+ if (isSingleClick || isDoubleClick || isMiddleClick || isRightClick || isSingleHold || isScroll) {
+ // Perform click
+ if (isSingleClick) {
+ // Log.i("MouseReceiverPlugin", "singleClick");
+ return MouseReceiverService.click();
+ } else if (isDoubleClick) { // left & right
+ // Log.i("MouseReceiverPlugin", "doubleClick");
+ return MouseReceiverService.recentButton();
+ } else if (isMiddleClick) {
+ // Log.i("MouseReceiverPlugin", "middleClick");
+ return MouseReceiverService.homeButton();
+ } else if (isRightClick) {
+ // Log.i("MouseReceiverPlugin", "rightClick");
+ return MouseReceiverService.backButton();
+ } else if (isSingleHold){
+ // For drag'n drop
+ // Log.i("MouseReceiverPlugin", "singleHold");
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ return MouseReceiverService.longClickSwipe();
+ } else {
+ return MouseReceiverService.longClick();
+ }
+ } else if (isScroll) {
+ // Log.i("MouseReceiverPlugin", "scroll dx: " + dx + " dy: " + dy);
+ return MouseReceiverService.scroll(dx, dy); // dx is always 0
+ }
+
+ } else {
+ // Mouse Move
+ if (dx != 0 || dy != 0) {
+ // Log.i("MouseReceiverPlugin", "move Mouse dx: " + dx + " dy: " + dy);
+ return MouseReceiverService.move(dx, dy);
+ }
+ }
+
+ return super.onPacketReceived(np);
+ }
+
+ @Override
+ public int getMinSdk() {
+ return Build.VERSION_CODES.N;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return context.getString(R.string.mouse_receiver_plugin_name);
+ }
+
+ @Override
+ public String getDescription() {
+ return "MouseReceiverPlugin.getDescription()";
+ //return context.getString(R.string.pref_plugin_remotekeyboard_desc);
+ }
+
+ @Override
+ public String[] getSupportedPacketTypes() {
+ return new String[]{PACKET_TYPE_MOUSEPAD_REQUEST};
+ }
+
+ @Override
+ public String[] getOutgoingPacketTypes() {
+ return new String[0];
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverService.java b/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverService.java
new file mode 100644
index 00000000..801753d5
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/MouseReceiverPlugin/MouseReceiverService.java
@@ -0,0 +1,317 @@
+/*
+ * SPDX-FileCopyrightText: 2021 SohnyBohny
+ *
+ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+ */
+
+package org.kde.kdeconnect.Plugins.MouseReceiverPlugin;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.GestureDescription;
+import android.graphics.Path;
+import android.graphics.PixelFormat;
+import android.os.Build;
+import android.os.Handler;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.ImageView;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.content.ContextCompat;
+
+import org.kde.kdeconnect_tp.R;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+public class MouseReceiverService extends AccessibilityService {
+ public static MouseReceiverService instance;
+
+ private View cursorView;
+ private LayoutParams cursorLayout;
+ private WindowManager windowManager;
+ private Handler runHandler;
+ private Runnable hideRunnable;
+ private GestureDescription.StrokeDescription swipeStoke;
+ private double scrollSum;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ MouseReceiverService.instance = this;
+ Log.i("MouseReceiverService", "created");
+ }
+
+ @Override
+ protected void onServiceConnected() {
+ // Create an overlay and display the cursor
+ windowManager = ContextCompat.getSystemService(this, WindowManager.class);
+ DisplayMetrics displayMetrics = new DisplayMetrics();
+ windowManager.getDefaultDisplay().getMetrics(displayMetrics);
+
+ cursorView = View.inflate(getBaseContext(), R.layout.mouse_receiver_cursor, null);
+ cursorLayout = new LayoutParams(
+ LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT,
+ LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ LayoutParams.FLAG_DISMISS_KEYGUARD | LayoutParams.FLAG_NOT_FOCUSABLE
+ | LayoutParams.FLAG_NOT_TOUCHABLE | LayoutParams.FLAG_FULLSCREEN
+ | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT);
+
+ // allow cursor to move over status bar on devices having a display cutout
+ // https://developer.android.com/guide/topics/display-cutout/#render_content_in_short_edge_cutout_areas
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ cursorLayout.layoutInDisplayCutoutMode = LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ }
+
+ cursorLayout.gravity = Gravity.LEFT | Gravity.TOP;
+ cursorLayout.x = displayMetrics.widthPixels / 2;
+ cursorLayout.y = displayMetrics.heightPixels / 2;
+
+ // https://developer.android.com/training/system-ui/navigation.html#behind
+ cursorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
+
+ windowManager.addView(cursorView, cursorLayout);
+
+ hideRunnable = () -> {
+ cursorView.setVisibility(View.GONE);
+ Log.i("MouseReceiverService", "hideAfter5Seconds: done");
+ };
+ runHandler = new Handler();
+
+ cursorView.setVisibility(View.GONE);
+ }
+
+ private void hideAfter5Seconds() {
+ runHandler.removeCallbacks(hideRunnable);
+ runHandler.postDelayed(hideRunnable, 5000);
+ Log.i("MouseReceiverService", "hideAfter5Seconds: called");
+ }
+
+ public float getX() {
+ return cursorLayout.x + cursorView.getWidth() / 2;
+ }
+
+ public float getY() {
+ return cursorLayout.y + cursorView.getHeight() / 2;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
+ public void moveView(double dx, double dy) {
+ DisplayMetrics displayMetrics = new DisplayMetrics();
+ instance.windowManager.getDefaultDisplay().getRealMetrics(displayMetrics);
+
+ cursorLayout.x += dx;
+ cursorLayout.y += dy;
+
+ if (getX() > displayMetrics.widthPixels)
+ cursorLayout.x = displayMetrics.widthPixels - cursorView.getWidth() / 2;
+ if (getY() > displayMetrics.heightPixels)
+ cursorLayout.y = displayMetrics.heightPixels - cursorView.getHeight() / 2;
+ if (getX() < 0) cursorLayout.x = -cursorView.getWidth() / 2;
+ if (getY() < 0) cursorLayout.y = -cursorView.getHeight() / 2;
+
+ new Handler(instance.getMainLooper()).post(() -> {
+ // Log.i("MouseReceiverService", "performing move");
+ instance.windowManager.updateViewLayout(instance.cursorView, instance.cursorLayout);
+ instance.cursorView.setVisibility(View.VISIBLE);
+ });
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
+ public static boolean move(double dx, double dy) {
+ if (instance == null) return false;
+
+ float fromX = instance.getX();
+ float fromY = instance.getY();
+
+ instance.moveView(dx, dy);
+
+ instance.hideAfter5Seconds();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && instance.isSwiping()) {
+ return instance.continueSwipe(fromX, fromY);
+ }
+
+ return true;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ private static GestureDescription createClick(float x, float y, int duration) {
+ Path clickPath = new Path();
+ clickPath.moveTo(x, y);
+ GestureDescription.StrokeDescription clickStroke =
+ new GestureDescription.StrokeDescription(clickPath, 0, duration);
+ GestureDescription.Builder clickBuilder = new GestureDescription.Builder();
+ clickBuilder.addStroke(clickStroke);
+ return clickBuilder.build();
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static boolean click() {
+ if (instance == null) return false;
+ // Log.i("MouseReceiverService", "x: " + instance.getX() + " y:" + instance.getY());
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && instance.isSwiping()) {
+ return instance.stopSwipe();
+ }
+
+ return click(instance.getX(), instance.getY());
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static boolean click(float x, float y) {
+ if (instance == null) return false;
+ return instance.dispatchGesture(createClick(x, y, 1 /*ms*/), null, null);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static boolean longClick() {
+ if (instance == null) return false;
+ return instance.dispatchGesture(createClick(instance.getX(), instance.getY(),
+ ViewConfiguration.getLongPressTimeout()), null, null);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ public static boolean longClickSwipe() {
+ if (instance == null) return false;
+
+ if (instance.isSwiping()) {
+ return instance.stopSwipe();
+ } else {
+ return instance.startSwipe();
+ }
+ }
+
+ private boolean isSwiping() {
+ return swipeStoke != null;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private boolean startSwipe() {
+ assert swipeStoke == null;
+ Path path = new Path();
+ path.moveTo(getX(), getY());
+ swipeStoke = new GestureDescription.StrokeDescription(path, 0, 1, true);
+ GestureDescription.Builder builder = new GestureDescription.Builder();
+ builder.addStroke(swipeStoke);
+ ((ImageView) cursorView.findViewById(R.id.mouse_cursor)).setImageResource(R.drawable.mouse_pointer_clicked);
+ return dispatchGesture(builder.build(), null, null);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private boolean continueSwipe(float fromX, float fromY) {
+ Path path = new Path();
+ path.moveTo(fromX, fromY);
+ path.lineTo(getX(), getY());
+ swipeStoke = swipeStoke.continueStroke(path, 0, 5, true);
+ GestureDescription.Builder builder = new GestureDescription.Builder();
+ builder.addStroke(swipeStoke);
+ return dispatchGesture(builder.build(), null, null);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private boolean stopSwipe() {
+ Path path = new Path();
+ path.moveTo(getX(), getY());
+ swipeStoke = swipeStoke.continueStroke(path, 0, 1, false);
+ GestureDescription.Builder builder = new GestureDescription.Builder();
+ builder.addStroke(swipeStoke);
+ swipeStoke = null;
+ ((ImageView) cursorView.findViewById(R.id.mouse_cursor)).setImageResource(R.drawable.mouse_pointer);
+ return dispatchGesture(builder.build(), null, null);
+ }
+
+
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ public static boolean scroll(double dx, double dy) {
+ if (instance == null) return false;
+
+ instance.scrollSum += dy;
+ if (Math.signum(dy) != Math.signum(instance.scrollSum)) instance.scrollSum = dy;
+ if (Math.abs(instance.scrollSum) < 500) return false;
+ instance.scrollSum = 0;
+
+ AccessibilityNodeInfo scrollable = instance.findNodeByAciton(instance.getRootInActiveWindow(),
+ dy > 0 ? AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD
+ : AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
+
+ if (scrollable == null) return false;
+
+ return scrollable.performAction(dy > 0
+ ? AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD.getId()
+ : AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD.getId()
+ );
+ }
+
+ // https://codelabs.developers.google.com/codelabs/developing-android-a11y-service/#6
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ private AccessibilityNodeInfo findNodeByAciton(AccessibilityNodeInfo root, AccessibilityNodeInfo.AccessibilityAction action) {
+ Deque deque = new ArrayDeque<>();
+ deque.add(root);
+ while (!deque.isEmpty()) {
+ AccessibilityNodeInfo node = deque.removeFirst();
+ if (node.getActionList().contains(action)) {
+ return node;
+ }
+ for (int i = 0; i < node.getChildCount(); i++) {
+ deque.addLast(node.getChild(i));
+ }
+ }
+ return null;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
+ public static boolean backButton() {
+ if (instance == null) return false;
+ return instance.performGlobalAction(GLOBAL_ACTION_BACK);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
+ public static boolean homeButton() {
+ if (instance == null) return false;
+ return instance.performGlobalAction(GLOBAL_ACTION_HOME);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
+ public static boolean recentButton() {
+ if (instance == null) return false;
+ return instance.performGlobalAction(GLOBAL_ACTION_RECENTS);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
+ public static boolean powerButton() {
+ if (instance == null) return false;
+
+ return instance.performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (windowManager != null && cursorView != null) {
+ windowManager.removeView(cursorView);
+ }
+ }
+
+ @Override
+ public void onAccessibilityEvent(AccessibilityEvent event) {
+
+ }
+
+ @Override
+ public void onInterrupt() {
+
+ }
+}