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 语言实现的 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()