From 8a0b1e6e98e41eb47832565650aa78230ae574e6 Mon Sep 17 00:00:00 2001 From: zyj Date: Tue, 10 Mar 2026 15:49:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=9F=BA=E7=A1=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 779 +++++++++++++++++++++------- adb/connect.go | 80 ++- adb/device.go | 52 +- cmd/main.go | 96 ++-- libs/device.go | 983 ++++++++++++++++++++++++++++++++++++ libs/doc.go | 51 +- libs/errors.go | 160 ++++++ libs/input.go | 220 ++++++++ libs/jsonrpc.go | 175 +++++++ libs/proto.go | 65 +++ libs/request.go | 165 ++---- libs/selector.go | 59 +-- libs/session.go | 86 ++++ libs/settings.go | 139 +++++ libs/swipe_ext.go | 86 ++++ libs/uiobject.go | 664 ++++++++++++++++++++++++ libs/watcher.go | 278 ++++++++++ services/install_service.go | 107 ++-- test/test.py | 8 +- 19 files changed, 3724 insertions(+), 529 deletions(-) create mode 100644 libs/device.go create mode 100644 libs/errors.go create mode 100644 libs/input.go create mode 100644 libs/jsonrpc.go create mode 100644 libs/proto.go create mode 100644 libs/session.go create mode 100644 libs/settings.go create mode 100644 libs/swipe_ext.go create mode 100644 libs/uiobject.go create mode 100644 libs/watcher.go diff --git a/README.md b/README.md index 702b6ef..ecd9d2a 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,472 @@ -# go-mobile-uiautomator +# Go Mobile UIAutomator -一个用 Go 语言实现的 Android 移动设备 UI 自动化工具,通过 ADB 协议与 Android 设备通信,实现应用安装、文件传输和 UI 自动化测试功能。 +

+ + Go Version + + + License + + + Zero Dependencies + +

-## 特性 +Go 语言实现的 Android UIAutomator2 客户端库。纯 Go 实现,零外部依赖,通过 ADB 隧道协议与 Android 设备直连,提供完整的 UI 自动化测试能力。 -- ✅ **纯 Go 实现** - 不依赖外部 ADB 命令行工具 -- ✅ **跨平台** - 支持 Windows/Linux/macOS -- ✅ **轻量级** - 无需安装 Android SDK -- ✅ **低延迟** - 直接 TCP 连接,无中间层 -- ✅ **功能完整** - 支持设备发现、文件传输、APK 安装、Shell 执行 -- ✅ **UIAutomator2** - 内置 UIAutomator2 服务支持 +参考 Python [openatx/uiautomator2](https://github.com/openatx/uiautomator2) 项目实现,连接方案与 Python 版本完全一致。 -## 环境要求 +## 功能特性 -- Go 1.25.1+ -- 已启动的 ADB 服务器(Android SDK 自带) -- Android 设备已开启 USB 调试 +- ✅ **ADB 隧道** - 纯 Go 实现 ADB TCP 协议,通过 ADB 隧道直连设备,无需端口转发 +- ✅ **设备管理** - 设备发现、连接、信息查询 +- ✅ **UIAutomator2 服务** - 自动启动和管理 UIAutomator2 服务端 +- ✅ **UI 元素操作** - 点击、滑动、输入、拖拽、手势等完整 UI 操作 +- ✅ **元素定位** - 支持 text、resourceId、className、description 等多种定位方式 +- ✅ **便捷选择器** - `d.ByText("登录").Click()` Python 风格链式调用 +- ✅ **输入法集成** - AdbKeyboard 输入法支持,支持中文输入 +- ✅ **Watcher 监控** - 自动监控并处理弹窗(权限提示、广告等) +- ✅ **文件传输** - 通过 ADB Sync 协议推送文件 +- ✅ **应用管理** - 启动、停止、卸载、清除应用数据 +- ✅ **截图功能** - 设备屏幕截图 +- ✅ **剪贴板** - 读写设备剪贴板 +- ✅ **Session 会话** - 应用会话管理,自动检测应用存活状态 +- ✅ **配置管理** - 线程安全的设备参数配置 +- ✅ **错误体系** - 完整的错误类型定义和自动重试机制 +- ✅ **JSON-RPC** - 完整的 JSON-RPC 2.0 通信层 ## 安装 -### 作为项目引用 - ```bash -go get github.com/zhuy1228/go-mobile-uiautomator@latest -``` - -### 从源码构建 - -```bash -git clone https://github.com/zhuy1228/go-mobile-uiautomator.git -cd go-mobile-uiautomator -go mod download +go get github.com/zhuy1228/go-mobile-uiautomator ``` ## 快速开始 -### 1. 基础设置 - -确保 ADB 服务器正在运行: - -```bash -adb devices -``` - -### 2. 运行示例 - -修改 `cmd/main.go` 中的设备配置: +### 连接设备 ```go -import "github.com/zhuy1228/go-mobile-uiautomator/adb" +package main -const serial = "your-device-serial" // 你的设备序列号 -const addr = "127.0.0.1:5037" // ADB 服务器地址 -``` - -运行程序: - -```bash -go run cmd/main.go -``` - -### 3. 启动 UIAutomator2 服务 - -程序会自动: -1. 列出已连接的设备 -2. 推送 `u2.jar` 到设备 -3. 启动 UIAutomator2 服务 -4. 输出服务日志 - -## 核心功能 - -### 设备管理 - -```go -// 列出所有设备 -payload, _ := adb.ListDevicesRaw(addr, 15*time.Second) -devices := adb.ParseDevicesPayload(payload) - -// 通过 Product 名称查找设备 -serial, err := adb.FindSerialByProduct(addr, "23113RKC6C") - -// 建立设备连接 -conn, err := adb.DialADB(addr, 15*time.Second) -defer conn.Close() -adb.TransportTo(conn, serial) -``` - -### Shell 命令执行 - -```go -// 执行 Shell 命令 -conn, _ := adb.DialADB(addr, 15*time.Second) -adb.TransportTo(conn, serial) -output, err := adb.ExecShell(conn, "getprop") -fmt.Println(string(output)) -``` - -### 文件传输 - -```go -// 推送文件到设备 -sync := adb.InitSync(conn) -n, err := sync.SyncPushFile( - "local/path/file.png", // 本地文件路径 - "/sdcard/remote.png", // 设备目标路径 - 0644, // 文件权限 - true, // 调试模式 +import ( + "fmt" + "github.com/zhuy1228/go-mobile-uiautomator/libs" ) + +func main() { + // 创建设备连接(自动推送 u2.jar、启动 UIAutomator2 服务) + d, err := libs.NewDevice("设备序列号") + if err != nil { + panic(err) + } + defer d.Close() + + // 自定义 ADB 地址 + // d, err := libs.NewDevice("设备序列号", "192.168.1.100:5037") + + // 获取设备信息 + info, err := d.Info() + if err != nil { + panic(err) + } + fmt.Printf("屏幕尺寸: %v x %v\n", info["displayWidth"], info["displayHeight"]) +} ``` -### APK 安装 +### 设备发现 ```go -// 安装 APK -services.InstallServiceApk(addr, serial) +import ( + "fmt" + "time" + "github.com/zhuy1228/go-mobile-uiautomator/adb" + "github.com/zhuy1228/go-mobile-uiautomator/libs" +) -// 或手动安装 -remotePath := "/data/local/tmp/app.apk" -output, err := adb.InstallApkOnDevice(addr, serial, remotePath, "-r", true) +// 列出所有连接的设备 +payload, err := adb.ListDevicesRaw(libs.DefaultADBAddr, 15*time.Second) +devices := adb.ParseDevicesPayload(payload) +for _, dev := range devices { + fmt.Printf("设备: %s 状态: %s\n", dev.Serial, dev.State) +} +``` + +### 应用管理 + +```go +// 启动应用(stop=true 先停止再启动) +d.AppStart("com.example.app", "", true) + +// 启动应用并指定 Activity +d.AppStart("com.example.app", ".MainActivity", false) + +// 获取当前运行的应用信息 +info, _ := d.AppCurrent() +fmt.Printf("当前应用: %s PID: %s\n", info["package"], info["pid"]) + +// 等待应用启动(超时 20 秒,front=true 等待应用到前台) +pid, err := d.AppWait("com.example.app", 20.0, true) + +// 停止应用 +d.AppStop("com.example.app") + +// 清除应用数据 +d.AppClear("com.example.app") + +// 卸载应用 +d.AppUninstall("com.example.app") +``` + +### UI 元素查找 + +```go +// ===== 便捷方法(推荐) ===== + +// 通过 text 查找并操作 +d.ByText("登录").Click() + +// 通过 textContains 模糊查找 +d.ByTextContains("登").Click() + +// 通过 resourceId 查找 +d.ByResourceId("com.example:id/btn_login").Click() + +// 通过 description 查找 +d.ByDescription("搜索").Click() + +// 通过 className 查找 +d.ByClassName("android.widget.EditText").SetText("Hello") + +// 多条件组合查找 +el := d.By(libs.P{ + "className": "android.widget.TextView", + "text": "确定", +}) +el.Click() + +// ===== FindElement 方法 ===== + +el, err := d.FindElement(map[string]interface{}{ + "resourceId": "com.example:id/btn_login", +}) +if err != nil { + panic(err) +} +el.Click() + +// 检查元素是否存在 +exists, _ := d.Exists(map[string]interface{}{"text": "登录"}) +``` + +### 等待操作 + +```go +el := d.ByText("登录") + +// 等待元素出现(超时 10 秒) +found, _ := el.Wait(true, 10.0) + +// 等待元素消失 +gone, _ := el.WaitGone(10.0) + +// 等待元素出现,不存在则返回错误 +err := el.MustWait(10.0) + +// 设置全局隐式等待超时 +d.ImplicitlyWait(20.0) +``` + +### 点击操作 + +```go +// 坐标点击 +d.Click(500, 800) + +// 双击 +d.DoubleClick(500, 800, 0) + +// 长按坐标(2 秒) +d.LongClick(500, 800, 2.0) + +// 元素点击 +d.ByText("确定").Click() + +// 带偏移的点击(相对元素宽高的比例,0.5 = 中心) +d.ByText("确定").ClickWithOffset(0.5, 0.5) + +// 点击元素(如果存在),返回是否成功 +clicked := d.ByText("确定").ClickExists(5.0) + +// 持续点击直到元素消失(最多重试 5 次,间隔 1 秒) +d.ByText("确定").ClickGone(5, 1.0) + +// 元素长按(1.5 秒) +d.ByText("确定").LongClick(1.5) +``` + +### 文本输入 + +```go +el := d.ByResourceId("com.example:id/input") + +// 获取元素文本 +text, _ := el.GetText() + +// 设置文本(先清空再输入) +el.SetText("Hello World") + +// 清除文本 +el.ClearText() + +// 追加文本 +el.SendKeys("additional text") + +// 使用 AdbKeyboard 输入法(支持中文) +im := libs.NewInputMethod(d) +im.SendKeys("你好世界") +im.ClearText() // 清除文本 +im.SendAction("done") // 模拟输入法动作 +``` + +### 滑动操作 + +```go +// 坐标滑动(steps 控制速度,每步约 5ms) +d.Swipe(500, 1500, 500, 500, 55) + +// 按持续时间滑动(0.5 秒) +d.SwipeWithDuration(500, 1500, 500, 500, 0.5) + +// 多点连续滑动 +d.SwipePoints([][2]int{{500, 1500}, {500, 1000}, {500, 500}}, 0.2) + +// 使用 SwipeExt 扩展滑动 +se := libs.NewSwipeExt(d) +se.Up(0.8, 55) // 向上滑动 80% 屏幕 +se.Down(0.5, 55) // 向下滑动 50% 屏幕 +se.Left(0.6, 55) // 向左滑动 60% 屏幕 +se.Right(0.4, 55) // 向右滑动 40% 屏幕 + +// 元素内滑动 +d.ByClassName("android.widget.ListView").UiSwipe("up", 55) +d.ByClassName("android.widget.ListView").UiSwipe("down", 55) + +// 拖拽 +d.Drag(100, 200, 500, 600, 0.5) +d.ByText("拖拽我").DragTo(500, 600, 0.5) +``` + +### 滚动查找 + +```go +list := d.ByClassName("android.widget.ListView") + +// 向前/向后滚动 +list.ScrollForward(true, 55) // 垂直向前 +list.ScrollBackward(true, 55) // 垂直向后 + +// 滚动到顶部/底部 +list.ScrollToBeginning(true, 10, 55) // 最多 10 次 +list.ScrollToEnd(true, 10, 55) + +// 滚动直到目标元素可见 +list.ScrollTo(map[string]interface{}{"text": "目标文本"}, true) + +// 快速滑动(Fling) +list.FlingForward(true) +list.FlingBackward(true) +list.FlingToBeginning(true, 10) +list.FlingToEnd(true, 10) +``` + +### 手势操作 + +```go +el := d.ByResourceId("com.example:id/image") + +// 捏合(缩小) +el.PinchIn(50, 10) + +// 捏合(放大) +el.PinchOut(50, 10) +``` + +### 子元素和兄弟元素 + +```go +// 查找子元素 +parent := d.ByClassName("android.widget.LinearLayout") +child, _ := parent.Child(map[string]interface{}{"className": "android.widget.Button"}) +child.Click() + +// 查找兄弟元素 +sibling, _ := d.ByText("标题").Sibling(map[string]interface{}{"className": "android.widget.Button"}) +sibling.Click() + +// 通过文本在子元素中搜索 +item, _ := parent.ChildByText("目标文本", map[string]interface{}{"className": "android.widget.TextView"}) + +// 获取匹配元素数量 +count, _ := d.ByClassName("android.widget.Button").Count() + +// 获取指定索引的元素 +d.ByClassName("android.widget.Button").Instance(0).Click() // 第一个 +d.ByClassName("android.widget.Button").Instance(-1).Click() // 最后一个 +``` + +### Watcher 弹窗监控 + +```go +// 创建 Watcher 上下文(builtin=true 自动添加常见弹窗规则) +w := libs.NewWatchContext(d, true) + +// 添加监控规则:点击"同意"按钮 +w.WhenText("同意").Click() + +// 添加监控规则:点击"允许"按钮 +w.WhenText("ALLOW").Click() + +// 添加监控规则:按下返回键关闭弹窗 +w.WhenText("取消").Press("back") + +// 添加监控规则:自定义回调 +w.WhenDescription("广告").Call(func(d *libs.Device) error { + fmt.Println("检测到广告弹窗") + return d.Press("back") +}) + +// 启动监控(后台运行) +w.Start() + +// 等待页面稳定(5 秒内无弹窗,超时 30 秒) +w.WaitStable(5.0, 30.0) + +// 停止监控 +w.Stop() +``` + +### Session 会话管理 + +```go +// 创建应用会话(attach=false 启动新实例) +session, err := libs.NewSession(d, "com.example.app", false) +if err != nil { + panic(err) +} + +// 检查应用是否仍在运行 +if session.Running() { + fmt.Println("应用运行中, PID:", session.PID()) +} + +// 会话中的 JSON-RPC 调用会自动检查应用状态 +// 如果应用退出,会返回 SessionBrokenError + +// 重启应用 +session.Restart() + +// 关闭会话 +session.Close() +``` + +### 截图 + +```go +// 获取截图(JPEG 格式字节数据) +imgData, err := d.Screenshot() +if err != nil { + panic(err) +} + +// 保存到文件 +os.WriteFile("screenshot.png", imgData, 0644) +``` + +### 设备控制 + +```go +// 亮屏/息屏 +d.ScreenOn() +d.ScreenOff() + +// 解锁屏幕 +d.Unlock() + +// 按键操作 +d.Press("home") +d.Press("back") +d.Press("recent") +d.Press("volume_up") +d.Press("volume_down") +d.Press("power") +d.Press("enter") + +// 发送按键码 +d.PressKeyCode(4, 0) // KEYCODE_BACK + +// 长按按键 +d.LongPress("power") + +// 发送按键事件 +d.Keyevent("KEYCODE_MENU") + +// 屏幕方向 +orientation, _ := d.Orientation() // 获取当前方向 +d.SetOrientation("natural") // 竖屏 +d.SetOrientation("left") // 左横屏 +d.FreezeRotation(true) // 冻结旋转 + +// 打开通知栏/快速设置 +d.OpenNotification() +d.OpenQuickSettings() + +// 打开 URL +d.OpenURL("https://www.example.com") + +// 剪贴板 +d.SetClipboard("复制的文本") +text, _ := d.GetClipboard() + +// Toast +d.MakeToast("提示消息", 2000) // 2000 毫秒 +toast, _ := d.GetLastToast() +d.ClearToast() + +// 获取页面层级结构 +hierarchy, _ := d.DumpHierarchy(false, 50) + +// 执行 Shell 命令 +result, _ := d.Shell("ls", "/sdcard/") +fmt.Println(result.Output) + +// 获取窗口大小 +w, h, _ := d.WindowSize() +fmt.Printf("窗口大小: %d x %d\n", w, h) + +// 获取设备 WLAN IP +ip, _ := d.WlanIP() + +// 推送文件到设备 +d.Push("local/file.txt", "/sdcard/file.txt") +``` + +### 配置管理 + +```go +// 设置等待超时(秒) +d.Settings().Set("wait_timeout", 30.0) + +// 获取配置 +timeout := d.Settings().GetFloat64("wait_timeout") + +// 设置操作延迟 +d.Settings().Set("operation_delay", []float64{0.5, 1.0}) // [操作前延迟, 操作后延迟] + +// 设置需要延迟的操作方法 +d.Settings().Set("operation_delay_methods", []string{"click", "swipe"}) + +// 设置隐式等待超时 +d.ImplicitlyWait(20.0) ``` ### UI 选择器 @@ -126,47 +475,108 @@ output, err := adb.InstallApkOnDevice(addr, serial, remotePath, "-r", true) // 创建选择器 selector := libs.MustNew(map[string]interface{}{ "className": "android.widget.TextView", - "text": "登录", + "text": "登录", }) // 添加子元素选择 selector.Child(map[string]interface{}{ "resourceId": "com.example:id/button", - "instance": 0, + "instance": 0, }) // 序列化为 JSON jsonData, _ := selector.ToJSON() ``` +### APK 安装 + +```go +import "github.com/zhuy1228/go-mobile-uiautomator/services" + +// 安装 UIAutomator2 JAR(自动检查设备端是否已存在) +err := services.InstallServiceJar(addr, serial, "", false) + +// 安装 UIAutomator2 APK +err = services.InstallServiceApk(addr, serial, "", false) + +// 强制重新安装 +err = services.InstallServiceApk(addr, serial, "/path/to/custom.apk", true) +``` + ## 项目结构 ``` go-mobile-uiautomator/ -├── adb/ # ADB 协议实现 -│ ├── connect.go # 连接管理、命令发送/接收 -│ ├── device.go # 设备发现、APK 安装 -│ └── sync.go # 文件同步协议 -├── assets/ # 资源文件 -│ ├── u2.jar # UIAutomator2 服务端 -│ ├── app-uiautomator.apk -│ └── sync.sh -├── cmd/ # 主程序 +├── adb/ # ADB 协议实现 +│ ├── connect.go # 连接管理、ADB 隧道、命令发送/接收 +│ ├── device.go # 设备发现、APK 安装 +│ ├── sync.go # 文件同步协议(Sync Push) +│ └── doc.go # 包文档 +├── libs/ # UIAutomator2 客户端核心库 +│ ├── device.go # 核心设备客户端(连接、操作、应用管理) +│ ├── uiobject.go # UI 元素操作(点击、输入、滑动、滚动) +│ ├── jsonrpc.go # JSON-RPC 2.0 通信层 +│ ├── selector.go # UI 选择器(定位条件构建) +│ ├── request.go # ADB 隧道 + 自定义 HTTP Transport +│ ├── input.go # AdbKeyboard 输入法集成 +│ ├── watcher.go # Watcher 弹窗自动监控 +│ ├── session.go # 应用会话管理 +│ ├── swipe_ext.go # 扩展滑动操作 +│ ├── settings.go # 线程安全配置管理 +│ ├── errors.go # 完整错误类型定义 +│ ├── proto.go # 协议常量和枚举 +│ └── doc.go # 包文档 +├── services/ # 服务模块 +│ ├── install_service.go # UIAutomator2 安装服务 +│ └── doc.go # 包文档 +├── assets/ # 资源文件 +│ ├── u2.jar # UIAutomator2 服务端 +│ ├── app-uiautomator.apk # UIAutomator2 APK +│ └── sync.sh # 资源同步脚本 +├── cmd/ # 主程序入口 │ └── main.go -├── config/ # 配置管理 -│ └── index.go -├── libs/ # 工具库 -│ ├── request.go # HTTP over ADB -│ └── selector.go # UI 选择器 -├── services/ # 服务模块 -│ └── install_service.go # UIAutomator2 安装 -├── test/ -│ └── test.py -├── config.yaml # 配置文件 -├── go.mod +├── go.mod # Go 模块定义(零外部依赖) └── README.md ``` +## 架构说明 + +``` +┌─────────────────────────────────────────────────┐ +│ 用户代码 │ +├─────────────────────────────────────────────────┤ +│ libs.Device / libs.UiObject / libs.Session │ +│ (设备操作 / UI 元素 / 会话管理) │ +├─────────────────────────────────────────────────┤ +│ libs.JsonRpcWrapper │ +│ (JSON-RPC 2.0 通信层) │ +├─────────────────────────────────────────────────┤ +│ libs.AdbTunnelDevice + http.Transport │ +│ (ADB 隧道 + 标准 HTTP 客户端) │ +├─────────────────────────────────────────────────┤ +│ adb.CreateTunnel (纯 Go ADB 协议) │ +│ (TCP → ADB Server → transport → tcp:9008) │ +├─────────────────────────────────────────────────┤ +│ Android 设备 │ +│ UIAutomator2 HTTP 服务 (端口 9008) │ +└─────────────────────────────────────────────────┘ +``` + +### 连接原理 + +与 Python uiautomator2 采用完全相同的 ADB 隧道方案: + +1. **每次 HTTP 请求**通过 `adb.CreateTunnel()` 建立一条 ADB 隧道到设备端口 9008 +2. `http.Transport` 的 `DialContext` 被替换为 ADB 隧道连接 +3. Go 标准 `http.Client` 在隧道上发送完整的 HTTP/1.1 请求 +4. 请求完成后隧道自动关闭,**无需 `adb forward`,不占用本地端口** + +``` +Go http.Client → http.Transport.DialContext → adb.CreateTunnel() + ↓ ↓ +HTTP/1.1 请求 ←→ ADB Server ←→ 设备:9008 (UIAutomator2) +``` + ## ADB 协议实现 本项目实现了以下 ADB 协议功能: @@ -176,95 +586,118 @@ go-mobile-uiautomator/ | 设备列表 | `host:devices-l` | 列出所有连接的设备 | | 设备路由 | `host:transport:` | 切换到指定设备 | | Shell 执行 | `shell:` | 执行 Shell 命令 | +| ADB 隧道 | `tcp:` | 建立到设备端口的直连隧道 | | 文件同步 | `sync:` | 启动文件传输协议 | | 文件推送 | `SEND/DATA/DONE` | Sync 协议传输文件 | -## 配置说明 +## 错误类型 -`config.yaml` 配置文件: +本库定义了完整的错误类型层次,便于精确错误处理: -```yaml -appName: "Go Desk" -port: 6997 # 本地监听端口 -wsUrl: "106.12.33.188:6996" # WebSocket 地址 -stunUrl: "stun:106.12.33.188:3478" # STUN 服务器 -apiUrl: "http://106.12.33.188:6996" # API 地址 -siteFileDir: "www" # 网页文件目录 -``` - -加载配置: - -```go -cfg, err := config.LoadConfig() -if err != nil { - log.Fatal(err) -} -fmt.Println(cfg.Port, cfg.WsUrl) -``` +| 错误类型 | 说明 | +|---------|------| +| `DeviceError` | 设备通用错误 | +| `ConnectError` | 连接错误 | +| `HTTPError` | HTTP 请求错误 | +| `HTTPTimeoutError` | HTTP 超时错误 | +| `AdbShellError` | ADB Shell 执行错误 | +| `RPCError` | JSON-RPC 调用错误 | +| `RPCUnknownError` | 未知 RPC 错误 | +| `RPCInvalidError` | 无效 RPC 响应 | +| `RPCStackOverflowError` | Java 端栈溢出 | +| `UiObjectNotFoundError` | UI 元素未找到 | +| `UiAutomationNotConnectedError` | UIAutomation 服务断开 | +| `HierarchyEmptyError` | UI 层级为空 | +| `LaunchUiAutomationError` | UIAutomator2 启动失败 | +| `SessionBrokenError` | 应用会话中断 | +| `AppNotFoundError` | 应用未安装 | +| `InputIMEError` | 输入法错误 | ## 常见问题 ### Q: 如何获取设备序列号? -A: 运行 `adb devices` 或使用代码: +运行 `adb devices` 或使用代码: ```go -payload, _ := adb.ListDevicesRaw("127.0.0.1:5037", 15*time.Second) +payload, _ := adb.ListDevicesRaw(libs.DefaultADBAddr, 15*time.Second) devices := adb.ParseDevicesPayload(payload) for _, dev := range devices { fmt.Println("Serial:", dev.Serial) } ``` -### Q: 文件推送失败怎么办? - -A: 检查以下几点: -1. 设备已正确连接且授权 -2. 目标路径有写入权限(如 `/sdcard/` 需要存储权限) -3. 文件路径使用绝对路径 -4. 启用调试模式查看详细日志 - ### Q: UIAutomator2 服务启动失败? -A: 确保: +确保: 1. `assets/u2.jar` 文件存在 -2. 设备已获取 root 权限或使用 `/data/local/tmp/` 路径 -3. CLASSPATH 环境变量正确设置 +2. 设备已正确连接且已授权 USB 调试 +3. 使用 `NewDevice()` 会自动推送 JAR 并启动服务,无需手动管理 + +### Q: 如何处理弹窗干扰自动化? + +使用 Watcher 机制自动处理弹窗: + +```go +w := libs.NewWatchContext(d, true) // builtin=true 自带常见弹窗规则 +w.WhenText("允许").Click() +w.Start() +defer w.Stop() +``` + +### Q: 如何输入中文? + +使用 AdbKeyboard 输入法: + +```go +im := libs.NewInputMethod(d) +im.SendKeys("你好世界") +``` + +### Q: 连接已有服务(不启动新服务)? + +```go +// 适用于 UIAutomator2 已经在设备上运行的场景 +d := libs.NewDeviceWithoutStart("emulator-5554") +d.ByText("Hello").Click() +``` ## 开发计划 -- [ ] 添加完整的错误处理和重连机制 -- [ ] 实现 WebSocket 远程控制 -- [ ] 支持批量设备管理 -- [ ] 添加 UI 自动化测试框架 -- [ ] 完善 HTTP over ADB 功能集成 -- [ ] 添加设备截图和录屏功能 -- [ ] 实现日志记录系统 +- [x] 纯 Go ADB 协议实现 +- [x] ADB 隧道直连(与 Python 一致) +- [x] 设备发现和连接管理 +- [x] JSON-RPC 2.0 通信层 +- [x] 完整 UI 元素操作(点击、滑动、输入、拖拽) +- [x] 便捷选择器(ByText、ByResourceId 等) +- [x] Watcher 弹窗自动监控 +- [x] Session 会话管理 +- [x] AdbKeyboard 输入法集成 +- [x] 截图功能 +- [x] 应用生命周期管理 +- [x] 完整错误类型体系 +- [x] 线程安全配置管理 +- [ ] XPath 选择器支持 +- [ ] 设备录屏功能 +- [ ] 批量设备管理 +- [ ] 完整单元测试 ## 依赖 +**零外部依赖** — 仅使用 Go 标准库。 + ```go module github.com/zhuy1228/go-mobile-uiautomator -require ( - gopkg.in/yaml.v3 v3.0.1 -) +go 1.25.1 ``` ## 许可证 MIT License -## 贡献 - -欢迎提交 Issue 和 Pull Request! - -## 作者 - -zhuy1228 - ## 致谢 本项目参考了以下开源项目: -- [UIAutomator2](https://github.com/openatx/uiautomator2) -- [Android ADB Protocol](https://android.googlesource.com/platform/packages/modules/adb/) +- [openatx/uiautomator2](https://github.com/openatx/uiautomator2) — Python Android 自动化框架 +- [Android ADB Protocol](https://android.googlesource.com/platform/packages/modules/adb/) — ADB 协议规范 diff --git a/adb/connect.go b/adb/connect.go index 9b62353..d30ae41 100644 --- a/adb/connect.go +++ b/adb/connect.go @@ -202,35 +202,71 @@ func ReadResponse(conn net.Conn, debug bool) (string, []byte, error) { return st, nil, nil } -// LaunchUiautomator 连接 ADB 服务器,路由到指定设备,启动 UIAutomator2 服务 -// 启动后持续将服务日志输出到标准输出 -// addr 为 ADB 服务器地址,serial 为设备序列号 -func LaunchUiautomator(addr, serial string) { - conn, err := DialADB(addr, 15*time.Second) +// ConnectToDevice 建立到指定设备的 ADB 连接并完成路由 +// 返回已路由到目标设备的连接,调用者负责关闭 +// 这是一个便捷函数,封装了 DialADB + TransportTo 的常见组合 +func ConnectToDevice(addr, serial string, timeout time.Duration) (net.Conn, error) { + conn, err := DialADB(addr, timeout) if err != nil { - fmt.Println("连接失败:", err) - return + return nil, fmt.Errorf("连接 ADB 服务器失败: %w", err) } - - // 路由到目标设备 if err := TransportTo(conn, serial); err != nil { - fmt.Println("设备路由失败:", err) - return + conn.Close() + return nil, fmt.Errorf("设备路由失败: %w", err) } + return conn, nil +} - // 启动 UIAutomator2 服务 - cmd := "shell:CLASSPATH=/data/local/tmp/u2.jar app_process / com.wetest.uia2.Main" - if err := WriteAdbCmd(conn, cmd); err != nil { - fmt.Println("发送命令失败:", err) - return +// PushFile 通过 ADB 推送本地文件到设备 +// 这是一个独立函数,不依赖 Device 实例,适合初始化阶段使用 +// addr 为 ADB 服务器地址,serial 为设备序列号 +// localPath 为本地文件路径,remotePath 为设备目标路径 +// mode 为文件权限(如 0644),debug 为 true 时输出调试信息 +func PushFile(addr, serial, localPath, remotePath string, mode int, debug bool) (int64, error) { + conn, err := ConnectToDevice(addr, serial, 15*time.Second) + if err != nil { + return 0, err + } + defer conn.Close() + + s := InitSync(conn) + return s.SyncPushFile(localPath, remotePath, mode, debug) +} + +// ---------- ADB 隧道(与 Python uiautomator2 完全一致) ---------- + +// CreateTunnel 建立到设备指定端口的 ADB 隧道 +// 等同于 Python adbutils 的 device.create_connection(Network.TCP, port) +// +// 工作原理: +// 1. 连接 ADB 服务器 +// 2. 通过 transport 选择目标设备 +// 3. 发送 tcp: 命令建立隧道 +// 4. 返回的连接是一条直通设备端口的原始 TCP 管道 +// +// 返回的连接由调用者负责关闭 +func CreateTunnel(addr, serial string, port int) (net.Conn, error) { + conn, err := DialADB(addr, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("连接 ADB 服务器失败: %w", err) + } + if err := TransportTo(conn, serial); err != nil { + conn.Close() + return nil, fmt.Errorf("设备路由失败: %w", err) + } + if err := WriteAdbCmd(conn, fmt.Sprintf("tcp:%d", port)); err != nil { + conn.Close() + return nil, fmt.Errorf("发送隧道命令失败: %w", err) } status, err := ReadStatus(conn) if err != nil { - fmt.Println("读取状态失败:", err) - return + conn.Close() + return nil, fmt.Errorf("读取隧道状态失败: %w", err) } - fmt.Println("UIAutomator2 启动状态:", status) - - // 持续输出 UIAutomator2 服务日志 - io.Copy(os.Stdout, conn) + if status != "OKAY" { + msg, _ := ReadLenFrame(conn) + conn.Close() + return nil, fmt.Errorf("ADB 隧道建立失败: %s %s", status, string(msg)) + } + return conn, nil } diff --git a/adb/device.go b/adb/device.go index e79c815..8270205 100644 --- a/adb/device.go +++ b/adb/device.go @@ -21,12 +21,6 @@ type DeviceInfo struct { Props map[string]string // 额外属性 } -// AdbDevice 表示一个已建立连接的 ADB 设备 -type AdbDevice struct { - Connect net.Conn // 到设备的 TCP 连接 - Serial string // 设备序列号 -} - // ListDevicesRaw 向 ADB 服务器请求设备列表并返回原始文本 // addr 为 ADB 服务器地址,timeout 为超时时间 func ListDevicesRaw(addr string, timeout time.Duration) (string, error) { @@ -146,33 +140,6 @@ func parseGetprop(raw []byte) map[string]string { return m } -// ParseDevicesMap 将设备列表文本解析为 serial → product 的映射 -// 同时返回原始行列表 -func ParseDevicesMap(payload string) (map[string]string, []string) { - m := make(map[string]string) - lines := strings.Split(payload, "\n") - for _, ln := range lines { - ln = strings.TrimSpace(ln) - if ln == "" || strings.HasPrefix(ln, "List of devices attached") { - continue - } - fields := strings.Fields(ln) - if len(fields) < 2 { - continue - } - serial := fields[0] - product := "" - for _, kv := range fields[2:] { - if strings.HasPrefix(kv, "product:") { - product = strings.TrimPrefix(kv, "product:") - break - } - } - m[serial] = product - } - return m, lines -} - // FindSerialByProduct 根据产品名称查找设备序列号 // 优先从设备列表中匹配,如果列表中没有 product 字段,则回退到逐设备查询 getprop func FindSerialByProduct(addr, targetProduct string) (string, error) { @@ -180,10 +147,10 @@ func FindSerialByProduct(addr, targetProduct string) (string, error) { if err != nil { return "", err } - m, _ := ParseDevicesMap(payload) - for serial, product := range m { - if product == targetProduct { - return serial, nil + devices := ParseDevicesPayload(payload) + for _, dev := range devices { + if dev.Product == targetProduct { + return dev.Serial, nil } } // 回退:逐设备查询 getprop ro.product.model @@ -198,15 +165,12 @@ func FindSerialByProduct(addr, targetProduct string) (string, error) { continue } serial := fields[0] - conn, err := DialADB(addr, 2*time.Second) + conn, err := ConnectToDevice(addr, serial, 2*time.Second) if err != nil { continue } - defer conn.Close() - if err := TransportTo(conn, serial); err != nil { - continue - } out, err := ExecShell(conn, "getprop ro.product.model") + conn.Close() if err == nil { if strings.TrimSpace(string(out)) == targetProduct { return serial, nil @@ -222,13 +186,11 @@ func FindSerialByProduct(addr, targetProduct string) (string, error) { // pmArgs 为 pm install 的额外参数(如 "-r" 表示覆盖安装),默认为 "-r" // debug 为 true 时输出调试信息 func InstallApkOnDevice(addr, serial string, remoteTmp string, pmArgs string, debug bool) (string, error) { - conn, err := DialADB(addr, 15*time.Second) + conn, err := ConnectToDevice(addr, serial, 15*time.Second) if err != nil { - fmt.Println("连接失败:", err) return "", err } defer conn.Close() - TransportTo(conn, serial) if pmArgs == "" { pmArgs = "-r" diff --git a/cmd/main.go b/cmd/main.go index ab5be22..e76d425 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,91 +6,57 @@ import ( "time" "github.com/zhuy1228/go-mobile-uiautomator/adb" - "github.com/zhuy1228/go-mobile-uiautomator/services" + "github.com/zhuy1228/go-mobile-uiautomator/libs" ) // 设备配置,根据实际环境修改 const ( - serial = "emulator-5554" // 设备序列号 - addr = "127.0.0.1:5037" // ADB 服务器地址 + serial = "emulator-5554" // 设备序列号 ) func main() { - filePushInstall() + launchUiautomator() } // launchUiautomator 推送服务文件并启动 UIAutomator2 func launchUiautomator() { - filePushInstall() - go adb.LaunchUiautomator(addr, serial) - select {} // 阻塞等待,持续输出日志 -} + // 列出设备 + payload, _ := adb.ListDevicesRaw(libs.DefaultADBAddr, 15*time.Second) + devices := adb.ParseDevicesPayload(payload) + b, _ := json.MarshalIndent(devices, "", " ") + fmt.Println(string(b)) -// filePushInstall 列出设备并推送 u2.jar 到设备 -func filePushInstall() { - payload, _ := adb.ListDevicesRaw(addr, 15*time.Second) - m := adb.ParseDevicesPayload(payload) - b2, _ := json.MarshalIndent(m, "", " ") - fmt.Println(string(b2)) - - services.InstallServiceJar(addr, serial) -} - -// filePush 文件推送验证示例 -func filePush() { - // 根据实际环境修改以下参数 - local := "C:/Users/01/Desktop/aaa.PNG" - remote := "/sdcard/ccc.PNG" - mode := 0644 - targetProduct := "23113RKC6C" - - devSerial, err := adb.FindSerialByProduct(addr, targetProduct) + // 使用 libs.NewDevice 启动 UIAutomator2 服务(不传 addr 使用默认地址) + d, err := libs.NewDevice(serial) if err != nil { - fmt.Println("查找设备失败:", err) + fmt.Println("启动失败:", err) return } - fmt.Println("找到设备:", devSerial) + defer d.Close() // 退出时停止 UIAutomator2 - conn, err := adb.DialADB(addr, 15*time.Second) + // 获取设备信息 + info, err := d.Info() if err != nil { - fmt.Println("连接失败:", err) + fmt.Println("获取设备信息失败:", err) return } - defer conn.Close() - adb.TransportTo(conn, devSerial) + fmt.Printf("设备信息: %v\n", info) - sync := adb.InitSync(conn) - n, err := sync.SyncPushFile(local, remote, mode, true) - if err != nil { - fmt.Println("推送失败:", err) + // 启动 Chrome + d.AppStart("com.android.chrome", "", true) + + // 设置隐式等待 10 秒 + d.ImplicitlyWait(10) + + // 开启调试模式,查看 JSON-RPC 请求和响应 + d.SetDebug(true) + + // 通过文本查找元素并点击 + if err = d.ByText("在裝置上新增帳戶").Click(); err != nil { + fmt.Println("点击失败:", err) } else { - fmt.Printf("推送成功, 共写入 %d 字节\n", n) - } -} - -// connect 连接验证示例 -func connect() { - targetProduct := "23113RKC6C" - - devSerial, err := adb.FindSerialByProduct(addr, targetProduct) - if err != nil { - fmt.Println("查找设备失败:", err) - return - } - fmt.Println("找到设备:", devSerial) - - conn, err := adb.DialADB(addr, 15*time.Second) - if err != nil { - fmt.Println("连接失败:", err) - return - } - defer conn.Close() - adb.TransportTo(conn, devSerial) - - out, err := adb.ExecShell(conn, "getprop") - if err != nil { - fmt.Println("Shell 执行失败:", err) - } else { - fmt.Printf("Shell 输出: %q\n", string(out)) + fmt.Println("点击成功") } + + select {} // 阻塞等待 } diff --git a/libs/device.go b/libs/device.go new file mode 100644 index 0000000..64e69e4 --- /dev/null +++ b/libs/device.go @@ -0,0 +1,983 @@ +package libs + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "strings" + "sync" + "time" + + "github.com/zhuy1228/go-mobile-uiautomator/adb" + "github.com/zhuy1228/go-mobile-uiautomator/services" +) + +// ---------- Device:核心设备客户端 ---------- + +// Device 是 UIAutomator2 的核心客户端 +// 封装了设备连接、UIAutomator2 服务管理、JSON-RPC 调用等功能 +// 对应 Python 版本的 Device 类 +type Device struct { + // ADB 连接信息 + addr string // ADB 服务器地址 + serial string // 设备序列号 + + // UIAutomator2 服务配置 + serverPort int // 设备端服务端口(默认 9008) + debug bool // 调试模式 + jarPath string // 本地 u2.jar 路径(空字符串使用默认路径) + + // 设备连接接口(通过 adb forward 连接) + dev AdbDevice + + // JSON-RPC 调用器 + jsonrpc *JsonRpcWrapper + + // 设置 + settings *Settings + + // UIAutomator2 进程管理 + mu sync.Mutex + processConn net.Conn // 启动 UIAutomator 时的连接 + + // 窗口尺寸缓存 + windowSizeCache [2]int +} + +// NewDevice 创建一个新的 Device 客户端并启动 UIAutomator2 服务 +// 使用 adb forward 端口转发,与 Python uiautomator2 相同的方案 +// +// serial: 设备序列号(如 "emulator-5554") +// addr: 可选,ADB 服务器地址,不传则使用默认值 "127.0.0.1:5037" +// +// 创建后会自动执行: +// 1. 设置 adb forward 端口转发 +// 2. 推送 u2.jar 到设备(如果尚未存在) +// 3. 启动 UIAutomator2 服务 +// 4. 等待服务就绪 +func NewDevice(serial string, addr ...string) (*Device, error) { + a := DefaultADBAddr + if len(addr) > 0 && addr[0] != "" { + a = addr[0] + } + + // 使用 ADB 隧道设备(与 Python uiautomator2 完全一致,无需 adb forward) + dev := &AdbTunnelDevice{AdbAddr: a, Serial: serial} + d := &Device{ + addr: a, + serial: serial, + serverPort: DeviceServerPort, + dev: dev, + settings: NewSettings(), + } + + // 创建 JSON-RPC 调用器 + d.jsonrpc = NewJsonRpcWrapper(func(method string, params interface{}, timeout float64) (json.RawMessage, error) { + return d.jsonrpcCall(method, params, timeout) + }) + + // 推送 u2.jar 到设备(仅在文件不存在时推送) + if err := services.InstallServiceJar(a, serial, d.jarPath, false); err != nil { + return nil, fmt.Errorf("安装 u2.jar 失败: %w", err) + } + + // 启动 UIAutomator2 服务 + if err := d.StartUiautomator(); err != nil { + return nil, err + } + + return d, nil +} + +// NewDeviceWithoutStart 创建 Device 但不自动启动 UIAutomator2 服务 +// 适用于服务已经在设备上运行的场景 +// +// serial: 设备序列号 +// addr: 可选,ADB 服务器地址,不传则使用默认值 "127.0.0.1:5037" +func NewDeviceWithoutStart(serial string, addr ...string) *Device { + a := DefaultADBAddr + if len(addr) > 0 && addr[0] != "" { + a = addr[0] + } + + dev := &AdbTunnelDevice{AdbAddr: a, Serial: serial} + d := &Device{ + addr: a, + serial: serial, + serverPort: DeviceServerPort, + dev: dev, + settings: NewSettings(), + } + d.jsonrpc = NewJsonRpcWrapper(func(method string, params interface{}, timeout float64) (json.RawMessage, error) { + return d.jsonrpcCall(method, params, timeout) + }) + return d +} + +// ---------- 设备属性 ---------- + +// Serial 返回设备序列号 +func (d *Device) Serial() string { + return d.serial +} + +// Settings 返回设备的配置管理器 +func (d *Device) Settings() *Settings { + return d.settings +} + +// SetDebug 设置调试模式 +func (d *Device) SetDebug(debug bool) { + d.debug = debug +} + +// Debug 返回是否为调试模式 +func (d *Device) Debug() bool { + return d.debug +} + +// ---------- UIAutomator2 服务管理 ---------- + +// StartUiautomator 启动 UIAutomator2 服务 +// 如果服务已经在运行(/ping 响应 pong),则不会重复启动 +func (d *Device) StartUiautomator() error { + d.mu.Lock() + defer d.mu.Unlock() + + // 检查服务是否已经在运行 + if d.checkAlive() { + return nil + } + + // 启动 UIAutomator2 进程 + return d.launchAndWait() +} + +// StopUiautomator 停止 UIAutomator2 服务 +func (d *Device) StopUiautomator() { + d.mu.Lock() + if d.processConn != nil { + d.processConn.Close() + d.processConn = nil + } + d.mu.Unlock() + + // 等待服务退出 + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if !d.checkAlive() { + return + } + time.Sleep(500 * time.Millisecond) + } +} + +// Close 关闭设备连接,停止 UIAutomator2 服务 +// ADB 隧道方案无需额外的端口清理 +func (d *Device) Close() { + d.StopUiautomator() +} + +// ResetUiautomator 重启 UIAutomator2 服务 +func (d *Device) ResetUiautomator() error { + d.StopUiautomator() + return d.StartUiautomator() +} + +// checkAlive 通过 /ping 端点检查 UIAutomator2 服务是否存活 +func (d *Device) checkAlive() bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := HttpRequest(ctx, d.dev, d.serverPort, "GET", "/ping", nil, 5.0, false) + if err != nil { + return false + } + return string(resp.Content) == "pong" +} + +// SetJarPath 设置本地 u2.jar 路径 +// 传空字符串则使用默认路径 (assets/u2.jar) +func (d *Device) SetJarPath(path string) { + d.jarPath = path +} + +// launchAndWait 启动 UIAutomator2 进程并等待就绪 +func (d *Device) launchAndWait() error { + // 通过 ADB shell 启动 UIAutomator2 + conn, err := adb.ConnectToDevice(d.addr, d.serial, 15*time.Second) + if err != nil { + return &LaunchUiAutomationError{Message: "连接 ADB 失败", Output: err.Error()} + } + + // 启动 UIAutomator2 服务进程 + cmd := "shell:CLASSPATH=/data/local/tmp/u2.jar app_process / com.wetest.uia2.Main" + if err := adb.WriteAdbCmd(conn, cmd); err != nil { + conn.Close() + return &LaunchUiAutomationError{Message: "发送启动命令失败", Output: err.Error()} + } + status, err := adb.ReadStatus(conn) + if err != nil { + conn.Close() + return &LaunchUiAutomationError{Message: "读取启动状态失败", Output: err.Error()} + } + if status != "OKAY" { + conn.Close() + return &LaunchUiAutomationError{Message: fmt.Sprintf("启动状态异常: %s", status)} + } + + d.processConn = conn + + // 启动后台 goroutine 读取输出 + go func() { + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + if n > 0 { + output := string(buf[:n]) + if d.debug { + log.Printf("[UIAutomator2] %s", output) + } + // 检查是否有 "already registered" 错误 + if strings.Contains(output, "already registered") { + log.Printf("[UIAutomator2] 辅助功能服务已注册,需要重启") + } + } + if err != nil { + break + } + } + }() + + // 等待服务就绪 + return d.waitReady(30 * time.Second) +} + +// waitReady 等待 UIAutomator2 服务就绪 +func (d *Device) waitReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if d.checkAlive() { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return &LaunchUiAutomationError{Message: "服务启动超时"} +} + +// ---------- JSON-RPC 调用 ---------- + +// jsonrpcCall 发送 JSON-RPC 调用,失败时自动重启 UIAutomator2 并重试 +func (d *Device) jsonrpcCall(method string, params interface{}, timeout float64) (json.RawMessage, error) { + ctx := context.Background() + + result, err := JsonRpcCall(ctx, d.dev, d.serverPort, method, params, timeout, d.debug) + if err != nil { + // 如果是连接错误或 UIAutomation 断开,尝试重启 + var uaErr *UiAutomationNotConnectedError + var httpErr *HTTPError + if errors.As(err, &uaErr) || errors.As(err, &httpErr) { + log.Printf("UIAutomator2 服务异常,正在重启: %v", err) + d.StopUiautomator() + if startErr := d.StartUiautomator(); startErr != nil { + return nil, startErr + } + // 重试一次 + return JsonRpcCall(ctx, d.dev, d.serverPort, method, params, timeout, d.debug) + } + return nil, err + } + return result, nil +} + +// JsonRpc 返回 JSON-RPC 调用包装器 +func (d *Device) JsonRpc() *JsonRpcWrapper { + return d.jsonrpc +} + +// ---------- Shell 命令 ---------- + +// ShellResponse Shell 命令的返回结果 +type ShellResponse struct { + Output string // 命令输出 + ExitCode int // 退出码(暂不支持精确返回) +} + +// Shell 在设备上执行 Shell 命令 +func (d *Device) Shell(cmdArgs ...string) (*ShellResponse, error) { + cmd := strings.Join(cmdArgs, " ") + + conn, err := adb.ConnectToDevice(d.addr, d.serial, 10*time.Second) + if err != nil { + return nil, &AdbShellError{Message: fmt.Sprintf("连接失败: %v", err)} + } + defer conn.Close() + + out, err := adb.ExecShell(conn, cmd) + if err != nil { + return nil, &AdbShellError{Message: fmt.Sprintf("执行失败: %v", err)} + } + + return &ShellResponse{ + Output: string(out), + ExitCode: 0, + }, nil +} + +// ---------- 设备信息 ---------- + +// Info 获取设备的 UI 信息(通过 JSON-RPC deviceInfo) +func (d *Device) Info() (map[string]interface{}, error) { + raw, err := d.jsonrpc.Call("deviceInfo", nil, 10) + if err != nil { + return nil, err + } + var info map[string]interface{} + if err := json.Unmarshal(raw, &info); err != nil { + return nil, err + } + return info, nil +} + +// DeviceInfo 获取设备的硬件信息(通过 getprop) +func (d *Device) DeviceInfo() (map[string]interface{}, error) { + info := make(map[string]interface{}) + + props := []struct { + key string + prop string + }{ + {"serial", "ro.serialno"}, + {"sdk", "ro.build.version.sdk"}, + {"brand", "ro.product.brand"}, + {"model", "ro.product.model"}, + {"arch", "ro.product.cpu.abi"}, + {"version", "ro.build.version.release"}, + } + + for _, p := range props { + resp, err := d.Shell("getprop", p.prop) + if err == nil { + info[p.key] = strings.TrimSpace(resp.Output) + } + } + + return info, nil +} + +// WindowSize 获取设备屏幕尺寸(宽, 高) +func (d *Device) WindowSize() (int, int, error) { + if d.windowSizeCache[0] > 0 { + return d.windowSizeCache[0], d.windowSizeCache[1], nil + } + + info, err := d.Info() + if err != nil { + return 0, 0, err + } + + w := int(info["displayWidth"].(float64)) + h := int(info["displayHeight"].(float64)) + d.windowSizeCache = [2]int{w, h} + return w, h, nil +} + +// ---------- 基础操作 ---------- + +// Click 点击屏幕坐标 +func (d *Device) Click(x, y int) error { + d.operationDelay("click") + _, err := d.jsonrpc.Call("click", []interface{}{x, y}) + d.operationDelayAfter("click") + return err +} + +// DoubleClick 双击屏幕坐标 +func (d *Device) DoubleClick(x, y int, duration float64) error { + if duration <= 0 { + duration = 0.1 + } + // 第一次按下抬起 + _, err := d.jsonrpc.Call("injectInputEvent", []interface{}{ActionDown, x, y, 0}) + if err != nil { + return err + } + _, err = d.jsonrpc.Call("injectInputEvent", []interface{}{ActionUp, x, y, 0}) + if err != nil { + return err + } + time.Sleep(time.Duration(duration * float64(time.Second))) + // 第二次点击 + return d.Click(x, y) +} + +// LongClick 长按屏幕坐标 +// duration 为按住时间(秒),默认 0.5 秒 +func (d *Device) LongClick(x, y int, duration float64) error { + if duration <= 0 { + duration = 0.5 + } + d.operationDelay("click") + _, err := d.jsonrpc.Call("click", []interface{}{x, y, int(duration * 1000)}) + d.operationDelayAfter("click") + return err +} + +// Swipe 从 (fx,fy) 滑动到 (tx,ty) +// steps: 滑动步数,每步约 5ms +func (d *Device) Swipe(fx, fy, tx, ty, steps int) error { + if steps < 2 { + steps = 2 + } + d.operationDelay("swipe") + _, err := d.jsonrpc.Call("swipe", []interface{}{fx, fy, tx, ty, steps}) + d.operationDelayAfter("swipe") + return err +} + +// SwipeWithDuration 按持续时间滑动 +func (d *Device) SwipeWithDuration(fx, fy, tx, ty int, duration float64) error { + steps := int(duration * 200) + if steps < 2 { + steps = ScrollSteps + } + return d.Swipe(fx, fy, tx, ty, steps) +} + +// SwipePoints 多点连续滑动 +// points 为坐标点列表 [[x1,y1], [x2,y2], ...] +// duration 为总持续时间(秒) +func (d *Device) SwipePoints(points [][2]int, duration float64) error { + ppoints := make([]interface{}, 0, len(points)*2) + for _, p := range points { + ppoints = append(ppoints, p[0], p[1]) + } + steps := int(duration / 0.005) + _, err := d.jsonrpc.Call("swipePoints", []interface{}{ppoints, steps}) + return err +} + +// Drag 将坐标从 (sx,sy) 拖拽到 (ex,ey) +func (d *Device) Drag(sx, sy, ex, ey int, duration float64) error { + if duration <= 0 { + duration = 0.5 + } + d.operationDelay("drag") + _, err := d.jsonrpc.Call("drag", []interface{}{sx, sy, ex, ey, int(duration * 200)}) + d.operationDelayAfter("drag") + return err +} + +// Press 按键操作 +// key 可以是按键名称(如 "home", "back")或按键代码 +func (d *Device) Press(key string) error { + d.operationDelay("press") + _, err := d.jsonrpc.Call("pressKey", []interface{}{key}) + d.operationDelayAfter("press") + return err +} + +// PressKeyCode 按键代码操作 +func (d *Device) PressKeyCode(keyCode int, meta ...int) error { + d.operationDelay("press") + params := []interface{}{keyCode} + if len(meta) > 0 { + params = append(params, meta[0]) + } + _, err := d.jsonrpc.Call("pressKeyCode", params) + d.operationDelayAfter("press") + return err +} + +// LongPress 长按按键 +func (d *Device) LongPress(key string) error { + d.operationDelay("press") + _, err := d.Shell("input", "keyevent", "--longpress", strings.ToUpper(key)) + d.operationDelayAfter("press") + return err +} + +// ---------- 屏幕操作 ---------- + +// ScreenOn 唤醒屏幕 +func (d *Device) ScreenOn() error { + _, err := d.jsonrpc.Call("wakeUp", nil) + return err +} + +// ScreenOff 熄灭屏幕 +func (d *Device) ScreenOff() error { + _, err := d.jsonrpc.Call("sleep", nil) + return err +} + +// Screenshot 截取屏幕截图,返回 JPEG 图片的原始字节 +func (d *Device) Screenshot() ([]byte, error) { + raw, err := d.jsonrpc.Call("takeScreenshot", []interface{}{1, 80}) + if err != nil { + return nil, err + } + // 结果是 base64 编码的字符串 + var base64Data string + if err := json.Unmarshal(raw, &base64Data); err != nil { + return nil, fmt.Errorf("解析截图数据失败: %w", err) + } + if base64Data == "" { + return nil, fmt.Errorf("截图返回空数据") + } + + // Base64 解码(使用标准库) + decoded, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return nil, fmt.Errorf("Base64 解码失败: %w", err) + } + return decoded, nil +} + +// ---------- 层级转储 ---------- + +// DumpHierarchy 转储当前窗口的 UI 层级 XML +// maxDepth 为最大递归深度,0 使用默认值 +func (d *Device) DumpHierarchy(compressed bool, maxDepth int) (string, error) { + if maxDepth <= 0 { + maxDepth = d.settings.GetInt("max_depth") + if maxDepth <= 0 { + maxDepth = 50 + } + } + + raw, err := d.jsonrpc.Call("dumpWindowHierarchy", []interface{}{compressed, maxDepth}) + if err != nil { + return "", err + } + var content string + if err := json.Unmarshal(raw, &content); err != nil { + return "", err + } + if content == "" { + return "", &HierarchyEmptyError{Message: "层级转储为空"} + } + if strings.Contains(content, ``) { + return "", &HierarchyEmptyError{Message: "层级转储为空(无子节点)"} + } + return content, nil +} + +// ---------- 方向和旋转 ---------- + +// Orientation 获取当前屏幕方向 +func (d *Device) Orientation() (string, error) { + info, err := d.Info() + if err != nil { + return "", err + } + rotation := int(info["displayRotation"].(float64)) + for _, o := range Orientations { + if o.Value == rotation { + return o.Name, nil + } + } + return "natural", nil +} + +// SetOrientation 设置屏幕方向 +// value 可以是 "natural"/"n"、"left"/"l"、"right"/"r"、"upsidedown"/"u" +func (d *Device) SetOrientation(value string) error { + for _, o := range Orientations { + if value == o.Name || value == o.Short || value == fmt.Sprintf("%d", o.Value) { + _, err := d.jsonrpc.Call("setOrientation", []interface{}{o.Name}) + return err + } + } + return fmt.Errorf("无效的方向值: %s", value) +} + +// FreezeRotation 冻结/解冻屏幕旋转 +func (d *Device) FreezeRotation(freeze bool) error { + _, err := d.jsonrpc.Call("freezeRotation", []interface{}{freeze}) + return err +} + +// ---------- 通知和快捷设置 ---------- + +// OpenNotification 打开通知栏 +func (d *Device) OpenNotification() error { + _, err := d.jsonrpc.Call("openNotification", nil) + return err +} + +// OpenQuickSettings 打开快捷设置 +func (d *Device) OpenQuickSettings() error { + _, err := d.jsonrpc.Call("openQuickSettings", nil) + return err +} + +// OpenURL 通过浏览器打开 URL +func (d *Device) OpenURL(url string) error { + _, err := d.Shell("am", "start", "-a", "android.intent.action.VIEW", "-d", url) + return err +} + +// ---------- 剪贴板 ---------- + +// GetClipboard 获取剪贴板内容 +func (d *Device) GetClipboard() (string, error) { + raw, err := d.jsonrpc.Call("getClipboard", nil) + if err != nil { + return "", err + } + var text string + json.Unmarshal(raw, &text) + return text, nil +} + +// SetClipboard 设置剪贴板内容 +func (d *Device) SetClipboard(text string, label ...string) error { + l := "" + if len(label) > 0 { + l = label[0] + } + _, err := d.jsonrpc.Call("setClipboard", []interface{}{l, text}) + return err +} + +// ---------- Toast ---------- + +// GetLastToast 获取最后一个 Toast 消息 +func (d *Device) GetLastToast() (string, error) { + raw, err := d.jsonrpc.Call("getLastToast", nil) + if err != nil { + return "", err + } + var text string + json.Unmarshal(raw, &text) + return text, nil +} + +// ClearToast 清除 Toast 消息 +func (d *Device) ClearToast() error { + _, err := d.jsonrpc.Call("clearLastToast", nil) + return err +} + +// MakeToast 在设备上显示 Toast 消息 +func (d *Device) MakeToast(text string, durationMs float64) error { + _, err := d.jsonrpc.Call("makeToast", []interface{}{text, durationMs * 1000}) + return err +} + +// ---------- 等待超时 ---------- + +// ImplicitlyWait 设置默认等待超时 +func (d *Device) ImplicitlyWait(seconds float64) error { + return d.settings.Set("wait_timeout", seconds) +} + +// WaitTimeout 获取当前的等待超时时间 +func (d *Device) WaitTimeout() float64 { + return d.settings.GetFloat64("wait_timeout") +} + +// ---------- UiObject 选择器入口 ---------- + +// FindElement 根据选择器参数查找 UI 元素,返回 UiObject +func (d *Device) FindElement(params map[string]interface{}) (*UiObject, error) { + sel, err := New(params) + if err != nil { + return nil, err + } + return NewUiObject(d, sel), nil +} + +// By 通过任意选择器参数查找 UI 元素(便捷方法) +// +// 用法类似 Python 的 d(text="xxx", className="yyy") +// +// d.By(libs.P{"text": "登录"}).Click() +// d.By(libs.P{"resourceId": "com.example:id/btn", "clickable": true}).Click() +func (d *Device) By(params map[string]interface{}) *UiObject { + sel := MustNew(params) + return NewUiObject(d, sel) +} + +// ByText 通过文本查找 UI 元素 +// +// d.ByText("向设备添加账号").Click() +func (d *Device) ByText(text string) *UiObject { + return d.By(map[string]interface{}{"text": text}) +} + +// ByTextContains 通过包含的文本查找 UI 元素 +// +// d.ByTextContains("添加").Click() +func (d *Device) ByTextContains(text string) *UiObject { + return d.By(map[string]interface{}{"textContains": text}) +} + +// ByResourceId 通过资源 ID 查找 UI 元素 +// +// d.ByResourceId("com.example:id/login_btn").Click() +func (d *Device) ByResourceId(id string) *UiObject { + return d.By(map[string]interface{}{"resourceId": id}) +} + +// ByDescription 通过 contentDescription 查找 UI 元素 +// +// d.ByDescription("返回").Click() +func (d *Device) ByDescription(desc string) *UiObject { + return d.By(map[string]interface{}{"description": desc}) +} + +// ByClassName 通过类名查找 UI 元素 +// +// d.ByClassName("android.widget.EditText").SetText("hello") +func (d *Device) ByClassName(className string) *UiObject { + return d.By(map[string]interface{}{"className": className}) +} + +// P 是 map[string]interface{} 的别名,用于简化选择器参数书写 +// +// d.By(libs.P{"text": "确定", "clickable": true}) +type P = map[string]interface{} + +// ---------- 应用管理 ---------- + +// AppStart 启动应用 +// packageName: 包名 +// activity: Activity 名称(可选) +// stop: 是否先停止应用 +func (d *Device) AppStart(packageName string, activity string, stop bool) error { + if stop { + d.AppStop(packageName) + } + + if activity == "" { + // 使用 monkey 命令启动 + _, err := d.Shell("monkey", "-p", packageName, "-c", + "android.intent.category.LAUNCHER", "1") + return err + } + + args := []string{ + "am", "start", + "-a", "android.intent.action.MAIN", + "-c", "android.intent.category.LAUNCHER", + "-n", fmt.Sprintf("%s/%s", packageName, activity), + } + _, err := d.Shell(args...) + return err +} + +// AppStop 停止应用 +func (d *Device) AppStop(packageName string) error { + _, err := d.Shell("am", "force-stop", packageName) + return err +} + +// AppClear 清除应用数据 +func (d *Device) AppClear(packageName string) error { + _, err := d.Shell("pm", "clear", packageName) + return err +} + +// AppUninstall 卸载应用 +func (d *Device) AppUninstall(packageName string) (bool, error) { + resp, err := d.Shell("pm", "uninstall", packageName) + if err != nil { + return false, err + } + return strings.Contains(resp.Output, "Success"), nil +} + +// AppCurrent 获取当前前台应用信息 +func (d *Device) AppCurrent() (map[string]string, error) { + resp, err := d.Shell("dumpsys", "activity", "activities") + if err != nil { + return nil, err + } + + result := make(map[string]string) + lines := strings.Split(resp.Output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // 解析 mResumedActivity 或 mFocusedActivity + if strings.Contains(line, "mResumedActivity") || strings.Contains(line, "mFocusedActivity") { + // 格式: mResumedActivity: ActivityRecord{... pkg/activity ...} + parts := strings.Fields(line) + for _, p := range parts { + if strings.Contains(p, "/") && !strings.HasPrefix(p, "{") { + comp := strings.TrimSuffix(p, "}") + slash := strings.Index(comp, "/") + if slash > 0 { + result["package"] = comp[:slash] + result["activity"] = comp[slash+1:] + return result, nil + } + } + } + } + } + + return result, &DeviceError{Message: "无法获取前台应用信息"} +} + +// AppWait 等待应用启动 +// timeout: 超时时间(秒) +// front: 是否等待到前台 +// 返回应用的 PID,0 表示未启动 +func (d *Device) AppWait(packageName string, timeout float64, front bool) (int, error) { + if timeout <= 0 { + timeout = 20.0 + } + deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) + for time.Now().Before(deadline) { + if front { + current, err := d.AppCurrent() + if err == nil && current["package"] == packageName { + pid := d.pidOfApp(packageName) + if pid > 0 { + return pid, nil + } + } + } else { + pid := d.pidOfApp(packageName) + if pid > 0 { + return pid, nil + } + } + time.Sleep(1 * time.Second) + } + return 0, nil +} + +// pidOfApp 获取应用的进程 ID +func (d *Device) pidOfApp(packageName string) int { + resp, err := d.Shell("ps", "-A") + if err != nil { + return 0 + } + output := resp.Output + if len(strings.TrimSpace(output)) <= 1 { + resp, err = d.Shell("ps") + if err != nil { + return 0 + } + output = resp.Output + } + + lines := strings.Split(output, "\n") + for _, line := range lines { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) >= 9 && fields[len(fields)-1] == packageName { + pid := 0 + fmt.Sscanf(fields[1], "%d", &pid) + return pid + } + } + return 0 +} + +// ---------- 文件操作 ---------- + +// Push 推送文件到设备 +func (d *Device) Push(localPath, remotePath string) error { + _, err := adb.PushFile(d.addr, d.serial, localPath, remotePath, 0644, d.debug) + return err +} + +// ---------- 辅助方法 ---------- + +// operationDelayHelper 通用操作延迟辅助函数 +// isBefore 为 true 时取操作前延迟,否则取操作后延迟 +func (d *Device) operationDelayHelper(operation string, isBefore bool) { + methods := d.settings.GetStringSlice("operation_delay_methods") + for _, m := range methods { + if m == operation { + before, after := d.settings.GetOperationDelay() + delay := after + if isBefore { + delay = before + } + if delay > 0 { + time.Sleep(time.Duration(delay * float64(time.Second))) + } + return + } + } +} + +// operationDelay 操作前延迟 +func (d *Device) operationDelay(operation string) { + d.operationDelayHelper(operation, true) +} + +// operationDelayAfter 操作后延迟 +func (d *Device) operationDelayAfter(operation string) { + d.operationDelayHelper(operation, false) +} + +// ---------- 存在性检查 ---------- + +// Exists 检查匹配选择器参数的 UI 元素是否存在 +func (d *Device) Exists(params map[string]interface{}) (bool, error) { + obj, err := d.FindElement(params) + if err != nil { + return false, err + } + return obj.Exists() +} + +// ClearText 清除输入框文本 +func (d *Device) ClearText() error { + _, err := d.jsonrpc.Call("clearInputText", nil) + return err +} + +// Keyevent 发送按键事件 +func (d *Device) Keyevent(key string) error { + _, err := d.Shell("input", "keyevent", strings.ToUpper(key)) + return err +} + +// WlanIP 获取设备 WLAN IP 地址 +func (d *Device) WlanIP() (string, error) { + resp, err := d.Shell("ip", "addr", "show", "wlan0") + if err != nil { + return "", err + } + lines := strings.Split(resp.Output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "inet ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + ip := strings.Split(parts[1], "/")[0] + return ip, nil + } + } + } + return "", nil +} + +// Unlock 解锁屏幕(从左下滑到右上) +func (d *Device) Unlock() error { + info, err := d.Info() + if err != nil { + return err + } + screenOn, _ := info["screenOn"].(bool) + if !screenOn { + d.Keyevent("POWER") + w, h, err := d.WindowSize() + if err != nil { + return err + } + return d.Swipe(int(float64(w)*0.1), int(float64(h)*0.9), + int(float64(w)*0.9), int(float64(h)*0.1), ScrollSteps) + } + return nil +} diff --git a/libs/doc.go b/libs/doc.go index 815c0b4..9859742 100644 --- a/libs/doc.go +++ b/libs/doc.go @@ -1,16 +1,45 @@ -// Package libs 提供了与 UIAutomator2 服务交互的工具库。 +// Package libs 提供了完整的 Android UIAutomator2 自动化框架。 // -// 本包包含以下核心组件: -// - AdbHTTPConnection:通过 ADB 隧道发送 HTTP 请求到设备端 UIAutomator2 服务 -// - Selector:UI 元素选择器构造器,支持文本、类名、资源 ID 等多种查询条件 -// - HTTPResponse:HTTP 响应封装 +// 本包是 Python uiautomator2 的 Go 语言实现,通过 ADB 协议与运行在 Android 设备上的 +// UIAutomator2 HTTP 服务通信,提供设备控制、UI 操作、文本输入等功能。 +// +// 核心组件: +// - Device:设备客户端,管理 UIAutomator2 服务生命周期,提供所有设备操作 +// - UiObject:UI 控件对象,支持点击、输入、滑动、等待等操作 +// - Selector:UI 元素选择器,支持文本、类名、资源 ID 等多种查询条件 +// - JsonRpcWrapper:JSON-RPC 2.0 调用封装 +// - InputMethod:通过 AdbKeyboard 输入法实现快速文本输入 +// - SwipeExt:扩展滑动操作(按方向、比例滑动) +// - WatchContext/Watcher:弹窗/对话框自动监控和处理 +// - Session:应用会话管理,自动检测应用状态 +// - Settings:设备配置管理(等待超时、操作延迟等) +// +// 通信层: +// - AdbHTTPConnection:通过 ADB 隧道发送 HTTP 请求 +// - HttpRequest:高层 HTTP 请求封装 +// - JsonRpcCall:JSON-RPC 2.0 请求/响应处理 // // 使用示例: // -// // 创建 UI 选择器 -// selector := libs.MustNew(map[string]interface{}{ -// "text": "登录", -// "className": "android.widget.Button", -// }) -// jsonData, _ := selector.ToJSON() +// // 创建设备连接 +// device, err := libs.NewDevice("emulator-5554") +// // 自定义 ADB 地址: libs.NewDevice("emulator-5554", "192.168.1.100:5037") +// if err != nil { +// log.Fatal(err) +// } +// defer device.StopUiautomator() +// +// // 查找并点击按钮 +// btn, _ := device.FindElement(map[string]interface{}{"text": "登录"}) +// btn.Click() +// +// // 输入文本 +// input, _ := device.FindElement(map[string]interface{}{"resourceId": "com.example:id/username"}) +// input.SetText("admin") +// +// // 使用 Watcher 自动处理弹窗 +// watcher := libs.NewWatcher(device) +// watcher.WhenText("同意").Click() +// watcher.Start(2.0) +// defer watcher.Stop() package libs diff --git a/libs/errors.go b/libs/errors.go new file mode 100644 index 0000000..de543cd --- /dev/null +++ b/libs/errors.go @@ -0,0 +1,160 @@ +package libs + +import "fmt" + +// ---------- 基础错误类型 ---------- + +// DeviceError 设备层面的错误基类 +type DeviceError struct { + Message string +} + +func (e *DeviceError) Error() string { + return fmt.Sprintf("设备错误: %s", e.Message) +} + +// ConnectError 设备连接失败 +type ConnectError struct { + Message string +} + +func (e *ConnectError) Error() string { + return fmt.Sprintf("连接失败: %s", e.Message) +} + +// HTTPError HTTP 请求失败 +type HTTPError struct { + Message string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP 错误: %s", e.Message) +} + +// HTTPTimeoutError HTTP 请求超时 +type HTTPTimeoutError struct { + Message string +} + +func (e *HTTPTimeoutError) Error() string { + return fmt.Sprintf("HTTP 超时: %s", e.Message) +} + +// AdbShellError ADB Shell 执行失败 +type AdbShellError struct { + Message string +} + +func (e *AdbShellError) Error() string { + return fmt.Sprintf("ADB Shell 错误: %s", e.Message) +} + +// ---------- RPC 错误类型 ---------- + +// RPCError JSON-RPC 调用的错误基类 +type RPCError struct { + Code int + Message string + Data string // 堆栈信息 + Params interface{} +} + +func (e *RPCError) Error() string { + return fmt.Sprintf("RPC 错误 [%d]: %s", e.Code, e.Message) +} + +// RPCUnknownError 未知的 RPC 错误 +type RPCUnknownError struct { + RPCError +} + +// RPCInvalidError 无效的 RPC 响应 +type RPCInvalidError struct { + Message string +} + +func (e *RPCInvalidError) Error() string { + return fmt.Sprintf("RPC 无效响应: %s", e.Message) +} + +// RPCStackOverflowError Java 端栈溢出错误 +type RPCStackOverflowError struct { + RPCError +} + +// UiObjectNotFoundError UI 元素未找到 +type UiObjectNotFoundError struct { + Code int + Message string + Params interface{} +} + +func (e *UiObjectNotFoundError) Error() string { + return fmt.Sprintf("UiObject 未找到: %s (参数: %v)", e.Message, e.Params) +} + +// UiAutomationNotConnectedError UIAutomation 服务未连接 +type UiAutomationNotConnectedError struct { + Message string +} + +func (e *UiAutomationNotConnectedError) Error() string { + return fmt.Sprintf("UIAutomation 未连接: %s", e.Message) +} + +// HierarchyEmptyError dump_hierarchy 返回空结果 +type HierarchyEmptyError struct { + Message string +} + +func (e *HierarchyEmptyError) Error() string { + return fmt.Sprintf("层级为空: %s", e.Message) +} + +// ---------- 应用相关错误 ---------- + +// LaunchUiAutomationError UIAutomator2 服务启动失败 +type LaunchUiAutomationError struct { + Message string + Output string +} + +func (e *LaunchUiAutomationError) Error() string { + return fmt.Sprintf("UIAutomator 启动失败: %s\n输出: %s", e.Message, e.Output) +} + +// AccessibilityServiceAlreadyRegisteredError 辅助功能服务已注册 +type AccessibilityServiceAlreadyRegisteredError struct { + Output string +} + +func (e *AccessibilityServiceAlreadyRegisteredError) Error() string { + return fmt.Sprintf("辅助功能服务已注册: %s", e.Output) +} + +// SessionBrokenError 应用会话中断(应用已退出或崩溃) +type SessionBrokenError struct { + Message string +} + +func (e *SessionBrokenError) Error() string { + return fmt.Sprintf("会话中断: %s", e.Message) +} + +// AppNotFoundError 应用未安装 +type AppNotFoundError struct { + PackageName string +} + +func (e *AppNotFoundError) Error() string { + return fmt.Sprintf("应用未找到: %s", e.PackageName) +} + +// InputIMEError 输入法错误 +type InputIMEError struct { + Message string +} + +func (e *InputIMEError) Error() string { + return fmt.Sprintf("输入法错误: %s", e.Message) +} diff --git a/libs/input.go b/libs/input.go new file mode 100644 index 0000000..241429c --- /dev/null +++ b/libs/input.go @@ -0,0 +1,220 @@ +package libs + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "time" +) + +// ---------- 输入法功能 ---------- + +// 输入法相关常量 +const ( + // imeID AdbKeyboard 输入法的标识符 + imeID = "com.github.uiautomator/.AdbKeyboard" + + // broadcastResultOK 广播成功返回码 + broadcastResultOK = -1 +) + +// BroadcastResult 广播命令的返回结果 +type BroadcastResult struct { + Code int // 结果码,-1 表示成功 + Data string // 返回数据 +} + +// InputMethod 提供输入法相关操作 +// 通过 AdbKeyboard 输入法实现快速文本输入 +type InputMethod struct { + device *Device +} + +// NewInputMethod 创建输入法操作实例 +func NewInputMethod(device *Device) *InputMethod { + return &InputMethod{device: device} +} + +// CurrentIME 获取当前活动的输入法 +func (im *InputMethod) CurrentIME() (string, error) { + resp, err := im.device.Shell("settings", "get", "secure", "default_input_method") + if err != nil { + return "", err + } + return strings.TrimSpace(resp.Output), nil +} + +// SetInputIME 启用或禁用 AdbKeyboard 输入法 +func (im *InputMethod) SetInputIME(enable bool) error { + if !enable { + _, err := im.device.Shell("ime", "disable", imeID) + return err + } + + // 检查是否已经设置为当前输入法 + current, err := im.CurrentIME() + if err == nil && current == imeID { + return nil + } + + // 检查是否已安装 + if !im.IsInstalled() { + return &InputIMEError{Message: "AdbKeyboard 输入法未安装,请先安装 app-uiautomator.apk"} + } + + // 启用并设置为默认输入法 + im.device.Shell("ime", "enable", imeID) + im.device.Shell("ime", "set", imeID) + im.device.Shell("settings", "put", "secure", "default_input_method", imeID) + + // 等待输入法就绪 + return im.waitReady() +} + +// IsInstalled 检查 AdbKeyboard 输入法是否已安装 +func (im *InputMethod) IsInstalled() bool { + list, _ := im.getIMEList() + for _, id := range list { + if id == imeID { + return true + } + } + return false +} + +// getIMEList 获取设备上所有输入法列表 +func (im *InputMethod) getIMEList() ([]string, error) { + resp, err := im.device.Shell("ime", "list", "-s") + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(resp.Output), "\n") + var result []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result, nil +} + +// waitReady 等待输入法就绪 +func (im *InputMethod) waitReady() error { + for i := 0; i < 10; i++ { + current, err := im.CurrentIME() + if err == nil && current == imeID { + return nil + } + time.Sleep(300 * time.Millisecond) + } + return &InputIMEError{Message: "等待输入法就绪超时"} +} + +// broadcast 发送广播命令 +func (im *InputMethod) broadcast(action string, extras map[string]string) (*BroadcastResult, error) { + args := []string{"am", "broadcast", "-a", action} + for k, v := range extras { + args = append(args, "--es", k, v) + } + + resp, err := im.device.Shell(args...) + if err != nil { + return nil, err + } + + // 解析返回结果 + // 格式: result=-1 data="success" + result := &BroadcastResult{Code: 0} + + reResult := regexp.MustCompile(`result=(-?\d+)`) + reData := regexp.MustCompile(`data="([^"]+)"`) + + if m := reResult.FindStringSubmatch(resp.Output); len(m) > 1 { + fmt.Sscanf(m[1], "%d", &result.Code) + } + if m := reData.FindStringSubmatch(resp.Output); len(m) > 1 { + result.Data = m[1] + } + + return result, nil +} + +// mustBroadcast 发送广播并确保成功 +func (im *InputMethod) mustBroadcast(action string, extras map[string]string) error { + var lastErr error + for i := 0; i < 3; i++ { + result, err := im.broadcast(action, extras) + if err != nil { + lastErr = err + time.Sleep(time.Duration(1000+i*500) * time.Millisecond) + continue + } + if result.Code == broadcastResultOK { + return nil + } + lastErr = fmt.Errorf("广播 %s 失败: code=%d data=%s", action, result.Code, result.Data) + time.Sleep(time.Duration(1000+i*500) * time.Millisecond) + } + return lastErr +} + +// SendKeys 通过 AdbKeyboard 输入法输入文本 +// 自动启用输入法并在输入完成后隐藏键盘 +func (im *InputMethod) SendKeys(text string) error { + if err := im.SetInputIME(true); err != nil { + return err + } + + // Base64 编码文本 + encoded := base64.StdEncoding.EncodeToString([]byte(text)) + + // 发送文本输入广播 + if err := im.mustBroadcast("ADB_KEYBOARD_INPUT_TEXT", map[string]string{ + "text": encoded, + }); err != nil { + return err + } + + // 隐藏键盘 + im.mustBroadcast("ADB_KEYBOARD_HIDE", nil) + return nil +} + +// SendAction 模拟输入法编辑器动作 +// code 为动作代码: +// +// "go"/"search"/"send"/"next"/"done"/"previous" 或数字 +func (im *InputMethod) SendAction(code string) error { + if err := im.SetInputIME(true); err != nil { + return err + } + + // 将名称转换为代码 + actionCodes := map[string]string{ + "go": "2", + "search": "3", + "send": "4", + "next": "5", + "done": "6", + "previous": "7", + } + + codeStr := code + if mapped, ok := actionCodes[strings.ToLower(code)]; ok { + codeStr = mapped + } + + return im.mustBroadcast("ADB_KEYBOARD_EDITOR_CODE", map[string]string{ + "code": codeStr, + }) +} + +// ClearText 通过输入法清除文本 +func (im *InputMethod) ClearText() error { + if err := im.SetInputIME(true); err != nil { + return err + } + return im.mustBroadcast("ADB_KEYBOARD_CLEAR_TEXT", nil) +} diff --git a/libs/jsonrpc.go b/libs/jsonrpc.go new file mode 100644 index 0000000..b6feeac --- /dev/null +++ b/libs/jsonrpc.go @@ -0,0 +1,175 @@ +package libs + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" +) + +// ---------- JSON-RPC 请求/响应结构 ---------- + +// JsonRpcRequest JSON-RPC 2.0 请求 +type JsonRpcRequest struct { + JsonRpc string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params"` +} + +// JsonRpcResponse JSON-RPC 2.0 响应 +type JsonRpcResponse struct { + JsonRpc string `json:"jsonrpc"` + ID int `json:"id"` + Result *json.RawMessage `json:"result,omitempty"` + Error *JsonRpcError `json:"error,omitempty"` +} + +// JsonRpcError JSON-RPC 错误对象 +type JsonRpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data,omitempty"` // Java 堆栈信息 +} + +// ---------- JSON-RPC 调用函数 ---------- + +// JsonRpcCall 向 UIAutomator2 服务发送 JSON-RPC 调用 +// dev 实现 AdbDevice 接口,devicePort 为设备端服务端口 +// method 为 RPC 方法名,params 为参数 +// timeout 为请求超时(秒),debug 为 true 时输出调试信息 +// +// 返回值为 JSON 原始字节(由调用者根据需要解析) +// +// 可能返回的错误类型: +// - *UiObjectNotFoundError: UI 元素未找到 +// - *UiAutomationNotConnectedError: UIAutomation 服务断开 +// - *RPCStackOverflowError: Java 端栈溢出 +// - *RPCUnknownError: 未知 RPC 错误 +// - *RPCInvalidError: 无效的 RPC 响应 +func JsonRpcCall(ctx context.Context, dev AdbDevice, devicePort int, method string, params interface{}, timeout float64, debug bool) (json.RawMessage, error) { + // 构造 JSON-RPC 请求体 + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + + // 通过 HTTP 发送到 /jsonrpc/0 端点 + resp, err := HttpRequest(ctx, dev, devicePort, "POST", "/jsonrpc/0", payload, timeout, debug) + if err != nil { + return nil, err + } + + // 解析响应 + var rpcResp JsonRpcResponse + if err := json.Unmarshal(resp.Content, &rpcResp); err != nil { + return nil, &RPCInvalidError{Message: fmt.Sprintf("JSON 解析失败: %v", err)} + } + + // 处理 RPC 错误 + if rpcResp.Error != nil { + return nil, handleRpcError(rpcResp.Error, resp.Text(), params) + } + + // 确保有结果字段 + if rpcResp.Result == nil { + return nil, &RPCInvalidError{Message: "响应中缺少 result 字段"} + } + + return *rpcResp.Result, nil +} + +// handleRpcError 根据 JSON-RPC 错误内容映射到具体的 Go 错误类型 +func handleRpcError(rpcErr *JsonRpcError, rawText string, params interface{}) error { + code := rpcErr.Code + message := rpcErr.Message + data := rpcErr.Data + + if debug := false; debug { + log.Printf("JSON-RPC 错误: code=%d message=%s", code, message) + } + + // UIAutomation 未连接 + if strings.Contains(rawText, "UiAutomation not connected") { + return &UiAutomationNotConnectedError{Message: "UiAutomation not connected"} + } + if strings.Contains(message, "android.os.DeadObjectException") { + return &UiAutomationNotConnectedError{Message: "android.os.DeadObjectException"} + } + if strings.Contains(message, "android.os.DeadSystemRuntimeException") { + return &UiAutomationNotConnectedError{Message: "android.os.DeadSystemRuntimeException"} + } + + // UI 元素未找到 + if strings.Contains(message, "uiautomator.UiObjectNotFoundException") { + return &UiObjectNotFoundError{ + Code: code, + Message: message, + Params: params, + } + } + + // 栈溢出 + if strings.Contains(message, "java.lang.StackOverflowError") { + truncated := data + if len(data) > 2000 { + truncated = data[:1000] + "..." + data[len(data)-1000:] + } + return &RPCStackOverflowError{ + RPCError: RPCError{ + Code: code, + Message: fmt.Sprintf("StackOverflowError: %s", message), + Data: truncated, + Params: params, + }, + } + } + + // 未知 RPC 错误 + return &RPCUnknownError{ + RPCError: RPCError{ + Code: code, + Message: fmt.Sprintf("未知 RPC 错误: %d %s", code, message), + Data: data, + Params: params, + }, + } +} + +// ---------- JSON-RPC 动态调用包装器 ---------- + +// JsonRpcWrapper 提供动态方法名的 JSON-RPC 调用 +// 通过记录方法名并在 Call 时发送请求,实现类似 Python 的动态属性访问 +type JsonRpcWrapper struct { + // caller 为实际发送 JSON-RPC 请求的函数 + caller func(method string, params interface{}, timeout float64) (json.RawMessage, error) +} + +// NewJsonRpcWrapper 创建 JSON-RPC 动态调用包装器 +func NewJsonRpcWrapper(caller func(method string, params interface{}, timeout float64) (json.RawMessage, error)) *JsonRpcWrapper { + return &JsonRpcWrapper{caller: caller} +} + +// Call 发送 JSON-RPC 调用 +// method 为 RPC 方法名(如 "click"、"setText") +// params 为方法参数,通常为 []interface{} 或 map[string]interface{} +// timeout 为超时时间(秒),0 使用默认值 +func (w *JsonRpcWrapper) Call(method string, params interface{}, timeout ...float64) (json.RawMessage, error) { + t := HTTPTimeout + if len(timeout) > 0 && timeout[0] > 0 { + t = timeout[0] + } + return w.caller(method, params, t) +} + +// CallResult 发送 JSON-RPC 调用并将结果解析到指定结构体 +func (w *JsonRpcWrapper) CallResult(result interface{}, method string, params interface{}, timeout ...float64) error { + raw, err := w.Call(method, params, timeout...) + if err != nil { + return err + } + return json.Unmarshal(raw, result) +} diff --git a/libs/proto.go b/libs/proto.go new file mode 100644 index 0000000..94330b7 --- /dev/null +++ b/libs/proto.go @@ -0,0 +1,65 @@ +package libs + +// ---------- 协议常量 ---------- + +// ScrollSteps 默认滚动步数 +// 每步约 5ms,55 步约 275ms +const ScrollSteps = 55 + +// HTTPTimeout 默认 HTTP 请求超时时间(秒) +const HTTPTimeout = 300.0 + +// DeviceServerPort UIAutomator2 服务默认端口 +const DeviceServerPort = 9008 + +// DefaultADBAddr 默认 ADB 服务器地址 +const DefaultADBAddr = "127.0.0.1:5037" + +// ---------- 方向枚举 ---------- + +// Direction 表示滑动/滚动方向 +type Direction string + +const ( + // DirectionLeft 向左 + DirectionLeft Direction = "left" + // DirectionRight 向右 + DirectionRight Direction = "right" + // DirectionUp 向上 + DirectionUp Direction = "up" + // DirectionDown 向下 + DirectionDown Direction = "down" + // DirectionForward 向前(等同于向下) + DirectionForward Direction = "forward" + // DirectionBackward 向后(等同于向上) + DirectionBackward Direction = "backward" +) + +// ---------- 触摸事件常量 ---------- + +const ( + // ActionDown 手指按下事件 + ActionDown = 0 + // ActionUp 手指抬起事件 + ActionUp = 1 + // ActionMove 手指移动事件 + ActionMove = 2 +) + +// ---------- 设备方向映射 ---------- + +// OrientationInfo 设备方向信息 +type OrientationInfo struct { + Value int // displayRotation 值 + Name string // 方向名称 + Short string // 简写 + Rotation int // 旋转角度 +} + +// Orientations 所有设备方向定义 +var Orientations = []OrientationInfo{ + {Value: 0, Name: "natural", Short: "n", Rotation: 0}, + {Value: 1, Name: "left", Short: "l", Rotation: 90}, + {Value: 2, Name: "upsidedown", Short: "u", Rotation: 180}, + {Value: 3, Name: "right", Short: "r", Rotation: 270}, +} diff --git a/libs/request.go b/libs/request.go index 7e38ba7..d7e8ce3 100644 --- a/libs/request.go +++ b/libs/request.go @@ -1,7 +1,6 @@ package libs import ( - "bufio" "bytes" "context" "encoding/json" @@ -12,17 +11,32 @@ import ( "net/http" "strings" "time" + + "github.com/zhuy1228/go-mobile-uiautomator/adb" ) // ---------- 外部接口和类型定义 ---------- -// AdbDevice 定义了通过 ADB 隧道创建设备连接的接口 +// AdbDevice 定义了设备连接接口 type AdbDevice interface { // CreateConnection 建立到设备的 TCP 连接 // network 通常为 "tcp",port 为设备上服务监听端口 CreateConnection(network string, port int) (net.Conn, error) } +// AdbTunnelDevice 通过 ADB 隧道直连设备(与 Python uiautomator2 完全一致) +// 每次 CreateConnection 会建立一条新的 ADB 隧道,无需 adb forward,无需端口管理 +// 等同于 Python 中 AdbHTTPConnection 继承 HTTPConnection 并重写 connect() 的方案 +type AdbTunnelDevice struct { + AdbAddr string // ADB 服务器地址 + Serial string // 设备序列号 +} + +// CreateConnection 建立到设备指定端口的 ADB 隧道连接 +func (d *AdbTunnelDevice) CreateConnection(network string, port int) (net.Conn, error) { + return adb.CreateTunnel(d.AdbAddr, d.Serial, port) +} + // HTTPResponse 封装 HTTP 响应数据 type HTTPResponse struct { Content []byte // 响应体内容 @@ -49,126 +63,25 @@ var ( ErrHTTPFailed = errors.New("HTTP 请求失败") ) -// ---------- AdbHTTPConnection:通过 ADB 隧道发送 HTTP 请求 ---------- - -// AdbHTTPConnection 基于 net.Conn 实现的 HTTP 连接 -// 通过 ADB 端口转发直接与设备端 UIAutomator2 服务通信 -type AdbHTTPConnection struct { - Conn net.Conn -} - -// NewAdbHTTPConnection 创建一个新的 ADB HTTP 连接 -// dev 为设备接口,port 为设备端服务端口,timeout 为连接超时 -func NewAdbHTTPConnection(dev AdbDevice, port int, timeout time.Duration) (*AdbHTTPConnection, error) { - conn, err := dev.CreateConnection("tcp", port) - if err != nil { - return nil, fmt.Errorf("无法连接到 UIAutomator2 服务: %w", err) - } - _ = conn.SetDeadline(time.Now().Add(timeout)) - return &AdbHTTPConnection{Conn: conn}, nil -} - -// Close 关闭底层连接 -func (c *AdbHTTPConnection) Close() error { - if c.Conn != nil { - return c.Conn.Close() - } - return nil -} - -// sendRequest 将 HTTP 请求写入连接并读取响应 -// 通过原始 TCP 连接发送 HTTP 报文,避免依赖标准 http.Client -func (c *AdbHTTPConnection) sendRequest(req *http.Request, timeout time.Duration) (*http.Response, error) { - // 设置读写截止时间 - if timeout > 0 { - _ = c.Conn.SetDeadline(time.Now().Add(timeout)) - } else { - _ = c.Conn.SetDeadline(time.Time{}) - } - - // 序列化 HTTP 请求为原始报文 - var buf bytes.Buffer - - // 请求行:METHOD PATH HTTP/1.1 - path := req.URL.RequestURI() - if path == "" { - path = "/" - } - fmt.Fprintf(&buf, "%s %s HTTP/1.1\r\n", req.Method, path) - fmt.Fprintf(&buf, "Host: localhost\r\n") - - // 设置默认请求头 - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "uiautomator2") - } - if req.Header.Get("Accept-Encoding") == "" { - req.Header.Set("Accept-Encoding", "") - } - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - // 写入请求头 - for k, vals := range req.Header { - for _, v := range vals { - fmt.Fprintf(&buf, "%s: %s\r\n", k, v) - } - } - - // 处理请求体 - var bodyBytes []byte - if req.Body != nil { - var err error - bodyBytes, err = io.ReadAll(req.Body) - if err != nil { - return nil, fmt.Errorf("读取请求体失败: %w", err) - } - fmt.Fprintf(&buf, "Content-Length: %d\r\n", len(bodyBytes)) - } else { - fmt.Fprintf(&buf, "Content-Length: 0\r\n") - } - - // 请求头与请求体之间的空行 - buf.WriteString("\r\n") - - // 发送请求头 - if _, err := c.Conn.Write(buf.Bytes()); err != nil { - return nil, fmt.Errorf("发送请求头失败: %w", err) - } - // 发送请求体 - if len(bodyBytes) > 0 { - if _, err := c.Conn.Write(bodyBytes); err != nil { - return nil, fmt.Errorf("发送请求体失败: %w", err) - } - } - - // 使用标准库解析 HTTP 响应 - reader := bufio.NewReader(c.Conn) - resp, err := http.ReadResponse(reader, req) - if err != nil { - return nil, fmt.Errorf("读取 HTTP 响应失败: %w", err) - } - return resp, nil -} - -// ---------- HttpRequest:高层 HTTP 请求封装 ---------- +// ---------- HttpRequest:通过标准 HTTP 客户端发送请求 ---------- // HttpRequest 向设备端 UIAutomator2 服务发送 HTTP 请求 -// ctx 为上下文控制,dev 为设备接口,devicePort 为设备端服务端口 -// method 为 HTTP 方法,path 为请求路径 -// data 为请求体数据(会被 JSON 编码),timeoutSecs 为超时秒数 -// printRequest 为 true 时输出 curl 风格的调试信息 +// 使用 Go 标准 http.Client + 自定义 ADB 隧道 Dialer,与 Python uiautomator2 完全一致 +// +// 工作原理(与 Python 的 AdbHTTPConnection 等价): +// 1. http.Transport 的 DialContext 会为每个请求建立一条新的 ADB 隧道 +// 2. 标准 http.Client 在这条隧道上发送完整的 HTTP/1.1 请求 +// 3. 请求完成后隧道自动关闭,无需额外清理 func HttpRequest(ctx context.Context, dev AdbDevice, devicePort int, method, path string, data map[string]interface{}, timeoutSecs float64, printRequest bool) (*HTTPResponse, error) { // 默认超时 10 秒 if timeoutSecs <= 0 { timeoutSecs = 10.0 } - timeout := time.Duration(timeoutSecs * float64(time.Second)) // 调试模式:打印 curl 风格的请求信息 if printRequest { now := time.Now().Format("15:04:05.000") - url := fmt.Sprintf("http://127.0.0.1:%d%s", devicePort, path) + url := fmt.Sprintf("http://:%d%s", devicePort, path) if data != nil { b, _ := json.Marshal(data) fmt.Printf("# HTTP 超时=%.3f\n%s $ curl -X %s %s -d '%s'\n", timeoutSecs, now, method, url, string(b)) @@ -177,7 +90,9 @@ func HttpRequest(ctx context.Context, dev AdbDevice, devicePort int, method, pat } } - // 构造 HTTP 请求 + // 构造 HTTP 请求体 + // URL 中的 host:port 会被自定义 DialContext 忽略,实际连接通过 ADB 隧道 + url := fmt.Sprintf("http://127.0.0.1:%d%s", devicePort, path) var body io.Reader if data != nil { b, err := json.Marshal(data) @@ -186,23 +101,27 @@ func HttpRequest(ctx context.Context, dev AdbDevice, devicePort int, method, pat } body = bytes.NewReader(b) } - req, err := http.NewRequestWithContext(ctx, method, "http://localhost"+path, body) + + req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err) } - req.Header.Set("User-Agent", "uiautomator2") - req.Header.Set("Accept-Encoding", "") - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json; charset=utf-8") - // 建立到设备的连接 - connWrapper, err := NewAdbHTTPConnection(dev, devicePort, timeout) - if err != nil { - return nil, err + // 核心:自定义 Transport,用 ADB 隧道替代普通 TCP 连接 + // 这与 Python 中 AdbHTTPConnection.connect() 重写 self.sock 的做法完全等价 + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dev.CreateConnection("tcp", devicePort) + }, + DisableKeepAlives: true, // 每次请求独立隧道,与 Python 行为一致 + } + client := &http.Client{ + Transport: transport, + Timeout: time.Duration(timeoutSecs * float64(time.Second)), } - defer connWrapper.Close() - // 发送请求并读取响应 - resp, err := connWrapper.sendRequest(req, timeout) + resp, err := client.Do(req) if err != nil { var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { diff --git a/libs/selector.go b/libs/selector.go index 1d259ff..3a998df 100644 --- a/libs/selector.go +++ b/libs/selector.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "strconv" ) // FieldMeta 定义选择器字段的掩码位和默认值 @@ -198,20 +197,20 @@ func (s *Selector) Clone() *Selector { } // ToMap 将选择器序列化为 map,便于 JSON 编码或 RPC 调用 +// 始终包含 childOrSibling 和 childOrSiblingSelector 字段(即使为空), +// 与 Python 版本保持一致,确保 UIAutomator2 服务端能正确解析 func (s *Selector) ToMap() map[string]interface{} { out := make(map[string]interface{}, len(s.fields)+3) for k, v := range s.fields { out[k] = v } out["mask"] = s.mask - if len(s.childOrSibling) > 0 { - out["childOrSibling"] = append([]string{}, s.childOrSibling...) - cs := make([]map[string]interface{}, 0, len(s.childOrSiblingSelector)) - for _, c := range s.childOrSiblingSelector { - cs = append(cs, c.ToMap()) - } - out["childOrSiblingSelector"] = cs + out["childOrSibling"] = append([]string{}, s.childOrSibling...) + cs := make([]map[string]interface{}, 0, len(s.childOrSiblingSelector)) + for _, c := range s.childOrSiblingSelector { + cs = append(cs, c.ToMap()) } + out["childOrSiblingSelector"] = cs return out } @@ -305,47 +304,3 @@ func (s *Selector) String() string { b, _ := json.Marshal(m) return "Selector " + string(b) } - -// Example 使用示例(仅供参考,非单元测试) -func Example() { - // 初始化根选择器 - root := MustNew(map[string]interface{}{ - "className": "android.widget.LinearLayout", - }) - - // 添加子元素选择器 - root.Child(map[string]interface{}{ - "text": "下一步", - "instance": 0, - }) - - // 更新最后一个子选择器的 instance - _ = root.UpdateInstance(2) - - // 深拷贝 - cpy := root.Clone() - - // 序列化为 JSON - j, _ := cpy.ToJSON() - fmt.Println(string(j)) -} - -// SimpleTests 简单测试函数(建议迁移到 _test.go 文件中使用 testing 包) -func SimpleTests() { - // 设置与删除字段 - s := MustNew(map[string]interface{}{"text": "hello"}) - fmt.Println("设置后掩码:", strconv.FormatUint(uint64(s.Mask()), 10)) - _ = s.Delete("text") - fmt.Println("删除后掩码:", strconv.FormatUint(uint64(s.Mask()), 10)) - - // 类型校验:bool 字段传入非 bool 值应报错 - _, err := New(map[string]interface{}{"checkable": "yes"}) - fmt.Println("非法 bool 值报错:", err != nil) - - // 深拷贝独立性验证 - s2 := MustNew(map[string]interface{}{"text": "a"}) - s2.Child(map[string]interface{}{"text": "b", "instance": 1}) - c := s2.Clone() - c.childOrSibling[0] = "sibling" - fmt.Println("原始 childOrSibling:", s2.childOrSibling[0], "克隆 childOrSibling:", c.childOrSibling[0]) -} diff --git a/libs/session.go b/libs/session.go new file mode 100644 index 0000000..7b7b03f --- /dev/null +++ b/libs/session.go @@ -0,0 +1,86 @@ +package libs + +import ( + "encoding/json" + "fmt" +) + +// Session 在 Device 基础上增加应用会话监控 +// 每次 JSON-RPC 调用前检查目标应用是否仍在运行 +// 如果应用退出或崩溃,会返回 SessionBrokenError +// +// 对应 Python 版本的 Session 类 +type Session struct { + *Device + packageName string // 监控的应用包名 + pid int // 应用启动时的 PID +} + +// NewSession 创建一个新的应用会话 +// 启动应用并记录其 PID +func NewSession(device *Device, packageName string, attach bool) (*Session, error) { + // 如果不是 attach 模式,先停止再启动应用 + if !attach { + device.AppStop(packageName) + } + device.AppStart(packageName, "", false) + + // 等待应用启动并获取 PID + pid, err := device.AppWait(packageName, 20.0, false) + if err != nil { + return nil, err + } + if pid == 0 { + return nil, &DeviceError{Message: fmt.Sprintf("应用 %s 启动失败", packageName)} + } + + return &Session{ + Device: device, + packageName: packageName, + pid: pid, + }, nil +} + +// PackageName 返回会话监控的应用包名 +func (s *Session) PackageName() string { + return s.packageName +} + +// PID 返回应用的进程 ID +func (s *Session) PID() int { + return s.pid +} + +// Running 检查应用是否仍在运行 +func (s *Session) Running() bool { + currentPid := s.pidOfApp(s.packageName) + return currentPid == s.pid && s.pid > 0 +} + +// jsonrpcCall 重写 Device 的 jsonrpcCall,增加会话状态检查 +func (s *Session) jsonrpcCall(method string, params interface{}, timeout float64) (json.RawMessage, error) { + if !s.Running() { + return nil, &SessionBrokenError{ + Message: fmt.Sprintf("应用 %s (PID: %d) 已退出", s.packageName, s.pid), + } + } + return s.Device.jsonrpcCall(method, params, timeout) +} + +// Restart 重启应用 +func (s *Session) Restart() error { + s.Device.AppStop(s.packageName) + s.Device.AppStart(s.packageName, "", false) + pid, err := s.Device.AppWait(s.packageName, 20.0, false) + if err != nil { + return err + } + s.pid = pid + return nil +} + +// Close 关闭会话(停止应用) +func (s *Session) Close() { + s.Device.AppStop(s.packageName) + s.pid = 0 +} diff --git a/libs/settings.go b/libs/settings.go new file mode 100644 index 0000000..f0594a9 --- /dev/null +++ b/libs/settings.go @@ -0,0 +1,139 @@ +package libs + +import ( + "fmt" + "sync" +) + +// Settings 管理设备的各项配置参数 +// 支持类型安全的读写操作 +type Settings struct { + mu sync.RWMutex + data map[string]interface{} +} + +// 默认配置值 +var defaultSettings = map[string]interface{}{ + // 等待元素出现的超时时间(秒) + "wait_timeout": 20.0, + // 操作前后的延迟 [前延迟, 后延迟](秒) + "operation_delay": [2]float64{0, 0}, + // 需要应用操作延迟的方法列表 + "operation_delay_methods": []string{"click", "swipe", "drag", "press"}, + // dump_hierarchy 的最大深度 + "max_depth": 50, +} + +// NewSettings 创建一个新的 Settings 实例,使用默认配置 +func NewSettings() *Settings { + s := &Settings{ + data: make(map[string]interface{}), + } + // 复制默认配置 + for k, v := range defaultSettings { + s.data[k] = v + } + return s +} + +// Get 获取配置项的值 +// 如果配置项不存在,返回 nil +func (s *Settings) Get(key string) interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + return s.data[key] +} + +// GetFloat64 获取 float64 类型的配置值 +func (s *Settings) GetFloat64(key string) float64 { + v := s.Get(key) + if v == nil { + return 0 + } + switch val := v.(type) { + case float64: + return val + case float32: + return float64(val) + case int: + return float64(val) + default: + return 0 + } +} + +// GetInt 获取 int 类型的配置值 +func (s *Settings) GetInt(key string) int { + v := s.Get(key) + if v == nil { + return 0 + } + switch val := v.(type) { + case int: + return val + case float64: + return int(val) + default: + return 0 + } +} + +// GetStringSlice 获取 []string 类型的配置值 +func (s *Settings) GetStringSlice(key string) []string { + v := s.Get(key) + if v == nil { + return nil + } + if val, ok := v.([]string); ok { + return val + } + return nil +} + +// GetOperationDelay 获取操作延迟配置 [前延迟, 后延迟] +func (s *Settings) GetOperationDelay() (float64, float64) { + v := s.Get("operation_delay") + if v == nil { + return 0, 0 + } + if val, ok := v.([2]float64); ok { + return val[0], val[1] + } + return 0, 0 +} + +// Set 设置配置项的值,包含类型校验 +func (s *Settings) Set(key string, value interface{}) error { + s.mu.Lock() + defer s.mu.Unlock() + + // 类型校验 + if existing, ok := defaultSettings[key]; ok { + if err := validateSettingType(key, existing, value); err != nil { + return err + } + } + s.data[key] = value + return nil +} + +// validateSettingType 校验设置值的类型是否与默认值匹配 +func validateSettingType(key string, defaultVal, newVal interface{}) error { + switch defaultVal.(type) { + case float64: + switch newVal.(type) { + case float64, float32, int, int64: + return nil + default: + return fmt.Errorf("配置 %s 必须是数值类型", key) + } + case int: + switch newVal.(type) { + case int, int64, float64: + return nil + default: + return fmt.Errorf("配置 %s 必须是整数类型", key) + } + } + return nil +} diff --git a/libs/swipe_ext.go b/libs/swipe_ext.go new file mode 100644 index 0000000..c895061 --- /dev/null +++ b/libs/swipe_ext.go @@ -0,0 +1,86 @@ +package libs + +import "fmt" + +// SwipeExt 提供扩展的滑动操作 +// 支持按方向、比例、区域进行滑动 +// 对应 Python 版本的 SwipeExt 类 +type SwipeExt struct { + device *Device +} + +// NewSwipeExt 创建扩展滑动操作实例 +func NewSwipeExt(device *Device) *SwipeExt { + return &SwipeExt{device: device} +} + +// SwipeDirection 按方向滑动 +// direction: 滑动方向(DirectionLeft/Right/Up/Down) +// scale: 滑动比例(0-1.0),默认 0.9 +// box: 滑动区域 [left, top, right, bottom],nil 表示全屏 +// steps: 滑动步数 +func (s *SwipeExt) SwipeDirection(direction Direction, scale float64, box *[4]int, steps int) error { + if scale <= 0 || scale > 1.0 { + scale = 0.9 + } + if steps <= 0 { + steps = ScrollSteps + } + + var lx, ly, rx, ry int + if box != nil { + lx, ly, rx, ry = box[0], box[1], box[2], box[3] + } else { + w, h, err := s.device.WindowSize() + if err != nil { + return err + } + lx, ly = 0, 0 + rx, ry = w, h + } + + width := rx - lx + height := ry - ly + + hOffset := int(float64(width) * (1 - scale) / 2) + vOffset := int(float64(height) * (1 - scale) / 2) + + center := [2]int{lx + width/2, ly + height/2} + left := [2]int{lx + hOffset, ly + height/2} + up := [2]int{lx + width/2, ly + vOffset} + right := [2]int{rx - hOffset, ly + height/2} + bottom := [2]int{lx + width/2, ry - vOffset} + + switch direction { + case DirectionLeft: + return s.device.Swipe(right[0], right[1], left[0], left[1], steps) + case DirectionRight: + return s.device.Swipe(left[0], left[1], right[0], right[1], steps) + case DirectionUp: + return s.device.Swipe(center[0], center[1], up[0], up[1], steps) + case DirectionDown: + return s.device.Swipe(center[0], center[1], bottom[0], bottom[1], steps) + default: + return fmt.Errorf("不支持的方向: %s", string(direction)) + } +} + +// Left 向左滑动 +func (s *SwipeExt) Left(scale float64, steps int) error { + return s.SwipeDirection(DirectionLeft, scale, nil, steps) +} + +// Right 向右滑动 +func (s *SwipeExt) Right(scale float64, steps int) error { + return s.SwipeDirection(DirectionRight, scale, nil, steps) +} + +// Up 向上滑动 +func (s *SwipeExt) Up(scale float64, steps int) error { + return s.SwipeDirection(DirectionUp, scale, nil, steps) +} + +// Down 向下滑动 +func (s *SwipeExt) Down(scale float64, steps int) error { + return s.SwipeDirection(DirectionDown, scale, nil, steps) +} diff --git a/libs/uiobject.go b/libs/uiobject.go new file mode 100644 index 0000000..2c6c6e4 --- /dev/null +++ b/libs/uiobject.go @@ -0,0 +1,664 @@ +package libs + +import ( + "encoding/json" + "fmt" + "time" +) + +// UiObject 表示一个 Android UI 控件对象 +// 通过 Selector 定位,支持点击、输入、滑动等操作 +// 对应 Python 版本的 UiObject 类 +type UiObject struct { + device *Device + selector *Selector + jsonrpc *JsonRpcWrapper +} + +// NewUiObject 创建一个新的 UiObject +func NewUiObject(device *Device, selector *Selector) *UiObject { + return &UiObject{ + device: device, + selector: selector, + jsonrpc: device.JsonRpc(), + } +} + +// Selector 返回当前 UiObject 的选择器 +func (u *UiObject) Selector() *Selector { + return u.selector +} + +// ---------- 等待和存在性 ---------- + +// Exists 检查 UI 元素是否存在于当前窗口 +func (u *UiObject) Exists() (bool, error) { + raw, err := u.jsonrpc.Call("objInfo", []interface{}{u.selector.ToMap()}, 10) + if err != nil { + // UiObjectNotFoundError 意味着不存在 + if _, ok := err.(*UiObjectNotFoundError); ok { + return false, nil + } + return false, err + } + return raw != nil, nil +} + +// Wait 等待 UI 元素出现或消失 +// exists: true 等待出现,false 等待消失 +// timeout: 超时时间(秒),0 使用默认值 +// +// 通过 JSON-RPC 调用服务端的 waitForExists/waitUntilGone 实现 +// 使用 adb forward 端口转发,连接稳定,与 Python 版本行为一致 +func (u *UiObject) Wait(exists bool, timeout float64) (bool, error) { + if timeout <= 0 { + timeout = u.device.WaitTimeout() + } + if timeout <= 0 { + timeout = 10.0 + } + httpWait := timeout + 10 + + if exists { + raw, err := u.jsonrpc.Call("waitForExists", []interface{}{u.selector.ToMap(), int(timeout * 1000)}, httpWait) + if err != nil { + // HTTP 超时时回退到 Exists 检查 + if _, ok := err.(*HTTPError); ok { + ex, _ := u.Exists() + return ex, nil + } + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil + } + + // 等待消失 + raw, err := u.jsonrpc.Call("waitUntilGone", []interface{}{u.selector.ToMap(), int(timeout * 1000)}, httpWait) + if err != nil { + if _, ok := err.(*HTTPError); ok { + ex, _ := u.Exists() + return !ex, nil + } + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// WaitGone 等待 UI 元素消失 +func (u *UiObject) WaitGone(timeout float64) (bool, error) { + return u.Wait(false, timeout) +} + +// MustWait 等待元素出现,不存在则返回 UiObjectNotFoundError +func (u *UiObject) MustWait(timeout float64) error { + found, err := u.Wait(true, timeout) + if err != nil { + return err + } + if !found { + return &UiObjectNotFoundError{ + Code: -32002, + Message: fmt.Sprintf("等待超时: %s", u.selector.String()), + Params: u.selector.ToMap(), + } + } + return nil +} + +// ---------- 元素信息 ---------- + +// ObjInfo 包含 UI 元素的详细信息 +type ObjInfo struct { + Text string `json:"text"` + ClassName string `json:"className"` + ContentDescription string `json:"contentDescription"` + PackageName string `json:"packageName"` + ResourceName string `json:"resourceName"` + Checkable bool `json:"checkable"` + Checked bool `json:"checked"` + Clickable bool `json:"clickable"` + Enabled bool `json:"enabled"` + Focusable bool `json:"focusable"` + Focused bool `json:"focused"` + LongClickable bool `json:"longClickable"` + Scrollable bool `json:"scrollable"` + Selected bool `json:"selected"` + Bounds map[string]int `json:"bounds"` + VisibleBounds map[string]int `json:"visibleBounds"` + ChildCount int `json:"childCount"` + Extra map[string]interface{} `json:"-"` // 额外字段 +} + +// Info 获取 UI 元素信息 +func (u *UiObject) Info() (*ObjInfo, error) { + raw, err := u.jsonrpc.Call("objInfo", []interface{}{u.selector.ToMap()}) + if err != nil { + return nil, err + } + var info ObjInfo + if err := json.Unmarshal(raw, &info); err != nil { + return nil, err + } + return &info, nil +} + +// InfoRaw 获取 UI 元素的原始 map 信息 +func (u *UiObject) InfoRaw() (map[string]interface{}, error) { + raw, err := u.jsonrpc.Call("objInfo", []interface{}{u.selector.ToMap()}) + if err != nil { + return nil, err + } + var info map[string]interface{} + if err := json.Unmarshal(raw, &info); err != nil { + return nil, err + } + return info, nil +} + +// InfoList 获取所有匹配元素的信息列表 +func (u *UiObject) InfoList() ([]map[string]interface{}, error) { + raw, err := u.jsonrpc.Call("objInfoOfAllInstances", []interface{}{u.selector.ToMap()}) + if err != nil { + return nil, err + } + var list []map[string]interface{} + if err := json.Unmarshal(raw, &list); err != nil { + return nil, err + } + return list, nil +} + +// ---------- 边界和坐标 ---------- + +// Bounds 获取元素的边界坐标 (left, top, right, bottom) +func (u *UiObject) Bounds() (int, int, int, int, error) { + info, err := u.InfoRaw() + if err != nil { + return 0, 0, 0, 0, err + } + + // 优先使用 visibleBounds + bounds, ok := info["visibleBounds"].(map[string]interface{}) + if !ok { + bounds, ok = info["bounds"].(map[string]interface{}) + if !ok { + return 0, 0, 0, 0, fmt.Errorf("无法获取元素边界") + } + } + + lx := int(bounds["left"].(float64)) + ly := int(bounds["top"].(float64)) + rx := int(bounds["right"].(float64)) + ry := int(bounds["bottom"].(float64)) + return lx, ly, rx, ry, nil +} + +// Center 获取元素中心坐标 +// offset: [xoff, yoff],(0,0) 表示左上角,(0.5,0.5) 表示中心 +func (u *UiObject) Center(offset ...float64) (int, int, error) { + xoff, yoff := 0.5, 0.5 + if len(offset) >= 2 { + xoff, yoff = offset[0], offset[1] + } + + lx, ly, rx, ry, err := u.Bounds() + if err != nil { + return 0, 0, err + } + + width := rx - lx + height := ry - ly + x := lx + int(float64(width)*xoff) + y := ly + int(float64(height)*yoff) + return x, y, nil +} + +// ---------- 点击操作 ---------- + +// Click 点击 UI 元素 +// timeout: 等待元素出现的超时时间(秒),0 使用默认值 +func (u *UiObject) Click(timeout ...float64) error { + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + + if err := u.MustWait(t); err != nil { + return err + } + + x, y, err := u.Center() + if err != nil { + return err + } + return u.device.Click(x, y) +} + +// ClickWithOffset 带偏移量点击 UI 元素 +func (u *UiObject) ClickWithOffset(xoff, yoff float64, timeout ...float64) error { + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + + if err := u.MustWait(t); err != nil { + return err + } + + x, y, err := u.Center(xoff, yoff) + if err != nil { + return err + } + return u.device.Click(x, y) +} + +// ClickExists 如果元素存在则点击,返回是否成功 +func (u *UiObject) ClickExists(timeout ...float64) bool { + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + err := u.Click(t) + return err == nil +} + +// ClickGone 持续点击直到元素消失 +// maxRetry: 最大重试次数 +// interval: 重试间隔(秒) +func (u *UiObject) ClickGone(maxRetry int, interval float64) bool { + if maxRetry <= 0 { + maxRetry = 10 + } + if interval <= 0 { + interval = 1.0 + } + + u.ClickExists(0) + for i := 0; i < maxRetry; i++ { + time.Sleep(time.Duration(interval * float64(time.Second))) + exists, _ := u.Exists() + if !exists { + return true + } + u.ClickExists(0) + } + return false +} + +// LongClick 长按 UI 元素 +// duration: 按住时间(秒),默认 0.5 +func (u *UiObject) LongClick(duration float64, timeout ...float64) error { + if duration <= 0 { + duration = 0.5 + } + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + + if err := u.MustWait(t); err != nil { + return err + } + + x, y, err := u.Center() + if err != nil { + return err + } + return u.device.LongClick(x, y, duration) +} + +// ---------- 文本操作 ---------- + +// GetText 获取元素文本内容 +func (u *UiObject) GetText(timeout ...float64) (string, error) { + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + if err := u.MustWait(t); err != nil { + return "", err + } + + raw, err := u.jsonrpc.Call("getText", []interface{}{u.selector.ToMap()}) + if err != nil { + return "", err + } + var text string + json.Unmarshal(raw, &text) + return text, nil +} + +// SetText 设置元素文本内容 +// 如果 text 为空,则清除文本 +func (u *UiObject) SetText(text string, timeout ...float64) error { + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + if err := u.MustWait(t); err != nil { + return err + } + + if text == "" { + _, err := u.jsonrpc.Call("clearTextField", []interface{}{u.selector.ToMap()}) + return err + } + _, err := u.jsonrpc.Call("setText", []interface{}{u.selector.ToMap(), text}) + return err +} + +// ClearText 清除元素文本 +func (u *UiObject) ClearText(timeout ...float64) error { + return u.SetText("", timeout...) +} + +// SendKeys SetText 的别名 +func (u *UiObject) SendKeys(text string, timeout ...float64) error { + return u.SetText(text, timeout...) +} + +// ---------- 滑动操作 ---------- + +// UiSwipe 在元素范围内滑动 +// direction: 方向 "left"/"right"/"up"/"down" +// steps: 滑动步数 +func (u *UiObject) UiSwipe(direction string, steps int) error { + if steps <= 0 { + steps = 10 + } + + if err := u.MustWait(0); err != nil { + return err + } + + lx, ly, rx, ry, err := u.Bounds() + if err != nil { + return err + } + + cx := (lx + rx) / 2 + cy := (ly + ry) / 2 + + switch direction { + case "up": + return u.device.Swipe(cx, cy, cx, ly, steps) + case "down": + return u.device.Swipe(cx, cy, cx, ry-1, steps) + case "left": + return u.device.Swipe(cx, cy, lx, cy, steps) + case "right": + return u.device.Swipe(cx, cy, rx-1, cy, steps) + default: + return fmt.Errorf("不支持的方向: %s", direction) + } +} + +// DragTo 将元素拖拽到指定坐标 +func (u *UiObject) DragTo(x, y int, duration float64, timeout ...float64) error { + if duration <= 0 { + duration = 0.5 + } + t := 0.0 + if len(timeout) > 0 { + t = timeout[0] + } + if err := u.MustWait(t); err != nil { + return err + } + + steps := int(duration * 200) + _, err := u.jsonrpc.Call("dragTo", []interface{}{u.selector.ToMap(), x, y, steps}) + return err +} + +// ---------- 手势操作 ---------- + +// PinchIn 向内捏合(缩小) +func (u *UiObject) PinchIn(percent, steps int) error { + if percent <= 0 { + percent = 100 + } + if steps <= 0 { + steps = 50 + } + _, err := u.jsonrpc.Call("pinchIn", []interface{}{u.selector.ToMap(), percent, steps}) + return err +} + +// PinchOut 向外捏合(放大) +func (u *UiObject) PinchOut(percent, steps int) error { + if percent <= 0 { + percent = 100 + } + if steps <= 0 { + steps = 50 + } + _, err := u.jsonrpc.Call("pinchOut", []interface{}{u.selector.ToMap(), percent, steps}) + return err +} + +// ---------- 子/兄弟元素 ---------- + +// Child 查找子元素 +func (u *UiObject) Child(params map[string]interface{}) (*UiObject, error) { + sel := u.selector.Clone() + if _, err := sel.Child(params); err != nil { + return nil, err + } + return NewUiObject(u.device, sel), nil +} + +// Sibling 查找兄弟元素 +func (u *UiObject) Sibling(params map[string]interface{}) (*UiObject, error) { + sel := u.selector.Clone() + if _, err := sel.Sibling(params); err != nil { + return nil, err + } + return NewUiObject(u.device, sel), nil +} + +// ChildByText 通过文本查找子元素 +func (u *UiObject) ChildByText(text string, params map[string]interface{}) (*UiObject, error) { + childSel, err := New(params) + if err != nil { + return nil, err + } + raw, err := u.jsonrpc.Call("childByText", []interface{}{u.selector.ToMap(), childSel.ToMap(), text}) + if err != nil { + return nil, err + } + var resultMap map[string]interface{} + if err := json.Unmarshal(raw, &resultMap); err != nil { + return nil, err + } + resultSel, err := FromMap(resultMap) + if err != nil { + return nil, err + } + return NewUiObject(u.device, resultSel), nil +} + +// ChildByDescription 通过描述查找子元素 +func (u *UiObject) ChildByDescription(desc string, params map[string]interface{}) (*UiObject, error) { + childSel, err := New(params) + if err != nil { + return nil, err + } + raw, err := u.jsonrpc.Call("childByDescription", []interface{}{u.selector.ToMap(), childSel.ToMap(), desc}) + if err != nil { + return nil, err + } + var resultMap map[string]interface{} + if err := json.Unmarshal(raw, &resultMap); err != nil { + return nil, err + } + resultSel, err := FromMap(resultMap) + if err != nil { + return nil, err + } + return NewUiObject(u.device, resultSel), nil +} + +// ---------- 数量和索引 ---------- + +// Count 获取匹配元素的数量 +func (u *UiObject) Count() (int, error) { + raw, err := u.jsonrpc.Call("count", []interface{}{u.selector.ToMap()}) + if err != nil { + return 0, err + } + var count int + json.Unmarshal(raw, &count) + return count, nil +} + +// Instance 获取指定索引的元素 +func (u *UiObject) Instance(index int) *UiObject { + sel := u.selector.Clone() + if index < 0 { + // 负数索引需要先获取总数 + count, err := u.Count() + if err == nil && index+count >= 0 { + index = index + count + } else { + index = 0 + } + } + sel.UpdateInstance(index) + return NewUiObject(u.device, sel) +} + +// ---------- 滚动操作 ---------- + +// ScrollForward 向前滚动 +func (u *UiObject) ScrollForward(vertical bool, steps int) (bool, error) { + if steps <= 0 { + steps = ScrollSteps + } + raw, err := u.jsonrpc.Call("scrollForward", []interface{}{u.selector.ToMap(), vertical, steps}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// ScrollBackward 向后滚动 +func (u *UiObject) ScrollBackward(vertical bool, steps int) (bool, error) { + if steps <= 0 { + steps = ScrollSteps + } + raw, err := u.jsonrpc.Call("scrollBackward", []interface{}{u.selector.ToMap(), vertical, steps}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// ScrollToBeginning 滚动到开头 +func (u *UiObject) ScrollToBeginning(vertical bool, maxSwipes, steps int) (bool, error) { + if maxSwipes <= 0 { + maxSwipes = 500 + } + if steps <= 0 { + steps = ScrollSteps + } + raw, err := u.jsonrpc.Call("scrollToBeginning", []interface{}{u.selector.ToMap(), vertical, maxSwipes, steps}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// ScrollToEnd 滚动到末尾 +func (u *UiObject) ScrollToEnd(vertical bool, maxSwipes, steps int) (bool, error) { + if maxSwipes <= 0 { + maxSwipes = 500 + } + if steps <= 0 { + steps = ScrollSteps + } + raw, err := u.jsonrpc.Call("scrollToEnd", []interface{}{u.selector.ToMap(), vertical, maxSwipes, steps}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// ScrollTo 滚动到指定元素可见 +func (u *UiObject) ScrollTo(targetParams map[string]interface{}, vertical bool) (bool, error) { + targetSel, err := New(targetParams) + if err != nil { + return false, err + } + raw, err := u.jsonrpc.Call("scrollTo", []interface{}{u.selector.ToMap(), targetSel.ToMap(), vertical}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// ---------- Fling 操作 ---------- + +// FlingForward 向前快速滑动 +func (u *UiObject) FlingForward(vertical bool) (bool, error) { + raw, err := u.jsonrpc.Call("flingForward", []interface{}{u.selector.ToMap(), vertical}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// FlingBackward 向后快速滑动 +func (u *UiObject) FlingBackward(vertical bool) (bool, error) { + raw, err := u.jsonrpc.Call("flingBackward", []interface{}{u.selector.ToMap(), vertical}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// FlingToBeginning 快速滑动到开头 +func (u *UiObject) FlingToBeginning(vertical bool, maxSwipes int) (bool, error) { + if maxSwipes <= 0 { + maxSwipes = 500 + } + raw, err := u.jsonrpc.Call("flingToBeginning", []interface{}{u.selector.ToMap(), vertical, maxSwipes}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} + +// FlingToEnd 快速滑动到末尾 +func (u *UiObject) FlingToEnd(vertical bool, maxSwipes int) (bool, error) { + if maxSwipes <= 0 { + maxSwipes = 500 + } + raw, err := u.jsonrpc.Call("flingToEnd", []interface{}{u.selector.ToMap(), vertical, maxSwipes}) + if err != nil { + return false, err + } + var result bool + json.Unmarshal(raw, &result) + return result, nil +} diff --git a/libs/watcher.go b/libs/watcher.go new file mode 100644 index 0000000..860255e --- /dev/null +++ b/libs/watcher.go @@ -0,0 +1,278 @@ +package libs + +import ( + "fmt" + "log" + "sync" + "time" +) + +// ---------- WatchContext:简化版弹窗监控 ---------- + +// WatchCondition 定义一个监控条件和对应的操作 +type WatchCondition struct { + // Selectors 匹配条件列表(xpath 或文本),全部匹配时触发 + Selectors []map[string]interface{} + // Callback 匹配后的回调操作 + Callback func(d *Device) error +} + +// WatchContext 提供 UI 弹窗/对话框的自动监控和处理 +// 对应 Python 版本的 WatchContext 和 Watcher +type WatchContext struct { + device *Device + + // 监控条件列表 + conditions []WatchCondition + + // 当前正在构建的条件 + pendingSelectors []map[string]interface{} + + // 状态管理 + mu sync.Mutex + stopCh chan struct{} + stopped chan struct{} + started bool + triggerTime time.Time + + // 检查间隔(秒) + interval float64 +} + +// NewWatchContext 创建一个新的监控上下文 +// builtin: 是否添加内置的中文弹窗处理规则 +func NewWatchContext(device *Device, builtin bool) *WatchContext { + wc := &WatchContext{ + device: device, + conditions: []WatchCondition{}, + interval: 2.0, + triggerTime: time.Now(), + } + + if builtin { + // 添加常见的中文弹窗自动处理规则 + wc.WhenText("继续使用").Click() + wc.WhenText("同意").Click() + wc.WhenText("确定").Click() + wc.WhenText("好的").Click() + wc.WhenText("继续安装").Click() + wc.WhenText("安装").Click() + wc.WhenText("Agree").Click() + wc.WhenText("ALLOW").Click() + } + + return wc +} + +// WhenText 添加按文本匹配的监控条件(支持链式调用) +func (wc *WatchContext) WhenText(text string) *WatchContext { + wc.pendingSelectors = append(wc.pendingSelectors, map[string]interface{}{ + "text": text, + }) + return wc +} + +// WhenDescription 添加按描述匹配的监控条件 +func (wc *WatchContext) WhenDescription(desc string) *WatchContext { + wc.pendingSelectors = append(wc.pendingSelectors, map[string]interface{}{ + "description": desc, + }) + return wc +} + +// WhenResourceID 添加按资源 ID 匹配的监控条件 +func (wc *WatchContext) WhenResourceID(id string) *WatchContext { + wc.pendingSelectors = append(wc.pendingSelectors, map[string]interface{}{ + "resourceId": id, + }) + return wc +} + +// Click 为当前待处理的条件设置点击操作 +func (wc *WatchContext) Click() { + if len(wc.pendingSelectors) == 0 { + return + } + + selectors := make([]map[string]interface{}, len(wc.pendingSelectors)) + copy(selectors, wc.pendingSelectors) + wc.pendingSelectors = nil + + wc.conditions = append(wc.conditions, WatchCondition{ + Selectors: selectors, + Callback: func(d *Device) error { + // 点击最后一个匹配的选择器 + lastSel := selectors[len(selectors)-1] + obj, err := d.FindElement(lastSel) + if err != nil { + return err + } + return obj.Click(0) + }, + }) +} + +// Press 为当前待处理的条件设置按键操作 +func (wc *WatchContext) Press(key string) { + if len(wc.pendingSelectors) == 0 { + return + } + + selectors := make([]map[string]interface{}, len(wc.pendingSelectors)) + copy(selectors, wc.pendingSelectors) + wc.pendingSelectors = nil + + wc.conditions = append(wc.conditions, WatchCondition{ + Selectors: selectors, + Callback: func(d *Device) error { + return d.Press(key) + }, + }) +} + +// Call 为当前待处理的条件设置自定义回调 +func (wc *WatchContext) Call(fn func(d *Device) error) { + if len(wc.pendingSelectors) == 0 { + return + } + + selectors := make([]map[string]interface{}, len(wc.pendingSelectors)) + copy(selectors, wc.pendingSelectors) + wc.pendingSelectors = nil + + wc.conditions = append(wc.conditions, WatchCondition{ + Selectors: selectors, + Callback: fn, + }) +} + +// ---------- 运行控制 ---------- + +// Start 开始后台监控 +func (wc *WatchContext) Start() { + wc.mu.Lock() + defer wc.mu.Unlock() + + if wc.started { + return + } + wc.started = true + wc.stopCh = make(chan struct{}) + wc.stopped = make(chan struct{}) + + go wc.runForever() +} + +// Stop 停止监控 +func (wc *WatchContext) Stop() { + wc.mu.Lock() + if !wc.started { + wc.mu.Unlock() + return + } + close(wc.stopCh) + wc.mu.Unlock() + + // 等待停止 + select { + case <-wc.stopped: + case <-time.After(10 * time.Second): + } + + wc.mu.Lock() + wc.started = false + wc.mu.Unlock() +} + +// Running 检查是否正在运行 +func (wc *WatchContext) Running() bool { + wc.mu.Lock() + defer wc.mu.Unlock() + return wc.started +} + +// runForever 持续监控循环 +func (wc *WatchContext) runForever() { + defer close(wc.stopped) + + ticker := time.NewTicker(time.Duration(wc.interval * float64(time.Second))) + defer ticker.Stop() + + for { + select { + case <-wc.stopCh: + return + case <-ticker.C: + wc.runOnce() + } + } +} + +// runOnce 执行一次监控检查 +func (wc *WatchContext) runOnce() bool { + wc.mu.Lock() + defer wc.mu.Unlock() + + for _, cond := range wc.conditions { + allMatched := true + for _, sel := range cond.Selectors { + exists, err := wc.device.Exists(sel) + if err != nil || !exists { + allMatched = false + break + } + } + + if allMatched { + log.Printf("[Watcher] 条件匹配,执行回调") + if err := cond.Callback(wc.device); err != nil { + log.Printf("[Watcher] 回调执行失败: %v", err) + } + wc.triggerTime = time.Now() + return true + } + } + return false +} + +// WaitStable 等待直到监控不再触发(稳定状态) +// stableSeconds: 稳定时间(秒) +// timeout: 超时时间(秒) +func (wc *WatchContext) WaitStable(stableSeconds, timeout float64) error { + if stableSeconds <= 0 { + stableSeconds = 5.0 + } + if timeout <= 0 { + timeout = 60.0 + } + + if !wc.started { + wc.Start() + } + + deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) + for time.Now().Before(deadline) { + wc.mu.Lock() + stable := time.Since(wc.triggerTime).Seconds() > stableSeconds + wc.mu.Unlock() + + if stable { + return nil + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("等待稳定超时") +} + +// Reset 停止并移除所有监控条件 +func (wc *WatchContext) Reset() { + if wc.started { + wc.Stop() + } + wc.conditions = nil +} + +// Remove 移除所有监控条件 +func (wc *WatchContext) Remove() { + wc.conditions = nil +} diff --git a/services/install_service.go b/services/install_service.go index fe3334f..0b426e6 100644 --- a/services/install_service.go +++ b/services/install_service.go @@ -2,63 +2,102 @@ package services import ( "fmt" + "os" "path/filepath" + "strings" "time" "github.com/zhuy1228/go-mobile-uiautomator/adb" ) -// InstallServiceJar 将 u2.jar 推送到设备的 /data/local/tmp/ 目录 -// addr 为 ADB 服务器地址,serial 为设备序列号 -func InstallServiceJar(addr, serial string) { - conn, err := adb.DialADB(addr, 15*time.Second) +// 默认路径常量 +const ( + // DefaultJarLocal 默认本地 JAR 路径 + DefaultJarLocal = "assets/u2.jar" + // DefaultJarRemote 默认设备端 JAR 路径 + DefaultJarRemote = "/data/local/tmp/u2.jar" + // DefaultApkLocal 默认本地 APK 路径 + DefaultApkLocal = "assets/app-uiautomator.apk" + // DefaultApkRemote 默认设备端 APK 临时路径 + DefaultApkRemote = "/data/local/tmp/app-uiautomator.apk" +) + +// fileExistsOnDevice 通过 ADB shell 检查设备端文件是否已存在 +// 返回 true 表示文件已存在,不需要重新推送 +func fileExistsOnDevice(addr, serial, remotePath string) bool { + conn, err := adb.ConnectToDevice(addr, serial, 10*time.Second) if err != nil { - fmt.Println("连接失败:", err) - return + return false } defer conn.Close() - if err := adb.TransportTo(conn, serial); err != nil { - fmt.Println("设备路由失败:", err) - return + // 使用 ls 检查文件是否存在 + out, err := adb.ExecShell(conn, fmt.Sprintf("ls %s 2>/dev/null", remotePath)) + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == remotePath +} + +// InstallServiceJar 将 u2.jar 推送到设备的 /data/local/tmp/ 目录 +// addr 为 ADB 服务器地址,serial 为设备序列号 +// localPath 为本地 JAR 路径,传空字符串则使用默认路径 +// force 为 true 时跳过存在性检查,强制推送 +func InstallServiceJar(addr, serial, localPath string, force bool) error { + if localPath == "" { + localPath = DefaultJarLocal } - targetPath := "/data/local/tmp/u2.jar" - sync := adb.InitSync(conn) - n, err := sync.SyncPushFile("./assets/u2.jar", targetPath, 0644, true) - if err != nil { - fmt.Println("推送失败:", err) - } else { - fmt.Printf("推送成功, 共写入 %d 字节\n", n) + // 检查本地文件是否存在 + if _, err := os.Stat(localPath); os.IsNotExist(err) { + return fmt.Errorf("本地 JAR 文件不存在: %s", localPath) } + + // 非强制模式下,检查设备端文件是否已存在 + if !force && fileExistsOnDevice(addr, serial, DefaultJarRemote) { + return nil // 文件已存在,跳过推送 + } + + _, err := adb.PushFile(addr, serial, localPath, DefaultJarRemote, 0644, false) + if err != nil { + return fmt.Errorf("推送 u2.jar 失败: %w", err) + } + return nil } // InstallServiceApk 将 UIAutomator2 APK 推送到设备并安装 // addr 为 ADB 服务器地址,serial 为设备序列号 -func InstallServiceApk(addr, serial string) { - conn, err := adb.DialADB(addr, 15*time.Second) - if err != nil { - fmt.Println("连接失败:", err) - return +// localPath 为本地 APK 路径,传空字符串则使用默认路径 +// force 为 true 时跳过存在性检查,强制推送 +func InstallServiceApk(addr, serial, localPath string, force bool) error { + if localPath == "" { + localPath = DefaultApkLocal } - if err := adb.TransportTo(conn, serial); err != nil { - fmt.Println("设备路由失败:", err) - conn.Close() - return + abs, err := filepath.Abs(localPath) + if err != nil { + return fmt.Errorf("解析 APK 路径失败: %w", err) } - targetPath := "/data/local/tmp/app-uiautomator.apk" - sync := adb.InitSync(conn) - abs, _ := filepath.Abs("./assets/app-uiautomator.apk") - n, err := sync.SyncPushFile(abs, targetPath, 0644, true) - if err != nil { - fmt.Println("推送失败:", err) + // 检查本地文件是否存在 + if _, err := os.Stat(abs); os.IsNotExist(err) { + return fmt.Errorf("本地 APK 文件不存在: %s", abs) + } + + // 非强制模式下,检查设备端文件是否已存在 + if !force && fileExistsOnDevice(addr, serial, DefaultApkRemote) { + // 文件已存在,跳过推送,但仍需确保已安装 } else { - fmt.Printf("推送成功, 共写入 %d 字节\n", n) + _, err = adb.PushFile(addr, serial, abs, DefaultApkRemote, 0644, false) + if err != nil { + return fmt.Errorf("推送 APK 失败: %w", err) + } } - conn.Close() // 在设备上安装 APK(覆盖安装) - adb.InstallApkOnDevice(addr, serial, targetPath, "-r", true) + _, err = adb.InstallApkOnDevice(addr, serial, DefaultApkRemote, "-r", false) + if err != nil { + return fmt.Errorf("安装 APK 失败: %w", err) + } + return nil } diff --git a/test/test.py b/test/test.py index 87c6247..57f2079 100644 --- a/test/test.py +++ b/test/test.py @@ -1,8 +1,8 @@ import uiautomator2 as u2 -d = u2.connect('emulator-5556') -# d.app_start('com.android.chrome', stop=True) # Start Bilibili +d = u2.connect('emulator-5554') +d.app_start('com.android.chrome', stop=True) # Start Bilibili -# d.implicitly_wait(10.0) +d.implicitly_wait(10.0) -# d(text="向设备添加账号").click() +d(text="在裝置上新增帳戶").click()