File indexing completed on 2024-12-22 04:41:39
0001 /* 0002 * SPDX-FileCopyrightText: 2021 SohnyBohny <sohny.bean@streber24.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.MouseReceiverPlugin; 0008 0009 import android.accessibilityservice.AccessibilityService; 0010 import android.accessibilityservice.GestureDescription; 0011 import android.graphics.Path; 0012 import android.graphics.PixelFormat; 0013 import android.os.Build; 0014 import android.os.Handler; 0015 import android.util.DisplayMetrics; 0016 import android.util.Log; 0017 import android.view.Gravity; 0018 import android.view.View; 0019 import android.view.ViewConfiguration; 0020 import android.view.WindowManager; 0021 import android.view.WindowManager.LayoutParams; 0022 import android.view.accessibility.AccessibilityEvent; 0023 import android.view.accessibility.AccessibilityNodeInfo; 0024 import android.widget.ImageView; 0025 0026 import androidx.annotation.RequiresApi; 0027 import androidx.core.content.ContextCompat; 0028 0029 import org.kde.kdeconnect_tp.R; 0030 0031 import java.util.ArrayDeque; 0032 import java.util.Deque; 0033 0034 public class MouseReceiverService extends AccessibilityService { 0035 public static MouseReceiverService instance; 0036 0037 private View cursorView; 0038 private LayoutParams cursorLayout; 0039 private WindowManager windowManager; 0040 private Handler runHandler; 0041 private Runnable hideRunnable; 0042 private GestureDescription.StrokeDescription swipeStoke; 0043 private double scrollSum; 0044 0045 @Override 0046 public void onCreate() { 0047 super.onCreate(); 0048 MouseReceiverService.instance = this; 0049 Log.i("MouseReceiverService", "created"); 0050 } 0051 0052 @Override 0053 protected void onServiceConnected() { 0054 // Create an overlay and display the cursor 0055 windowManager = ContextCompat.getSystemService(this, WindowManager.class); 0056 DisplayMetrics displayMetrics = new DisplayMetrics(); 0057 windowManager.getDefaultDisplay().getMetrics(displayMetrics); 0058 0059 cursorView = View.inflate(getBaseContext(), R.layout.mouse_receiver_cursor, null); 0060 cursorLayout = new LayoutParams( 0061 LayoutParams.WRAP_CONTENT, 0062 LayoutParams.WRAP_CONTENT, 0063 LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, 0064 LayoutParams.FLAG_DISMISS_KEYGUARD | LayoutParams.FLAG_NOT_FOCUSABLE 0065 | LayoutParams.FLAG_NOT_TOUCHABLE | LayoutParams.FLAG_FULLSCREEN 0066 | LayoutParams.FLAG_LAYOUT_NO_LIMITS, 0067 PixelFormat.TRANSLUCENT); 0068 0069 // allow cursor to move over status bar on devices having a display cutout 0070 // https://developer.android.com/guide/topics/display-cutout/#render_content_in_short_edge_cutout_areas 0071 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 0072 cursorLayout.layoutInDisplayCutoutMode = LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; 0073 } 0074 0075 cursorLayout.gravity = Gravity.START | Gravity.TOP; 0076 cursorLayout.x = displayMetrics.widthPixels / 2; 0077 cursorLayout.y = displayMetrics.heightPixels / 2; 0078 0079 // https://developer.android.com/training/system-ui/navigation.html#behind 0080 cursorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 0081 | View.SYSTEM_UI_FLAG_LAYOUT_STABLE 0082 | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); 0083 0084 windowManager.addView(cursorView, cursorLayout); 0085 0086 hideRunnable = () -> { 0087 cursorView.setVisibility(View.GONE); 0088 Log.i("MouseReceiverService", "Hiding pointer due to inactivity"); 0089 }; 0090 runHandler = new Handler(); 0091 0092 cursorView.setVisibility(View.GONE); 0093 } 0094 0095 private void hideAfter5Seconds() { 0096 runHandler.removeCallbacks(hideRunnable); 0097 runHandler.postDelayed(hideRunnable, 5000); 0098 } 0099 0100 public float getX() { 0101 return cursorLayout.x + cursorView.getWidth() / 2; 0102 } 0103 0104 public float getY() { 0105 return cursorLayout.y + cursorView.getHeight() / 2; 0106 } 0107 0108 public void moveView(double dx, double dy) { 0109 DisplayMetrics displayMetrics = new DisplayMetrics(); 0110 instance.windowManager.getDefaultDisplay().getRealMetrics(displayMetrics); 0111 0112 cursorLayout.x += dx; 0113 cursorLayout.y += dy; 0114 0115 if (getX() > displayMetrics.widthPixels) 0116 cursorLayout.x = displayMetrics.widthPixels - cursorView.getWidth() / 2; 0117 if (getY() > displayMetrics.heightPixels) 0118 cursorLayout.y = displayMetrics.heightPixels - cursorView.getHeight() / 2; 0119 if (getX() < 0) cursorLayout.x = -cursorView.getWidth() / 2; 0120 if (getY() < 0) cursorLayout.y = -cursorView.getHeight() / 2; 0121 0122 new Handler(instance.getMainLooper()).post(() -> { 0123 // Log.i("MouseReceiverService", "performing move"); 0124 instance.windowManager.updateViewLayout(instance.cursorView, instance.cursorLayout); 0125 instance.cursorView.setVisibility(View.VISIBLE); 0126 }); 0127 } 0128 0129 public static boolean move(double dx, double dy) { 0130 if (instance == null) return false; 0131 0132 float fromX = instance.getX(); 0133 float fromY = instance.getY(); 0134 0135 instance.moveView(dx, dy); 0136 0137 instance.hideAfter5Seconds(); 0138 0139 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && instance.isSwiping()) { 0140 return instance.continueSwipe(fromX, fromY); 0141 } 0142 0143 return true; 0144 } 0145 0146 @RequiresApi(api = Build.VERSION_CODES.N) 0147 private static GestureDescription createClick(float x, float y, int duration) { 0148 Path clickPath = new Path(); 0149 clickPath.moveTo(x, y); 0150 GestureDescription.StrokeDescription clickStroke = 0151 new GestureDescription.StrokeDescription(clickPath, 0, duration); 0152 GestureDescription.Builder clickBuilder = new GestureDescription.Builder(); 0153 clickBuilder.addStroke(clickStroke); 0154 return clickBuilder.build(); 0155 } 0156 0157 @RequiresApi(api = Build.VERSION_CODES.N) 0158 public static boolean click() { 0159 if (instance == null) return false; 0160 // Log.i("MouseReceiverService", "x: " + instance.getX() + " y:" + instance.getY()); 0161 0162 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && instance.isSwiping()) { 0163 return instance.stopSwipe(); 0164 } 0165 0166 return click(instance.getX(), instance.getY()); 0167 } 0168 0169 @RequiresApi(api = Build.VERSION_CODES.N) 0170 public static boolean click(float x, float y) { 0171 if (instance == null) return false; 0172 return instance.dispatchGesture(createClick(x, y, 1 /*ms*/), null, null); 0173 } 0174 0175 @RequiresApi(api = Build.VERSION_CODES.N) 0176 public static boolean longClick() { 0177 if (instance == null) return false; 0178 return instance.dispatchGesture(createClick(instance.getX(), instance.getY(), 0179 ViewConfiguration.getLongPressTimeout()), null, null); 0180 } 0181 0182 @RequiresApi(api = Build.VERSION_CODES.O) 0183 public static boolean longClickSwipe() { 0184 if (instance == null) return false; 0185 0186 if (instance.isSwiping()) { 0187 return instance.stopSwipe(); 0188 } else { 0189 return instance.startSwipe(); 0190 } 0191 } 0192 0193 private boolean isSwiping() { 0194 return swipeStoke != null; 0195 } 0196 0197 @RequiresApi(api = Build.VERSION_CODES.O) 0198 private boolean startSwipe() { 0199 assert swipeStoke == null; 0200 Path path = new Path(); 0201 path.moveTo(getX(), getY()); 0202 swipeStoke = new GestureDescription.StrokeDescription(path, 0, 1, true); 0203 GestureDescription.Builder builder = new GestureDescription.Builder(); 0204 builder.addStroke(swipeStoke); 0205 ((ImageView) cursorView.findViewById(R.id.mouse_cursor)).setImageResource(R.drawable.mouse_pointer_clicked); 0206 return dispatchGesture(builder.build(), null, null); 0207 } 0208 0209 @RequiresApi(api = Build.VERSION_CODES.O) 0210 private boolean continueSwipe(float fromX, float fromY) { 0211 Path path = new Path(); 0212 path.moveTo(fromX, fromY); 0213 path.lineTo(getX(), getY()); 0214 swipeStoke = swipeStoke.continueStroke(path, 0, 5, true); 0215 GestureDescription.Builder builder = new GestureDescription.Builder(); 0216 builder.addStroke(swipeStoke); 0217 return dispatchGesture(builder.build(), null, null); 0218 } 0219 0220 @RequiresApi(api = Build.VERSION_CODES.O) 0221 public boolean stopSwipe() { 0222 Path path = new Path(); 0223 path.moveTo(getX(), getY()); 0224 if (swipeStoke == null) { 0225 return true; 0226 } 0227 swipeStoke = swipeStoke.continueStroke(path, 0, 1, false); 0228 GestureDescription.Builder builder = new GestureDescription.Builder(); 0229 builder.addStroke(swipeStoke); 0230 swipeStoke = null; 0231 ((ImageView) cursorView.findViewById(R.id.mouse_cursor)).setImageResource(R.drawable.mouse_pointer); 0232 return dispatchGesture(builder.build(), null, null); 0233 } 0234 0235 0236 public static boolean scroll(double dx, double dy) { 0237 if (instance == null) return false; 0238 0239 instance.scrollSum += dy; 0240 if (Math.signum(dy) != Math.signum(instance.scrollSum)) instance.scrollSum = dy; 0241 if (Math.abs(instance.scrollSum) < 500) return false; 0242 instance.scrollSum = 0; 0243 0244 AccessibilityNodeInfo scrollable = instance.findNodeByAciton(instance.getRootInActiveWindow(), 0245 dy > 0 ? AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD 0246 : AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); 0247 0248 if (scrollable == null) return false; 0249 0250 return scrollable.performAction(dy > 0 0251 ? AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD.getId() 0252 : AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD.getId() 0253 ); 0254 } 0255 0256 // https://codelabs.developers.google.com/codelabs/developing-android-a11y-service/#6 0257 private AccessibilityNodeInfo findNodeByAciton(AccessibilityNodeInfo root, AccessibilityNodeInfo.AccessibilityAction action) { 0258 Deque<AccessibilityNodeInfo> deque = new ArrayDeque<>(); 0259 deque.add(root); 0260 while (!deque.isEmpty()) { 0261 AccessibilityNodeInfo node = deque.removeFirst(); 0262 if (node.getActionList().contains(action)) { 0263 return node; 0264 } 0265 for (int i = 0; i < node.getChildCount(); i++) { 0266 deque.addLast(node.getChild(i)); 0267 } 0268 } 0269 return null; 0270 } 0271 0272 public static boolean backButton() { 0273 if (instance == null) return false; 0274 return instance.performGlobalAction(GLOBAL_ACTION_BACK); 0275 } 0276 0277 public static boolean homeButton() { 0278 if (instance == null) return false; 0279 return instance.performGlobalAction(GLOBAL_ACTION_HOME); 0280 } 0281 0282 public static boolean recentButton() { 0283 if (instance == null) return false; 0284 return instance.performGlobalAction(GLOBAL_ACTION_RECENTS); 0285 } 0286 0287 public static boolean powerButton() { 0288 if (instance == null) return false; 0289 0290 return instance.performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN); 0291 } 0292 0293 @Override 0294 public void onDestroy() { 0295 super.onDestroy(); 0296 0297 if (windowManager != null && cursorView != null) { 0298 windowManager.removeView(cursorView); 0299 } 0300 } 0301 0302 @Override 0303 public void onAccessibilityEvent(AccessibilityEvent event) { 0304 0305 } 0306 0307 @Override 0308 public void onInterrupt() { 0309 0310 } 0311 }