From ee72a1fb3ec1d75bb40470d462c856417c882401 Mon Sep 17 00:00:00 2001 From: TQCasey <494294315@qq.com> Date: Mon, 26 Jan 2026 14:23:13 +0800 Subject: [PATCH] first blood --- .gitignore | 1 + android/build.gradle | 42 ++++ android/src/main/AndroidManifest.xml | 17 ++ .../com/rnbot/RNBotAccessibilityService.java | 160 +++++++++++++++ .../src/main/java/com/rnbot/RNBotModule.java | 185 ++++++++++++++++++ .../src/main/java/com/rnbot/RNBotPackage.java | 24 +++ .../res/xml/accessibility_service_config.xml | 8 + package.json | 15 ++ src/index.ts | 123 ++++++++++++ 9 files changed, 575 insertions(+) create mode 100644 .gitignore create mode 100644 android/build.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/java/com/rnbot/RNBotAccessibilityService.java create mode 100644 android/src/main/java/com/rnbot/RNBotModule.java create mode 100644 android/src/main/java/com/rnbot/RNBotPackage.java create mode 100644 android/src/main/res/xml/accessibility_service_config.xml create mode 100644 package.json create mode 100644 src/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..374b31d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +android/build diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..fbf46d1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,42 @@ +buildscript { + ext { + buildToolsVersion = "31.0.0" + minSdkVersion = 21 + compileSdkVersion = 31 + targetSdkVersion = 31 + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:7.2.1") + } +} + +apply plugin: "com.android.library" + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + + lintOptions { + abortOnError false + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation 'com.facebook.react:react-native:+' +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..817fd06 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/android/src/main/java/com/rnbot/RNBotAccessibilityService.java b/android/src/main/java/com/rnbot/RNBotAccessibilityService.java new file mode 100644 index 0000000..71ad781 --- /dev/null +++ b/android/src/main/java/com/rnbot/RNBotAccessibilityService.java @@ -0,0 +1,160 @@ +package com.rnbot; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.GestureDescription; +import android.graphics.Path; +import android.os.Bundle; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import java.util.ArrayList; +import java.util.List; + +public class RNBotAccessibilityService extends AccessibilityService { + private static RNBotAccessibilityService instance; + + public static RNBotAccessibilityService getInstance() { + return instance; + } + + public static boolean isServiceRunning() { + return instance != null; + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event) { + // 可以在这里监听各种事件 + } + + @Override + public void onInterrupt() { + // 服务中断 + } + + @Override + protected void onServiceConnected() { + super.onServiceConnected(); + instance = this; + } + + @Override + public void onDestroy() { + super.onDestroy(); + instance = null; + } + + public List findNodesByType(String type, String value) { + List result = new ArrayList<>(); + AccessibilityNodeInfo root = getRootInActiveWindow(); + if (root == null) return result; + + switch (type) { + case "id": + findNodesByIdRecursive(root, value, result); + break; + case "text": + findNodesByTextRecursive(root, value, result); + break; + case "name": + findNodesByNameRecursive(root, value, result); + break; + case "className": + findNodesByClassNameRecursive(root, value, result); + break; + } + + return result; + } + + private void findNodesByIdRecursive(AccessibilityNodeInfo node, String resId, List result) { + if (node == null) return; + + String id = node.getViewIdResourceName(); + if (id != null && id.equals(resId)) { + result.add(node); + } + + for (int i = 0; i < node.getChildCount(); i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + findNodesByIdRecursive(child, resId, result); + } + } + } + + private void findNodesByTextRecursive(AccessibilityNodeInfo node, String text, List result) { + if (node == null) return; + + CharSequence nodeText = node.getText(); + if (nodeText != null && nodeText.toString().contains(text)) { + result.add(node); + } + + for (int i = 0; i < node.getChildCount(); i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + findNodesByTextRecursive(child, text, result); + } + } + } + + private void findNodesByNameRecursive(AccessibilityNodeInfo node, String name, List result) { + if (node == null) return; + + CharSequence desc = node.getContentDescription(); + if (desc != null && desc.toString().contains(name)) { + result.add(node); + } + + for (int i = 0; i < node.getChildCount(); i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + findNodesByNameRecursive(child, name, result); + } + } + } + + private void findNodesByClassNameRecursive(AccessibilityNodeInfo node, String className, List result) { + if (node == null) return; + + CharSequence cls = node.getClassName(); + if (cls != null && cls.toString().equals(className)) { + result.add(node); + } + + for (int i = 0; i < node.getChildCount(); i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + findNodesByClassNameRecursive(child, className, result); + } + } + } + + public boolean clickAt(int x, int y) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) { + return false; + } + + Path path = new Path(); + path.moveTo(x, y); + + GestureDescription.Builder builder = new GestureDescription.Builder(); + GestureDescription gesture = builder + .addStroke(new GestureDescription.StrokeDescription(path, 0, 50)) + .build(); + + return dispatchGesture(gesture, null, null); + } + + public boolean setTextById(String resId, String text) { + List nodes = findNodesByType("id", resId); + if (nodes.isEmpty()) return false; + + AccessibilityNodeInfo node = nodes.get(0); + if (!node.isEditable()) return false; + + Bundle arguments = new Bundle(); + arguments.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text); + return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments); + } +} diff --git a/android/src/main/java/com/rnbot/RNBotModule.java b/android/src/main/java/com/rnbot/RNBotModule.java new file mode 100644 index 0000000..d474ca9 --- /dev/null +++ b/android/src/main/java/com/rnbot/RNBotModule.java @@ -0,0 +1,185 @@ +package com.rnbot; + +import android.app.Activity; +import android.content.Intent; +import android.provider.Settings; +import android.view.accessibility.AccessibilityNodeInfo; +import android.accessibilityservice.AccessibilityService; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeArray; +import com.facebook.react.bridge.WritableNativeMap; + +import java.util.List; + +public class RNBotModule extends ReactContextBaseJavaModule { + private final ReactApplicationContext reactContext; + + public RNBotModule(ReactApplicationContext context) { + super(context); + this.reactContext = context; + } + + @Override + public String getName() { + return "RNBotModule"; + } + + @ReactMethod + public void requestPermission(Promise promise) { + try { + Activity activity = getCurrentActivity(); + if (activity == null) { + promise.reject("NO_ACTIVITY", "Activity is null"); + return; + } + Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS); + activity.startActivity(intent); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void isServiceEnabled(Promise promise) { + promise.resolve(RNBotAccessibilityService.isServiceRunning()); + } + + @ReactMethod + public void findNodes(String type, String value, Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + + List nodes = service.findNodesByType(type, value); + WritableArray result = new WritableNativeArray(); + + for (AccessibilityNodeInfo node : nodes) { + WritableMap map = nodeToMap(node); + result.pushMap(map); + } + + promise.resolve(result); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void click(int x, int y, Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + boolean success = service.clickAt(x, y); + promise.resolve(success); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void setText(String resId, String text, Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + boolean success = service.setTextById(resId, text); + promise.resolve(success); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void back(Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + boolean success = service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK); + promise.resolve(success); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void home(Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + boolean success = service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME); + promise.resolve(success); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void recents(Promise promise) { + try { + RNBotAccessibilityService service = RNBotAccessibilityService.getInstance(); + if (service == null) { + promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未运行"); + return; + } + boolean success = service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS); + promise.resolve(success); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + private WritableMap nodeToMap(AccessibilityNodeInfo node) { + WritableMap map = new WritableNativeMap(); + + if (node.getText() != null) { + map.putString("text", node.getText().toString()); + } + if (node.getContentDescription() != null) { + map.putString("name", node.getContentDescription().toString()); + } + if (node.getViewIdResourceName() != null) { + map.putString("id", node.getViewIdResourceName()); + } + if (node.getClassName() != null) { + map.putString("className", node.getClassName().toString()); + } + + android.graphics.Rect bounds = new android.graphics.Rect(); + node.getBoundsInScreen(bounds); + + WritableMap boundsMap = new WritableNativeMap(); + boundsMap.putInt("left", bounds.left); + boundsMap.putInt("top", bounds.top); + boundsMap.putInt("right", bounds.right); + boundsMap.putInt("bottom", bounds.bottom); + boundsMap.putInt("centerX", (bounds.left + bounds.right) / 2); + boundsMap.putInt("centerY", (bounds.top + bounds.bottom) / 2); + + map.putMap("bounds", boundsMap); + map.putBoolean("clickable", node.isClickable()); + map.putBoolean("editable", node.isEditable()); + + return map; + } +} diff --git a/android/src/main/java/com/rnbot/RNBotPackage.java b/android/src/main/java/com/rnbot/RNBotPackage.java new file mode 100644 index 0000000..7e8f845 --- /dev/null +++ b/android/src/main/java/com/rnbot/RNBotPackage.java @@ -0,0 +1,24 @@ +package com.rnbot; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RNBotPackage implements ReactPackage { + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new RNBotModule(reactContext)); + return modules; + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/android/src/main/res/xml/accessibility_service_config.xml b/android/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..2bdc961 --- /dev/null +++ b/android/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,8 @@ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..cf7c270 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "rnauto", + "version": "1.0.0", + "description": "React Native 无障碍自动化库", + "main": "src/index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["react-native", "accessibility", "automation"], + "author": "", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6819f52 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,123 @@ + +import { NativeModules, Platform } from 'react-native'; + +const { RNBotModule } = NativeModules; + +export interface NodeBounds { + left: number; + top: number; + right: number; + bottom: number; + centerX: number; + centerY: number; +} + +export interface NodeInfo { + text?: string; + name?: string; + id?: string; + className?: string; + bounds: NodeBounds; + clickable: boolean; + editable: boolean; +} + +class RNAuto { + private checkAndroid(): boolean { + if (Platform.OS !== 'android') { + console.warn('RNAuto 仅支持 Android'); + return false; + } + return true; + } + + /** 请求无障碍权限 */ + async requestPermission(): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.requestPermission(); + } + + /** 检查服务是否运行 */ + async isServiceEnabled(): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.isServiceEnabled(); + } + + /** 根据 ID 查找节点 */ + async id(resId: string): Promise { + if (!this.checkAndroid()) return []; + return await RNBotModule.findNodes('id', resId); + } + + /** 根据文本查找节点 */ + async text(text: string): Promise { + if (!this.checkAndroid()) return []; + return await RNBotModule.findNodes('text', text); + } + + /** 根据 ContentDescription 查找节点 */ + async name(name: string): Promise { + if (!this.checkAndroid()) return []; + return await RNBotModule.findNodes('name', name); + } + + /** 根据类名查找节点 */ + async className(className: string): Promise { + if (!this.checkAndroid()) return []; + return await RNBotModule.findNodes('className', className); + } + + /** 点击坐标 */ + async click(x: number, y: number): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.click(x, y); + } + + /** 点击节点 */ + async clickNode(resId: string): Promise { + const nodes = await this.id(resId); + if (nodes.length > 0) { + const { centerX, centerY } = nodes[0].bounds; + return await this.click(centerX, centerY); + } + return false; + } + + /** 设置文本 */ + async setText(resId: string, text: string): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.setText(resId, text); + } + + /** 返回键 */ + async back(): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.back(); + } + + /** Home 键 */ + async home(): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.home(); + } + + /** 最近任务键 */ + async recents(): Promise { + if (!this.checkAndroid()) return false; + return await RNBotModule.recents(); + } + + /** 延迟 */ + delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // 别名兼容 + fullId = this.id; + findById = this.id; + findByText = this.text; + findByName = this.name; + findByClassName = this.className; +} + +export default new RNAuto();