first blood

This commit is contained in:
2026-01-26 14:23:13 +08:00
parent 1634953750
commit ee72a1fb3e
9 changed files with 575 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
android/build

42
android/build.gradle Normal file
View File

@@ -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:+'
}

View File

@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.rnbot">
<application>
<service
android:name=".RNBotAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application>
</manifest>

View File

@@ -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<AccessibilityNodeInfo> findNodesByType(String type, String value) {
List<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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);
}
}

View File

@@ -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<AccessibilityNodeInfo> 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;
}
}

View File

@@ -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<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new RNBotModule(reactContext));
return modules;
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100" />

15
package.json Normal file
View File

@@ -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": "*"
}
}

123
src/index.ts Normal file
View File

@@ -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<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.requestPermission();
}
/** 检查服务是否运行 */
async isServiceEnabled(): Promise<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.isServiceEnabled();
}
/** 根据 ID 查找节点 */
async id(resId: string): Promise<NodeInfo[]> {
if (!this.checkAndroid()) return [];
return await RNBotModule.findNodes('id', resId);
}
/** 根据文本查找节点 */
async text(text: string): Promise<NodeInfo[]> {
if (!this.checkAndroid()) return [];
return await RNBotModule.findNodes('text', text);
}
/** 根据 ContentDescription 查找节点 */
async name(name: string): Promise<NodeInfo[]> {
if (!this.checkAndroid()) return [];
return await RNBotModule.findNodes('name', name);
}
/** 根据类名查找节点 */
async className(className: string): Promise<NodeInfo[]> {
if (!this.checkAndroid()) return [];
return await RNBotModule.findNodes('className', className);
}
/** 点击坐标 */
async click(x: number, y: number): Promise<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.click(x, y);
}
/** 点击节点 */
async clickNode(resId: string): Promise<boolean> {
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<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.setText(resId, text);
}
/** 返回键 */
async back(): Promise<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.back();
}
/** Home 键 */
async home(): Promise<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.home();
}
/** 最近任务键 */
async recents(): Promise<boolean> {
if (!this.checkAndroid()) return false;
return await RNBotModule.recents();
}
/** 延迟 */
delay(ms: number): Promise<void> {
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();