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();