完成基础功能
This commit is contained in:
779
README.md
779
README.md
@@ -1,123 +1,472 @@
|
||||
# go-mobile-uiautomator
|
||||
# Go Mobile UIAutomator
|
||||
|
||||
一个用 Go 语言实现的 Android 移动设备 UI 自动化工具,通过 ADB 协议与 Android 设备通信,实现应用安装、文件传输和 UI 自动化测试功能。
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhuy1228/go-mobile-uiautomator">
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go Version">
|
||||
</a>
|
||||
<a href="LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||
</a>
|
||||
<a href="#">
|
||||
<img src="https://img.shields.io/badge/dependencies-0-brightgreen?style=flat-square" alt="Zero Dependencies">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## 特性
|
||||
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:<serial>` | 切换到指定设备 |
|
||||
| Shell 执行 | `shell:<command>` | 执行 Shell 命令 |
|
||||
| ADB 隧道 | `tcp:<port>` | 建立到设备端口的直连隧道 |
|
||||
| 文件同步 | `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 协议规范
|
||||
|
||||
@@ -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:<port> 命令建立隧道
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
96
cmd/main.go
96
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 {} // 阻塞等待
|
||||
}
|
||||
|
||||
983
libs/device.go
Normal file
983
libs/device.go
Normal file
@@ -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, `<hierarchy rotation="0" />`) {
|
||||
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
|
||||
}
|
||||
51
libs/doc.go
51
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
|
||||
|
||||
160
libs/errors.go
Normal file
160
libs/errors.go
Normal file
@@ -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)
|
||||
}
|
||||
220
libs/input.go
Normal file
220
libs/input.go
Normal file
@@ -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)
|
||||
}
|
||||
175
libs/jsonrpc.go
Normal file
175
libs/jsonrpc.go
Normal file
@@ -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)
|
||||
}
|
||||
65
libs/proto.go
Normal file
65
libs/proto.go
Normal file
@@ -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},
|
||||
}
|
||||
165
libs/request.go
165
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://<adb-tunnel>:%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() {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
86
libs/session.go
Normal file
86
libs/session.go
Normal file
@@ -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
|
||||
}
|
||||
139
libs/settings.go
Normal file
139
libs/settings.go
Normal file
@@ -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
|
||||
}
|
||||
86
libs/swipe_ext.go
Normal file
86
libs/swipe_ext.go
Normal file
@@ -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)
|
||||
}
|
||||
664
libs/uiobject.go
Normal file
664
libs/uiobject.go
Normal file
@@ -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
|
||||
}
|
||||
278
libs/watcher.go
Normal file
278
libs/watcher.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user