feat(android): establish Roubao build baseline

This commit is contained in:
QiuSW
2026-07-25 18:20:37 +08:00
parent 285606639b
commit 04fa4a0994
96 changed files with 15249 additions and 125 deletions
+4
View File
@@ -5,3 +5,7 @@
*.md text eol=lf
*.json text eol=lf
*.txt text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.properties text eol=lf
android-buyer/gradlew text eol=lf
+3 -7
View File
@@ -77,12 +77,8 @@
## 验证
当前尚无生产代码和可运行构建命令。完成 `T-001` 后,必须把真实命令同步到:
- `init.ps1` / `init.sh`
- `docs/00-ai-start-here.md`
- `docs/03-tech-stack.md`
- `docs/05-coding-rules.md`
- `docs/current-state.md`
Android 基线使用根目录 `init.ps1` 或 `init.sh` 验证。默认执行单元测试任务并构建
Debug APK;设置 `RUN_START_COMMAND=1` 时还会在已授权真机上安装和启动 App。
后端建立后再把后端测试加入同一标准入口。
结束任务前按 `docs/clean-state-checklist.md` 收尾。
+5 -5
View File
@@ -21,12 +21,12 @@ MVP 不自动提交订单、不支付、不绕过验证码或平台风控。
```text
cmroubao/
├── android-buyer/ # 基于 Roubao 二次开发的采购 App,待接入
├── android-buyer/ # Roubao main 固定 commit 的 Android 基线
├── backend-api/ # Go-Gin API、管理页面和任务数据,待创建
├── docs/ # Harness Coding 项目事实和执行约束
├── AGENTS.md # AI coding agent 权威入口
├── init.ps1 # Windows 标准验证入口,待 T-001 配置
└── init.sh # Unix/WSL 等价入口,待 T-001 配置
├── init.ps1 # Windows 标准构建/真机启动入口
└── init.sh # Unix 等价构建/真机启动入口
```
管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据
@@ -53,5 +53,5 @@ Android 8.0(API 26),目标为 Android 14(API 34)。具体版本和本
- [当前实现状态](docs/current-state.md)
- [完整文档导航](docs/README.md)
当前仓库只有文档,还没有接入 Roubao、后端代码或可构建 APK。真实状态以
[`docs/current-state.md`](docs/current-state.md) 为准。
Roubao Android 基线已经接入并完成 Debug APK 构建和真机启动;Go 后端尚未建立。
真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。
+114
View File
@@ -0,0 +1,114 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
app/build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/modules.xml
.idea/misc.xml
.idea/vcs.xml
.idea/deploymentTargetSelector.xml
.idea/compiler.xml
.idea/kotlinc.xml
# Android Studio 3.1+ Annotation Processors
.cxx/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# Android Profiling
*.hprof
# Jetpack Compose
.compose/
# Kotlin
*.kotlin_module
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Sensitive files
*.env
.env.*
secrets.properties
api_keys.properties
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Roubao Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+540
View File
@@ -0,0 +1,540 @@
<p align="center">
<img src="docs/images/logo.png" alt="肉包 Roubao - AI Android Automation" width="120" height="120">
</p>
<h1 align="center">肉包 Roubao</h1>
<p align="center">
<strong>首款无需电脑的开源 AI 手机自动化助手 | AI Phone Automation Assistant</strong>
</p>
<p align="center">
基于视觉语言模型 (VLM) · 原生 Android Kotlin · 多 Agent 协作架构
</p>
<p align="center">
<a href="README_EN.md">English</a> | 简体中文
</p>
<p align="center">
<img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Platform">
<img src="https://img.shields.io/badge/Min%20SDK-26-blue.svg" alt="Min SDK">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
<img src="https://img.shields.io/badge/Language-Kotlin-purple.svg" alt="Language">
</p>
<p align="center">
<img src="docs/demo/demo.gif" width="280" alt="演示">
</p>
<p align="center">
<img src="docs/screenshots/1.jpg" width="180" alt="首页">
<img src="docs/screenshots/3.jpg" width="180" alt="能力">
<img src="docs/screenshots/2.jpg" width="180" alt="执行记录">
<img src="docs/screenshots/4.jpg" width="180" alt="设置">
</p>
---
## 项目背景
2025 年 12 月,字节跳动联合中兴发布了「豆包手机助手」,一款能够自动操作手机完成复杂任务的 AI 助手。它能帮你比价下单、批量投简历、刷视频,甚至代打游戏。
首批 3 万台工程机定价 3499 元,上线当天即告售罄,二手市场一度炒到 5000+。
**买不到?那就自己做一个。**
于是有了肉包——一个完全开源的 AI 手机自动化助手。
为什么叫「肉包」?因为作者不爱吃素。🥟
---
## 与同类项目的对比
| 特性 | 肉包 | 豆包手机 | 其他开源方案 |
|------|------|----------|--------------|
| 需要电脑 | ❌ 不需要 | ❌ 不需要 | ✅ 大多需要 |
| 需要购买硬件 | ❌ 不需要 | ✅ 需要 3499+ | ❌ 不需要 |
| 原生 Android 实现 | ✅ Kotlin | ✅ 原生 | ❌ Python |
| 开源 | ✅ MIT | ❌ 闭源 | ✅ 开源 |
| Skills/Tools 架构 | ✅ 完整 | ❓ 未知 | ❌ 无 |
| UI 设计 | ⭐⭐⭐½ | ⭐⭐⭐⭐ | ⭐⭐ |
| 自定义模型 | ✅ 支持 | ❌ 仅豆包 | ✅ 部分支持 |
### 我们解决了什么问题?
**传统的手机自动化方案痛点:**
- 必须连接电脑运行 ADB 命令
- 需要部署 Python 环境和各种依赖
- 只能在电脑端操作,手机必须通过数据线连接
- 技术门槛高,普通用户难以使用
**肉包的解决方案:**
一个 App,装上就能用。无需电脑、无需数据线、无需任何技术背景。
打开 App → 配置 API Key → 说出你想做的事 → 完成。
---
## 为什么选择肉包?
### 原生 Android 实现,不是 Python 脚本的封装
市面上几乎所有手机自动化开源项目(包括阿里的 MobileAgent)都是 **Python 实现**,需要:
- 在电脑上运行 Python 脚本
- 手机通过 USB/WiFi ADB 连接电脑
- 截图通过 ADB 传输到电脑,处理后再把操作指令传回手机
**肉包完全不同。**
我们用 **Kotlin 重写了整个 MobileAgent 框架**,原生运行在 Android 设备上:
- 截图、分析、执行全部在手机本地完成
- 无需电脑中转,延迟更低
- 利用 Shizuku 获得系统级权限,而非繁琐的 ADB 命令
### 为什么需要 Shizuku?
Android 系统出于安全考虑,普通 App 无法:
- 模拟用户点击、滑动屏幕
- 读取其他 App 的界面内容
- 执行 `input tap`、`screencap` 等系统命令
传统方案需要连接电脑执行 ADB 命令。而 **Shizuku** 是一个优雅的解决方案:
1. 通过无线调试或电脑 ADB **启动一次** Shizuku 服务
2. 之后普通 App 就可以获得 ADB 级别的权限
3. **无需 Root**,无需每次都连接电脑
这让肉包可以直接在手机上执行截图、点击、输入等操作,真正实现「一个 App 搞定一切」。
### 类 Claude Code 的 Tools/Skills 双层架构
受 [Claude Code](https://claude.ai/claude-code) 架构启发,肉包实现了 **Tools + Skills 双层 Agent 框架**:
```
用户: "帮我点份外卖"
│
▼
┌─────────────┐
│ SkillManager │ ← 意图识别
└─────────────┘
│
┌────┴────┐
│ │
▼ ▼
🚀 快速路径 🤖 标准路径
(Delegation) (GUI 自动化)
│ │
▼ ▼
直接 DeepLink Agent 循环
打开小美 AI 操作美团 App
```
**Tools 层(原子能力)**
底层工具集,每个 Tool 完成一个独立操作:
| Tool | 功能 |
|------|------|
| `search_apps` | 智能搜索已安装应用(支持拼音、语义) |
| `open_app` | 打开应用 |
| `deep_link` | 通过 DeepLink 跳转到 App 特定页面 |
| `clipboard` | 读写剪贴板 |
| `shell` | 执行 Shell 命令 |
| `http` | HTTP 请求(调用外部 API) |
**Skills 层(用户意图)**
面向用户的任务层,将自然语言映射到具体操作:
| Skill | 类型 | 描述 |
|-------|------|------|
| 点外卖(小美) | Delegation | 直接打开小美 AI 让它帮你点 |
| 点外卖(美团) | GUI 自动化 | 在美团 App 上一步步操作 |
| 导航(高德) | Delegation | DeepLink 直达高德搜索 |
| 生成图片(即梦) | Delegation | 打开即梦 AI 生成图片 |
| 发微信 | GUI 自动化 | 自动操作微信发消息 |
**两种执行模式:**
1. **Delegation(委托)**:高置信度匹配时,直接通过 DeepLink 打开有 AI 能力的 App(如小美、豆包、即梦),让它们完成任务。**快速、一步到位。**
2. **GUI 自动化**:没有 AI 能力的 App(如美团、微信),通过传统的截图-分析-操作循环完成。Skill 会提供操作步骤指导,提高成功率。
---
## 核心特性
### 🤖 智能 AI Agent
- 基于先进的视觉语言模型(VLM),能够"看懂"屏幕内容
- 自然语言指令,说人话就能操作手机
- 智能决策,根据屏幕状态自动规划下一步操作
### 🎨 精心设计的 UI
**这可能是所有手机自动化开源项目中 UI 做得最好看的。**
- 现代化 Material 3 设计语言
- 流畅的动画效果
- 深色/浅色主题自适应
- 精心设计的首次使用引导
- 完整的中英文双语支持
### 🔧 高度可定制
- 支持多种 VLM:阿里云通义千问、OpenAI GPT-4V、Claude 等
- 预设 API 服务商:阿里云、OpenAI、OpenRouter 一键切换
- 从 API 动态获取可用模型列表,支持模糊搜索
- 可配置自定义 API 端点,支持本地模型(Ollama、vLLM 等)
### 🔐 安全保护
- API Key 使用 AES-256-GCM 加密存储
- 检测到支付、密码等敏感页面自动停止
- 任务执行全程可视,悬浮窗显示进度
- 随时可以手动停止任务
- 可选的云端崩溃上报(可在设置中关闭)
### 🔓 Root 模式支持
当 Shizuku 以 Root 权限运行时,肉包可以启用 Root 模式:
- **Root 模式**:解锁更多系统级操作能力
- **su 命令**:允许执行 `su -c` 命令(需谨慎使用)
- **自动检测**:自动检测 Shizuku 权限等级(ADB/Root),非 Root 环境下该选项为灰色不可用
---
## 快速开始
### 前置要求
1. **Android 8.0 (API 26)** 或更高版本
2. **WiFi 网络** - Shizuku 无线调试依赖 WiFi 连接,确保手机已连接 WiFi
3. **Shizuku** - 用于获取系统级控制权限
4. **VLM API Key** - 需要视觉语言模型的 API 密钥(如阿里云通义千问)
### 安装步骤
#### 1. 安装并启动 Shizuku
Shizuku 是一个开源工具,可以让普通应用获得 ADB 权限,无需 Root。
- [Google Play](https://play.google.com/store/apps/details?id=moe.shizuku.privileged.api)
- [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases)
**启动方式(二选一):**
**无线调试(推荐,需 Android 11+)**
1. 进入 `设置 > 开发者选项 > 无线调试`
2. 开启无线调试
3. 在 Shizuku App 中选择"无线调试"方式启动
**电脑 ADB**
1. 手机连接电脑,开启 USB 调试
2. 执行:`adb shell sh /storage/emulated/0/Android/data/moe.shizuku.privileged.api/start.sh`
#### 2. 安装肉包
从[上游 Releases](https://github.com/Turbo1123/roubao/releases) 页面下载最新 APK 安装。
#### 3. 授权与配置
1. 打开肉包 App
2. 在 Shizuku 中授权肉包
3. **⚠️ 重要:进入设置页面,配置你的 API Key**
### 获取 API Key
**阿里云通义千问(推荐国内用户)**
1. 访问 [阿里云百炼平台](https://bailian.console.aliyun.com/)
2. 开通 DashScope 服务
3. 在 API-KEY 管理中创建密钥
**OpenAI(需要代理)**
1. 访问 [OpenAI Platform](https://platform.openai.com/)
2. 创建 API Key
---
## 使用示例
```
帮我点个附近好吃的汉堡
打开网易云音乐播放每日推荐
帮我把最后一张照片发送到微博
帮我在美团点一份猪脚饭
打开B站看热门视频
```
---
## 技术架构
```
┌──────────────────────────────────────────────────────────────┐
│ 肉包 App │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UI 层 (Compose) │ │
│ │ HomeScreen / Settings / History │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Skills 层 │ │
│ │ SkillManager → 意图识别 → 快速路径/标准路径 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 点外卖 │ 导航 │ 打车 │ 发微信 │ AI画图 │ ... │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Tools 层 │ │
│ │ ToolManager → 原子能力封装 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ search_apps │ open_app │ deep_link │ clipboard │ │
│ │ │ shell │ http │ screenshot │ tap │ swipe │ type │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Agent 层 │ │
│ │ MobileAgent (移植自 MobileAgent-v3) │ │
│ │ ┌───────────┬───────────┬───────────┬──────────┐ │ │
│ │ │ Manager │ Executor │ Reflector │ Notetaker│ │ │
│ │ │ (规划) │ (执行) │ (反思) │ (记录) │ │ │
│ │ └───────────┴───────────┴───────────┴──────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ VLM Client │ │
│ │ Qwen-VL / GPT-4V / Claude │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
├────────────────────────────┼────────────────────────────────┤
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shizuku │ │
│ │ System-level Control │ │
│ │ screencap │ input tap │ input swipe │ am start │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### 工作流程
```
用户输入指令
│
▼
┌─────────────────┐
│ Skills 匹配 │ ← 检查是否有对应的 Skill
└─────────────────┘
│
├── 高置信度 Delegation Skill ──▶ 直接 DeepLink 跳转 ──▶ 完成
│
▼
┌─────────────────┐
│ 标准 Agent 循环 │
└─────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ 1. 截图 - Shizuku screencap │
│ 2. Manager 规划 - VLM 分析当前状态 │
│ 3. Executor 决策 - 确定下一步操作 │
│ 4. 执行动作 - tap/swipe/type/open_app │
│ 5. Reflector 反思 - 评估操作效果 │
│ 6. 循环直到完成或安全限制 │
└──────────────────────────────────────────────┘
```
### 项目结构
```
app/src/main/java/com/roubao/autopilot/
├── agent/ # AI Agent 核心 (移植自 MobileAgent-v3)
│ ├── MobileAgent.kt # Agent 主循环
│ ├── Manager.kt # 规划 Agent
│ ├── Executor.kt # 执行 Agent
│ ├── ActionReflector.kt # 反思 Agent
│ ├── Notetaker.kt # 笔记 Agent
│ └── InfoPool.kt # 状态池
│
├── tools/ # Tools 层 - 原子能力
│ ├── Tool.kt # Tool 接口定义
│ ├── ToolManager.kt # 工具管理器
│ ├── SearchAppsTool.kt # 应用搜索
│ ├── OpenAppTool.kt # 打开应用
│ ├── DeepLinkTool.kt # DeepLink 跳转
│ ├── ClipboardTool.kt # 剪贴板操作
│ ├── ShellTool.kt # Shell 命令
│ └── HttpTool.kt # HTTP 请求
│
├── skills/ # Skills 层 - 用户意图
│ ├── Skill.kt # Skill 接口定义
│ ├── SkillRegistry.kt # Skill 注册表
│ └── SkillManager.kt # Skill 管理器
│
├── controller/ # 设备控制
│ ├── DeviceController.kt # Shizuku 控制器
│ └── AppScanner.kt # 应用扫描 (拼音/语义搜索)
│
├── vlm/ # VLM 客户端
│ └── VLMClient.kt # API 调用封装
│
├── ui/ # 用户界面
│ ├── screens/ # 各个页面
│ ├── theme/ # 主题定义
│ └── OverlayService.kt # 悬浮窗服务
│
├── data/ # 数据层
│ └── SettingsManager.kt # 设置管理
│
└── App.kt # Application 入口
app/src/main/assets/
└── skills.json # Skills 配置文件
```
---
## 路线图
### 已完成 (v1.x)
- [x] **原生 Android 实现** - Kotlin 重写 MobileAgent,摆脱 Python 依赖
- [x] **Tools 层** - 原子能力封装(search_apps、deep_link、clipboard 等)
- [x] **Skills 层** - 用户意图映射,支持 Delegation 和 GUI 自动化两种模式
- [x] **智能应用搜索** - 拼音、语义、分类多维度匹配
- [x] **快速路径** - 高置信度 Skill 直接 DeepLink 跳转
### 🚀 v2.0 开发中
> 正在开发的重大更新,目前在 `roubao2.0+AccessibilityService` 分支
- [ ] **无障碍服务混合模式** - 集成 AccessibilityService,实现更精准的 UI 操作
- 优先使用元素索引点击(不受屏幕变化影响)
- 智能回退:索引模式失败时自动切换到坐标模式
- 无需 Root,进一步降低使用门槛
- [ ] **UI 树感知** - Agent 能够获取完整的 UI 结构
- 识别可点击元素、输入框、滚动区域
- 为 LLM 提供结构化 UI 上下文
- 减少纯视觉误判
- [ ] **宏脚本系统** - 录制、存储、回放操作序列
- 将执行过程录制为可重复播放的脚本
- 支持循环播放、延时控制
- 脚本管理界面(新增"脚本"导航页)
- [ ] **设置增强**
- 无障碍服务开关与引导
- 混合模式状态展示
### 近期计划
- [ ] **MCP (Model Context Protocol)** - 接入更多能力扩展,如日历、邮件、文件管理等
- [ ] **执行录屏** - 保存任务执行过程视频,方便回顾和调试
- [ ] **更多 Skills** - 扩充内置 Skills,支持用户自定义
### 中期计划
- [ ] **更多设备支持** - 适配更多 Android 设备和定制系统(MIUI、ColorOS、HarmonyOS 等)
- [ ] **本地模型** - 支持在设备端运行小型 VLM,实现离线使用
- [ ] **任务模板** - 保存和分享常用任务
### 长期愿景
- [ ] **多应用协作** - 跨 App 联动完成复杂工作流
- [ ] **智能学习** - 从用户操作习惯中学习,优化执行策略
- [ ] **语音控制** - 语音唤醒和语音指令
---
## 开发
### 环境要求
- Android Studio Hedgehog 或更高版本
- JDK 17
- Android SDK 34
### 构建
```bash
# 克隆仓库
git clone https://github.com/yourusername/roubao.git
cd roubao
# 构建 Debug 版本
./gradlew assembleDebug
# 安装到设备
./gradlew installDebug
```
---
## 问题反馈
遇到崩溃或 Bug?请通过以下方式反馈:
### 导出日志
1. 打开肉包 App → 设置
2. 找到「反馈与调试」分组
3. 点击「导出日志」
4. 选择分享方式(微信、邮件等)发送给开发者
### 日志包含的信息
- 设备型号和 Android 版本
- 应用版本号
- 崩溃堆栈信息(如有)
- 操作日志
> 💡 日志文件不包含您的 API Key 或个人隐私信息
### 提交 Issue
请在 [GitHub Issues](https://github.com/Turbo1123/roubao/issues) 提交问题,附上:
- 问题描述
- 复现步骤
- 导出的日志文件
---
## 贡献
欢迎提交 Issue 和 Pull Request!
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 开启 Pull Request
---
## 许可证
本项目基于 MIT 许可证开源。详见 [LICENSE](LICENSE) 文件。
---
## 致谢
- [MobileAgent](https://github.com/X-PLUG/MobileAgent) - 阿里达摩院 X-PLUG 团队开源的移动端 Agent 框架,为本项目提供了重要的技术参考
- [Shizuku](https://github.com/RikkaApps/Shizuku) - 优秀的 Android 权限管理框架
---
<p align="center">
Made with ❤️ by Roubao Team
</p>
+540
View File
@@ -0,0 +1,540 @@
<p align="center">
<img src="docs/images/logo.png" alt="Roubao - AI Android Automation Assistant" width="120" height="120">
</p>
<h1 align="center">Roubao (肉包)</h1>
<p align="center">
<strong>The First Open-Source AI Phone Automation Assistant Without PC</strong>
</p>
<p align="center">
Vision-Language Model (VLM) · Native Android Kotlin · Multi-Agent Architecture
</p>
<p align="center">
English | <a href="README.md">简体中文</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Platform">
<img src="https://img.shields.io/badge/Min%20SDK-26-blue.svg" alt="Min SDK">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
<img src="https://img.shields.io/badge/Language-Kotlin-purple.svg" alt="Language">
</p>
<p align="center">
<img src="docs/demo/demo.gif" width="280" alt="Demo">
</p>
<p align="center">
<img src="docs/screenshots/1.jpg" width="180" alt="Home">
<img src="docs/screenshots/3.jpg" width="180" alt="Capabilities">
<img src="docs/screenshots/2.jpg" width="180" alt="History">
<img src="docs/screenshots/4.jpg" width="180" alt="Settings">
</p>
---
## Background
In December 2025, ByteDance partnered with ZTE to release "Doubao Phone Assistant" - an AI assistant that can automatically operate your phone to complete complex tasks. It can compare prices and place orders, batch submit job applications, scroll through videos, and even play games for you.
The first batch of 30,000 engineering units priced at 3,499 CNY (~$480) sold out on launch day, with resale prices reaching 5,000+ CNY.
**Can't buy one? Let's build our own.**
And so Roubao was born - a fully open-source AI phone automation assistant.
Why "Roubao" (肉包, meaning "meat bun")? Because the author doesn't like vegetables. 🥟
---
## Comparison
| Feature | Roubao | Doubao Phone | Other Open Source |
|---------|--------|--------------|-------------------|
| Requires PC | ❌ No | ❌ No | ✅ Most do |
| Requires Hardware | ❌ No | ✅ $480+ | ❌ No |
| Native Android | ✅ Kotlin | ✅ Native | ❌ Python |
| Open Source | ✅ MIT | ❌ Closed | ✅ Yes |
| Skills/Tools Architecture | ✅ Full | ❓ Unknown | ❌ No |
| UI Design | ⭐⭐⭐½ | ⭐⭐⭐⭐ | ⭐⭐ |
| Custom Models | ✅ Yes | ❌ Doubao only | ✅ Partial |
### What Problem Do We Solve?
**Pain points of traditional phone automation:**
- Must connect to a computer to run ADB commands
- Need to set up Python environment and various dependencies
- Can only operate from computer, phone must be connected via USB
- High technical barrier, difficult for regular users
**Roubao's Solution:**
One app, install and use. No computer, no cables, no technical background required.
Open App → Configure API Key → Tell it what you want → Done.
---
## Why Choose Roubao?
### Native Android Implementation, Not a Python Script Wrapper
Almost all phone automation open-source projects (including Alibaba's MobileAgent) are **Python implementations**, requiring:
- Running Python scripts on a computer
- Phone connected to computer via USB/WiFi ADB
- Screenshots transferred to computer, processed, then commands sent back to phone
**Roubao is completely different.**
We **rewrote the entire MobileAgent framework in Kotlin**, running natively on Android:
- Screenshot, analysis, and execution all happen locally on the phone
- No computer relay, lower latency
- Uses Shizuku for system-level permissions instead of cumbersome ADB commands
### Why Shizuku?
For security reasons, regular Android apps cannot:
- Simulate user taps and swipes on screen
- Read UI content from other apps
- Execute system commands like `input tap` or `screencap`
Traditional solutions require connecting to a computer for ADB commands. **Shizuku** is an elegant solution:
1. Start Shizuku service **once** via wireless debugging or computer ADB
2. After that, regular apps can gain ADB-level permissions
3. **No Root required**, no need to connect to computer each time
This allows Roubao to execute screenshots, taps, and input directly on the phone, truly achieving "one app does it all."
### Claude Code-Inspired Tools/Skills Architecture
Inspired by [Claude Code](https://claude.ai/claude-code), Roubao implements a **Tools + Skills dual-layer Agent framework**:
```
User: "Order me some food"
│
▼
┌─────────────┐
│ SkillManager │ ← Intent Recognition
└─────────────┘
│
┌────┴────┐
│ │
▼ ▼
🚀 Fast Path 🤖 Standard Path
(Delegation) (GUI Automation)
│ │
▼ ▼
Direct DeepLink Agent Loop
Open Xiaomei AI Operate Meituan App
```
**Tools Layer (Atomic Capabilities)**
Low-level toolkit where each Tool performs an independent operation:
| Tool | Function |
|------|----------|
| `search_apps` | Smart app search (pinyin, semantic support) |
| `open_app` | Open application |
| `deep_link` | Jump to specific app page via DeepLink |
| `clipboard` | Read/write clipboard |
| `shell` | Execute Shell commands |
| `http` | HTTP requests (call external APIs) |
**Skills Layer (User Intent)**
User-facing task layer that maps natural language to specific operations:
| Skill | Type | Description |
|-------|------|-------------|
| Order Food (Xiaomei) | Delegation | Directly open Xiaomei AI to help order |
| Order Food (Meituan) | GUI Automation | Step-by-step operation on Meituan App |
| Navigate (Amap) | Delegation | DeepLink directly to Amap search |
| Generate Image (Jimeng) | Delegation | Open Jimeng AI to generate images |
| Send WeChat | GUI Automation | Auto-operate WeChat to send messages |
**Two Execution Modes:**
1. **Delegation**: For high-confidence matches, directly open AI-capable apps (like Xiaomei, Doubao, Jimeng) via DeepLink to complete tasks. **Fast, one-step.**
2. **GUI Automation**: For apps without AI capability (like Meituan, WeChat), complete tasks through traditional screenshot-analyze-operate loops. Skills provide step guidance for better success rates.
---
## Key Features
### 🤖 Intelligent AI Agent
- Based on advanced Vision Language Models (VLM), can "see" and understand screen content
- Natural language commands - just speak normally
- Smart decision making, automatically plans next steps based on screen state
### 🎨 Beautifully Designed UI
**This is probably the best-looking UI among all open-source phone automation projects.**
- Modern Material 3 design language
- Smooth animations
- Dark/Light theme auto-adaptation
- Carefully designed onboarding experience
- Full English and Chinese language support
### 🔒 Safety Protection
- API Key encrypted with AES-256-GCM
- Automatically stops when detecting payment or password pages
- Full visibility during task execution with overlay progress display
- Can manually stop tasks anytime
- Optional cloud crash reporting (can be disabled in settings)
### 🔓 Root Mode Support
When Shizuku runs with Root privileges, Roubao can enable Root mode:
- **Root Mode**: Unlock more system-level operation capabilities
- **su Commands**: Allow execution of `su -c` commands (use with caution)
- **Auto Detection**: Automatically detects Shizuku privilege level (ADB/Root), option is grayed out in non-Root environments
### 🔧 Highly Customizable
- Supports multiple VLMs: Alibaba Qwen-VL, OpenAI GPT-4V, Claude, etc.
- Configurable custom API endpoints
- Can add custom models
---
## Quick Start
### Prerequisites
1. **Android 8.0 (API 26)** or higher
2. **WiFi Network** - Shizuku wireless debugging requires WiFi connection, ensure your phone is connected to WiFi
3. **Shizuku** - For system-level control permissions
4. **VLM API Key** - Requires a Vision Language Model API key (e.g., Alibaba Qwen-VL)
### Installation Steps
#### 1. Install and Start Shizuku
Shizuku is an open-source tool that allows regular apps to gain ADB-level permissions without Root.
- [Google Play](https://play.google.com/store/apps/details?id=moe.shizuku.privileged.api)
- [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases)
**Startup Methods (choose one):**
**Wireless Debugging (Recommended, requires Android 11+)**
1. Go to `Settings > Developer Options > Wireless Debugging`
2. Enable Wireless Debugging
3. In Shizuku app, select "Wireless Debugging" to start
**Computer ADB**
1. Connect phone to computer, enable USB Debugging
2. Run: `adb shell sh /storage/emulated/0/Android/data/moe.shizuku.privileged.api/start.sh`
#### 2. Install Roubao
Download the latest APK from the [upstream Releases](https://github.com/Turbo1123/roubao/releases) page.
#### 3. Authorization & Configuration
1. Open Roubao app
2. Authorize Roubao in Shizuku
3. **⚠️ Important: Go to Settings and configure your API Key**
### Getting an API Key
**Alibaba Qwen-VL (Recommended for China users)**
1. Visit [Alibaba Cloud Bailian Platform](https://bailian.console.aliyun.com/)
2. Enable DashScope service
3. Create API key in API-KEY management
**OpenAI (Requires proxy in some regions)**
1. Visit [OpenAI Platform](https://platform.openai.com/)
2. Create an API Key
---
## Usage Examples
```
Order a tasty burger nearby
Open NetEase Music and play daily recommendations
Post my latest photo to Weibo
Order pork trotter rice on Meituan
Watch trending videos on Bilibili
```
---
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Roubao App │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UI Layer (Compose) │ │
│ │ HomeScreen / Settings / History │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Skills Layer │ │
│ │ SkillManager → Intent Recognition → Fast/Standard │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Order Food │ Navigate │ Taxi │ WeChat │ AI Art │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Tools Layer │ │
│ │ ToolManager → Atomic Capability Wrapper │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ search_apps │ open_app │ deep_link │ clipboard │ │
│ │ │ shell │ http │ screenshot │ tap │ swipe │ type │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ Agent Layer │ │
│ │ MobileAgent (ported from MobileAgent-v3) │ │
│ │ ┌───────────┬───────────┬───────────┬──────────┐ │ │
│ │ │ Manager │ Executor │ Reflector │ Notetaker│ │ │
│ │ │ (Planning)│(Execution)│(Reflection)│ (Notes) │ │ │
│ │ └───────────┴───────────┴───────────┴──────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────┐ │
│ │ VLM Client │ │
│ │ Qwen-VL / GPT-4V / Claude │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
├────────────────────────────┼────────────────────────────────┤
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shizuku │ │
│ │ System-level Control │ │
│ │ screencap │ input tap │ input swipe │ am start │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### Workflow
```
User Input
│
▼
┌─────────────────┐
│ Skills Match │ ← Check for matching Skill
└─────────────────┘
│
├── High-confidence Delegation Skill ──▶ Direct DeepLink ──▶ Done
│
▼
┌─────────────────┐
│ Standard Agent │
│ Loop │
└─────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ 1. Screenshot - Shizuku screencap │
│ 2. Manager Planning - VLM analyzes state │
│ 3. Executor Decision - Determine next step │
│ 4. Execute Action - tap/swipe/type/open_app │
│ 5. Reflector - Evaluate action outcome │
│ 6. Loop until done or safety limit │
└──────────────────────────────────────────────┘
```
### Project Structure
```
app/src/main/java/com/roubao/autopilot/
├── agent/ # AI Agent Core (ported from MobileAgent-v3)
│ ├── MobileAgent.kt # Agent main loop
│ ├── Manager.kt # Planning Agent
│ ├── Executor.kt # Execution Agent
│ ├── ActionReflector.kt # Reflection Agent
│ ├── Notetaker.kt # Notes Agent
│ └── InfoPool.kt # State pool
│
├── tools/ # Tools Layer - Atomic Capabilities
│ ├── Tool.kt # Tool interface definition
│ ├── ToolManager.kt # Tool manager
│ ├── SearchAppsTool.kt # App search
│ ├── OpenAppTool.kt # Open app
│ ├── DeepLinkTool.kt # DeepLink jump
│ ├── ClipboardTool.kt # Clipboard operations
│ ├── ShellTool.kt # Shell commands
│ └── HttpTool.kt # HTTP requests
│
├── skills/ # Skills Layer - User Intent
│ ├── Skill.kt # Skill interface definition
│ ├── SkillRegistry.kt # Skill registry
│ └── SkillManager.kt # Skill manager
│
├── controller/ # Device Control
│ ├── DeviceController.kt # Shizuku controller
│ └── AppScanner.kt # App scanner (pinyin/semantic search)
│
├── vlm/ # VLM Client
│ └── VLMClient.kt # API wrapper
│
├── ui/ # User Interface
│ ├── screens/ # Screen composables
│ ├── theme/ # Theme definitions
│ └── OverlayService.kt # Overlay service
│
├── data/ # Data Layer
│ └── SettingsManager.kt # Settings management
│
└── App.kt # Application entry
app/src/main/assets/
└── skills.json # Skills configuration file
```
---
## Roadmap
### Completed (v1.x)
- [x] **Native Android Implementation** - Kotlin rewrite of MobileAgent, no Python dependency
- [x] **Tools Layer** - Atomic capability wrapper (search_apps, deep_link, clipboard, etc.)
- [x] **Skills Layer** - User intent mapping with Delegation and GUI Automation modes
- [x] **Smart App Search** - Multi-dimensional matching via pinyin, semantic, and category
- [x] **Fast Path** - High-confidence Skills direct DeepLink jump
### 🚀 v2.0 In Development
> Major update in progress on `roubao2.0+AccessibilityService` branch
- [ ] **Accessibility Service Hybrid Mode** - Integrate AccessibilityService for more precise UI control
- Prioritize element index-based clicking (unaffected by screen changes)
- Smart fallback: auto-switch to coordinate mode when index mode fails
- No Root required, further lowering the barrier to entry
- [ ] **UI Tree Awareness** - Agent can access complete UI structure
- Identify clickable elements, input fields, scrollable areas
- Provide structured UI context to LLM
- Reduce pure-visual misjudgments
- [ ] **Macro Script System** - Record, store, and replay action sequences
- Record execution as replayable scripts
- Support loop playback, delay control
- Script management UI (new "Scripts" navigation tab)
- [ ] **Settings Enhancement**
- Accessibility service toggle with guidance
- Hybrid mode status display
### Near-term
- [ ] **MCP (Model Context Protocol)** - Extended capabilities like calendar, email, file management
- [ ] **Execution Recording** - Save task execution videos for review and debugging
- [ ] **More Skills** - Expand built-in Skills, support user customization
### Mid-term
- [ ] **More Device Support** - Support more Android devices and custom systems (MIUI, ColorOS, HarmonyOS, etc.)
- [ ] **Local Models** - Support running small VLMs on-device for offline use
- [ ] **Task Templates** - Save and share common tasks
### Long-term Vision
- [ ] **Multi-app Collaboration** - Cross-app workflows for complex tasks
- [ ] **Smart Learning** - Learn from user habits to optimize execution strategies
- [ ] **Voice Control** - Voice activation and commands
---
## Development
### Requirements
- Android Studio Hedgehog or later
- JDK 17
- Android SDK 34
### Building
```bash
# Clone repository
git clone https://github.com/yourusername/roubao.git
cd roubao
# Build Debug version
./gradlew assembleDebug
# Install to device
./gradlew installDebug
```
---
## Bug Reports
Encountered a crash or bug? Here's how to report:
### Export Logs
1. Open Roubao App → Settings
2. Find "Feedback & Debug" section
3. Tap "Export Logs"
4. Choose a sharing method (Email, etc.) to send to developers
### Log Contents
- Device model and Android version
- App version
- Crash stack traces (if any)
- Operation logs
> 💡 Log files do NOT contain your API Key or personal information
### Submit an Issue
Please submit issues on [GitHub Issues](https://github.com/Turbo1123/roubao/issues) with:
- Problem description
- Steps to reproduce
- Exported log file
---
## Contributing
Issues and Pull Requests are welcome!
1. Fork this repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
---
## License
This project is open-sourced under the MIT License. See [LICENSE](LICENSE) file for details.
---
## Acknowledgments
- [MobileAgent](https://github.com/X-PLUG/MobileAgent) - Mobile Agent framework open-sourced by Alibaba DAMO Academy X-PLUG team, provided important technical reference for this project
- [Shizuku](https://github.com/RikkaApps/Shizuku) - Excellent Android permission management framework
---
<p align="center">
Made with ❤️ by Roubao Team
</p>
+21
View File
@@ -0,0 +1,21 @@
# Roubao Upstream Baseline
- Repository: https://github.com/Turbo1123/roubao
- Imported branch: `main`
- Imported commit: `c8a6d7f03422eb01744b01f3ee77bf7757741f7e`
- Imported on: 2026-07-25
- Upstream license: MIT; see `LICENSE`
The source was imported as a vendored snapshot without the upstream `.git`
directory. Local changes are tracked by the parent `cmroubao` repository.
## Baseline Adjustments
Firebase Analytics and Crashlytics were removed from the imported baseline.
The upstream repository does not include `google-services.json`, and telemetry
is not required for the procurement feasibility probe. Local crash logs remain
available through the existing `CrashHandler`.
The upstream `roubao2.0+AccessibilityService` branch is not the imported
baseline. Its accessibility implementation must be evaluated and ported in a
separate task after this baseline builds successfully.
+91
View File
@@ -0,0 +1,91 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.roubao.autopilot"
compileSdk = 34
defaultConfig {
applicationId = "com.roubao.autopilot"
minSdk = 26
targetSdk = 34
versionCode = 7
versionName = "1.4.2"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
aidl = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.5"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
// Security (Encrypted SharedPreferences)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Compose
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// OkHttp for API calls
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Shizuku
implementation("dev.rikka.shizuku:api:13.1.5")
implementation("dev.rikka.shizuku:provider:13.1.5")
// JSON
implementation("org.json:json:20231013")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+3
View File
@@ -0,0 +1,3 @@
# Add project specific ProGuard rules here.
-keep class com.example.autopilot.** { *; }
-keep class rikka.shizuku.** { *; }
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 存储权限 (截图保存) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Shizuku 权限 -->
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<!-- 悬浮窗权限 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 屏幕常亮 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 查询所有已安装应用 (Android 11+) -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!-- 声明需要查询的应用和 Intent -->
<queries>
<!-- 查询所有可启动的应用 -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- 查询 VIEW intent (DeepLink) -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="*" />
</intent>
<!-- 小美 DeepLink -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="beam" />
</intent>
<!-- 美团 -->
<package android:name="com.sankuai.meituan" />
<package android:name="com.meituan.android.beam" />
<!-- 饿了么 -->
<package android:name="me.ele" />
<!-- 大众点评 -->
<package android:name="com.dianping.v1" />
</queries>
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Baozi"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="34">
<!-- Shizuku Provider -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<!-- FileProvider 用于分享日志文件 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustNothing"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:theme="@style/Theme.Baozi.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 悬浮窗服务 -->
<service
android:name=".ui.OverlayService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="automation_overlay" />
</service>
</application>
</manifest>
@@ -0,0 +1,6 @@
package com.roubao.autopilot;
interface IShellService {
void destroy() = 16777114;
String exec(String command) = 1;
}
@@ -0,0 +1,709 @@
[
{
"id": "order_food",
"name": "点外卖",
"description": "帮你点外卖、订餐",
"category": "外卖",
"keywords": ["点外卖", "叫外卖", "外卖", "点餐", "订餐", "吃饭", "饿了", "点吃的", "送餐", "订个餐"],
"params": [
{
"name": "food",
"type": "string",
"description": "想吃什么",
"required": false,
"examples": ["汉堡", "猪脚饭", "麻辣烫", "炸鸡"]
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,直接对话点餐,最快捷"
},
{
"package": "me.ele",
"name": "饿了么",
"type": "gui_automation",
"priority": 60,
"steps": ["打开饿了么", "搜索食物", "选择商家", "下单"]
}
]
},
{
"id": "find_food",
"name": "找美食",
"description": "搜索附近好吃的、美食推荐",
"category": "外卖",
"keywords": ["附近有什么好吃的", "好吃的", "美食", "推荐吃的", "附近美食", "吃什么", "有啥好吃的", "找吃的", "想吃点什么"],
"params": [
{
"name": "food",
"type": "string",
"description": "想吃的类型",
"required": false,
"examples": ["火锅", "烧烤", "日料", "川菜"]
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,智能推荐附近美食"
},
{
"package": "com.dianping.v1",
"name": "大众点评",
"type": "gui_automation",
"priority": 70,
"steps": ["打开大众点评", "搜索或浏览美食", "查看评价"]
}
]
},
{
"id": "find_fun",
"name": "找好玩的",
"description": "搜索附近好玩的地方、娱乐推荐",
"category": "生活",
"keywords": ["附近有什么好玩的", "好玩的", "玩什么", "周末去哪", "附近景点", "有啥好玩的", "去哪玩", "找地方玩"],
"params": [
{
"name": "keyword",
"type": "string",
"description": "想玩的类型",
"required": false,
"examples": ["密室逃脱", "剧本杀", "电影院", "游乐园"]
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,智能推荐附近娱乐"
},
{
"package": "com.dianping.v1",
"name": "大众点评",
"type": "gui_automation",
"priority": 70,
"steps": ["打开大众点评", "搜索或浏览娱乐", "查看评价"]
}
]
},
{
"id": "book_hotel",
"name": "订酒店",
"description": "预订酒店、民宿",
"category": "生活",
"keywords": ["订酒店", "住酒店", "订房", "找酒店", "住宿", "民宿", "开房"],
"params": [
{
"name": "keyword",
"type": "string",
"description": "地点或酒店名",
"required": false
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,智能推荐酒店"
},
{
"package": "com.Qunar",
"name": "去哪儿",
"type": "gui_automation",
"priority": 70,
"steps": ["打开去哪儿", "搜索酒店", "选择日期", "预订"]
},
{
"package": "ctrip.android.view",
"name": "携程",
"type": "gui_automation",
"priority": 60,
"steps": ["打开携程", "搜索酒店", "选择日期", "预订"]
}
]
},
{
"id": "book_movie",
"name": "买电影票",
"description": "购买电影票",
"category": "生活",
"keywords": ["买电影票", "看电影", "电影票", "订电影票", "电影院"],
"params": [
{
"name": "keyword",
"type": "string",
"description": "电影名",
"required": false
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,查电影、买票"
},
{
"package": "com.taobao.movie.android",
"name": "淘票票",
"type": "gui_automation",
"priority": 70,
"steps": ["打开淘票票", "选择电影", "选择场次", "购票"]
}
]
},
{
"id": "meituan_general",
"name": "问小美",
"description": "让美团 AI 助手帮忙处理各种生活服务",
"category": "生活",
"keywords": ["问小美", "小美", "美团", "帮我查", "帮我找", "帮我订"],
"params": [
{
"name": "message",
"type": "string",
"description": "问题或需求",
"required": false
}
],
"related_apps": [
{
"package": "com.meituan.android.beam",
"name": "小美",
"type": "delegation",
"deep_link": "beam://www.meituan.com/home",
"priority": 100,
"description": "美团 AI 助手,支持外卖、酒店、电影、美食推荐等"
}
]
},
{
"id": "navigate",
"name": "导航",
"description": "导航到目的地",
"category": "出行",
"keywords": ["导航", "去", "怎么走", "路线", "地图", "到"],
"params": [
{
"name": "destination",
"type": "string",
"description": "目的地",
"required": true
}
],
"related_apps": [
{
"package": "com.autonavi.minimap",
"name": "高德地图",
"type": "delegation",
"deep_link": "amap://search?keyword={destination}&sourceApplication=roubao",
"priority": 100
},
{
"package": "com.baidu.BaiduMap",
"name": "百度地图",
"type": "delegation",
"deep_link": "baidumap://map/direction?destination={destination}&mode=driving&src=roubao",
"priority": 90
},
{
"package": "com.tencent.map",
"name": "腾讯地图",
"type": "delegation",
"deep_link": "qqmap://map/search?keyword={destination}",
"priority": 80
}
]
},
{
"id": "call_taxi",
"name": "打车",
"description": "叫车出行",
"category": "出行",
"keywords": ["打车", "叫车", "出租车", "网约车", "叫个车"],
"params": [
{
"name": "destination",
"type": "string",
"description": "目的地",
"required": false
}
],
"related_apps": [
{
"package": "com.sdu.didi.psnger",
"name": "滴滴出行",
"type": "delegation",
"deep_link": "diditaxi://",
"priority": 100
},
{
"package": "com.autonavi.minimap",
"name": "高德地图",
"type": "gui_automation",
"priority": 80,
"description": "高德打车",
"steps": ["打开高德", "点击打车", "输入目的地", "叫车"]
},
{
"package": "com.dache.caocao",
"name": "曹操出行",
"type": "delegation",
"deep_link": "caocao://",
"priority": 70
},
{
"package": "com.t3go.passenger",
"name": "T3出行",
"type": "delegation",
"deep_link": "t3go://",
"priority": 70
}
]
},
{
"id": "generate_image",
"name": "AI画图",
"description": "使用 AI 生成图片",
"category": "AI",
"keywords": ["画图", "生成图片", "AI画图", "生成图", "画一张", "生成一张"],
"params": [
{
"name": "prompt",
"type": "string",
"description": "图片描述",
"required": true
}
],
"related_apps": [
{
"package": "com.volcengine.jimeng",
"name": "即梦",
"type": "delegation",
"deep_link": "jimeng://create?prompt={prompt}",
"priority": 100
},
{
"package": "com.lark.bot",
"name": "豆包",
"type": "delegation",
"deep_link": "doubao://chat?message=帮我画{prompt}",
"priority": 90
}
]
},
{
"id": "ai_chat",
"name": "AI对话",
"description": "与 AI 助手对话",
"category": "AI",
"keywords": ["问AI", "AI对话", "AI助手", "问一下", "帮我查"],
"params": [
{
"name": "message",
"type": "string",
"description": "问题内容",
"required": false
}
],
"related_apps": [
{
"package": "com.lark.bot",
"name": "豆包",
"type": "delegation",
"deep_link": "doubao://chat?message={message}",
"priority": 100
},
{
"package": "com.moonshot.kimichat",
"name": "Kimi",
"type": "delegation",
"deep_link": "kimi://chat",
"priority": 90
},
{
"package": "com.alibaba.tongyi",
"name": "通义千问",
"type": "delegation",
"deep_link": "tongyi://chat",
"priority": 80
}
]
},
{
"id": "play_music",
"name": "听音乐",
"description": "播放音乐",
"category": "音乐",
"keywords": ["听歌", "放歌", "音乐", "播放", "来首歌", "听音乐"],
"params": [
{
"name": "song",
"type": "string",
"description": "歌曲名或歌手",
"required": false
}
],
"related_apps": [
{
"package": "com.netease.cloudmusic",
"name": "网易云音乐",
"type": "gui_automation",
"priority": 100,
"steps": ["打开网易云音乐", "搜索歌曲", "播放"]
},
{
"package": "com.tencent.qqmusic",
"name": "QQ音乐",
"type": "gui_automation",
"priority": 90,
"steps": ["打开QQ音乐", "搜索歌曲", "播放"]
},
{
"package": "com.kugou.android",
"name": "酷狗音乐",
"type": "gui_automation",
"priority": 80,
"steps": ["打开酷狗音乐", "搜索歌曲", "播放"]
},
{
"package": "com.spotify.music",
"name": "Spotify",
"type": "gui_automation",
"priority": 70,
"steps": ["打开Spotify", "搜索歌曲", "播放"]
}
]
},
{
"id": "watch_video",
"name": "看视频",
"description": "看视频/刷视频",
"category": "视频",
"keywords": ["看视频", "刷视频", "追剧", "看电影", "视频"],
"params": [
{
"name": "keyword",
"type": "string",
"description": "搜索关键词",
"required": false
}
],
"related_apps": [
{
"package": "tv.danmaku.bili",
"name": "哔哩哔哩",
"type": "gui_automation",
"priority": 100,
"steps": ["打开B站", "搜索或浏览视频", "播放"]
},
{
"package": "com.ss.android.ugc.aweme",
"name": "抖音",
"type": "delegation",
"deep_link": "snssdk1128://",
"priority": 90
},
{
"package": "com.smile.gifmaker",
"name": "快手",
"type": "delegation",
"deep_link": "kwai://",
"priority": 80
},
{
"package": "com.youku.phone",
"name": "优酷",
"type": "gui_automation",
"priority": 70,
"steps": ["打开优酷", "搜索视频", "播放"]
},
{
"package": "com.qiyi.video",
"name": "爱奇艺",
"type": "gui_automation",
"priority": 70,
"steps": ["打开爱奇艺", "搜索视频", "播放"]
}
]
},
{
"id": "send_message",
"name": "发消息",
"description": "给好友发消息",
"category": "社交",
"keywords": ["发消息", "发微信", "聊天", "给谁说"],
"params": [
{
"name": "contact",
"type": "string",
"description": "联系人",
"required": false
},
{
"name": "message",
"type": "string",
"description": "消息内容",
"required": false
}
],
"related_apps": [
{
"package": "com.tencent.mm",
"name": "微信",
"type": "gui_automation",
"priority": 100,
"steps": ["打开微信", "搜索联系人", "进入聊天", "输入消息", "发送"]
},
{
"package": "com.tencent.mobileqq",
"name": "QQ",
"type": "gui_automation",
"priority": 80,
"steps": ["打开QQ", "搜索联系人", "进入聊天", "输入消息", "发送"]
}
]
},
{
"id": "post_social",
"name": "发动态",
"description": "发布社交动态",
"category": "社交",
"keywords": ["发微博", "发朋友圈", "发动态", "发状态"],
"params": [
{
"name": "content",
"type": "string",
"description": "内容",
"required": false
}
],
"related_apps": [
{
"package": "com.tencent.mm",
"name": "微信",
"type": "gui_automation",
"priority": 100,
"description": "发朋友圈",
"steps": ["打开微信", "进入朋友圈", "点击发布", "输入内容", "发布"]
},
{
"package": "com.sina.weibo",
"name": "微博",
"type": "gui_automation",
"priority": 90,
"steps": ["打开微博", "点击发布", "输入内容", "发布"]
},
{
"package": "com.ss.android.ugc.aweme",
"name": "抖音",
"type": "gui_automation",
"priority": 80,
"description": "发抖音",
"steps": ["打开抖音", "点击发布", "录制或选择视频", "发布"]
}
]
},
{
"id": "post_xiaohongshu",
"name": "发小红书",
"description": "在小红书发布笔记",
"category": "社交",
"keywords": ["发小红书", "小红书", "发笔记", "小红书笔记", "红书"],
"params": [
{
"name": "content",
"type": "string",
"description": "笔记内容",
"required": false
}
],
"prompt_hint": "小红书笔记内容不能超过100字,请精炼表达",
"related_apps": [
{
"package": "com.xingin.xhs",
"name": "小红书",
"type": "gui_automation",
"priority": 100,
"steps": ["打开小红书", "点击底部+号发布", "选择图片或视频", "输入标题和正文(正文不超过100字)", "添加话题标签", "发布"]
}
]
},
{
"id": "pay",
"name": "付款",
"description": "付款/扫码支付",
"category": "支付",
"keywords": ["付款", "支付", "付钱", "扫码付款", "买单"],
"params": [],
"related_apps": [
{
"package": "com.eg.android.AlipayGphone",
"name": "支付宝",
"type": "delegation",
"deep_link": "alipays://platformapi/startapp?appId=20000056",
"priority": 100,
"description": "支付宝付款码"
},
{
"package": "com.tencent.mm",
"name": "微信",
"type": "delegation",
"deep_link": "weixin://pay",
"priority": 90,
"description": "微信付款码"
}
]
},
{
"id": "scan",
"name": "扫码",
"description": "扫描二维码",
"category": "支付",
"keywords": ["扫码", "扫一扫", "扫描"],
"params": [],
"related_apps": [
{
"package": "com.eg.android.AlipayGphone",
"name": "支付宝",
"type": "delegation",
"deep_link": "alipays://platformapi/startapp?appId=10000007",
"priority": 100
},
{
"package": "com.tencent.mm",
"name": "微信",
"type": "delegation",
"deep_link": "weixin://scanqrcode",
"priority": 90
}
]
},
{
"id": "take_photo",
"name": "拍照",
"description": "打开相机拍照",
"category": "工具",
"keywords": ["拍照", "照相", "自拍", "拍摄"],
"params": [],
"related_apps": [
{
"package": "com.android.camera",
"name": "相机",
"type": "gui_automation",
"priority": 100,
"steps": ["打开相机"]
}
]
},
{
"id": "set_alarm",
"name": "设闹钟",
"description": "设置闹钟提醒",
"category": "工具",
"keywords": ["闹钟", "提醒", "定时", "叫我起床", "设个闹钟"],
"params": [
{
"name": "time",
"type": "string",
"description": "时间",
"required": false
}
],
"related_apps": [
{
"package": "com.android.deskclock",
"name": "时钟",
"type": "gui_automation",
"priority": 100,
"steps": ["打开时钟", "进入闹钟", "新建闹钟", "设置时间", "保存"]
}
]
},
{
"id": "shopping",
"name": "购物",
"description": "网上购物",
"category": "购物",
"keywords": ["买", "购物", "下单", "网购", "买东西"],
"params": [
{
"name": "item",
"type": "string",
"description": "商品",
"required": false
}
],
"related_apps": [
{
"package": "com.taobao.taobao",
"name": "淘宝",
"type": "gui_automation",
"priority": 100,
"steps": ["打开淘宝", "搜索商品", "选择", "下单"]
},
{
"package": "com.jingdong.app.mall",
"name": "京东",
"type": "gui_automation",
"priority": 90,
"steps": ["打开京东", "搜索商品", "选择", "下单"]
},
{
"package": "com.xunmeng.pinduoduo",
"name": "拼多多",
"type": "gui_automation",
"priority": 80,
"steps": ["打开拼多多", "搜索商品", "选择", "下单"]
}
]
},
{
"id": "read_book",
"name": "看书",
"description": "阅读电子书",
"category": "阅读",
"keywords": ["看书", "读书", "阅读", "看小说"],
"params": [
{
"name": "book",
"type": "string",
"description": "书名",
"required": false
}
],
"related_apps": [
{
"package": "com.tencent.weread",
"name": "微信读书",
"type": "gui_automation",
"priority": 100,
"steps": ["打开微信读书", "搜索或打开书架", "阅读"]
},
{
"package": "com.dragon.read",
"name": "番茄小说",
"type": "gui_automation",
"priority": 80,
"steps": ["打开番茄小说", "搜索或打开书架", "阅读"]
}
]
}
]
@@ -0,0 +1,77 @@
package com.roubao.autopilot
import android.app.Application
import android.content.pm.PackageManager
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.skills.SkillManager
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.utils.CrashHandler
import rikka.shizuku.Shizuku
class App : Application() {
lateinit var deviceController: DeviceController
private set
lateinit var appScanner: AppScanner
private set
override fun onCreate() {
super.onCreate()
instance = this
// 初始化崩溃捕获(本地日志)
CrashHandler.getInstance().init(this)
// 初始化 Shizuku
Shizuku.addRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER)
// 初始化核心组件
initializeComponents()
}
private fun initializeComponents() {
// 初始化设备控制器
deviceController = DeviceController(this)
deviceController.setCacheDir(cacheDir)
// 初始化应用扫描器
appScanner = AppScanner(this)
// 初始化 Tools 层
val toolManager = ToolManager.init(this, deviceController, appScanner)
// 异步预扫描应用列表(避免 ANR)
println("[App] 开始异步扫描已安装应用...")
Thread {
appScanner.refreshApps()
println("[App] 已扫描 ${appScanner.getApps().size} 个应用")
}.start()
// 初始化 Skills 层(传入 appScanner 用于检测已安装应用)
val skillManager = SkillManager.init(this, toolManager, appScanner)
println("[App] SkillManager 已加载 ${skillManager.getAllSkills().size} 个 Skills")
println("[App] 组件初始化完成")
}
override fun onTerminate() {
super.onTerminate()
Shizuku.removeRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER)
}
companion object {
@Volatile
private var instance: App? = null
fun getInstance(): App {
return instance ?: throw IllegalStateException("App 未初始化")
}
private val REQUEST_PERMISSION_RESULT_LISTENER =
Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
val granted = grantResult == PackageManager.PERMISSION_GRANTED
println("[Shizuku] Permission result: $granted")
}
}
}
@@ -0,0 +1,625 @@
package com.roubao.autopilot
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import android.net.Uri
import android.provider.Settings
import com.roubao.autopilot.agent.MobileAgent
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.data.*
import com.roubao.autopilot.ui.screens.*
import com.roubao.autopilot.ui.theme.*
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import com.roubao.autopilot.vlm.GUIOwlClient
import com.roubao.autopilot.vlm.MAIUIClient
import com.roubao.autopilot.vlm.VLMClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import android.util.Log
private const val TAG = "MainActivity"
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home)
object Capabilities : Screen("capabilities", "能力", Icons.Outlined.Star, Icons.Filled.Star)
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
}
class MainActivity : ComponentActivity() {
private lateinit var deviceController: DeviceController
private lateinit var settingsManager: SettingsManager
private lateinit var executionRepository: ExecutionRepository
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false)
// 当前执行的协程 Job(用于停止任务)
private var currentExecutionJob: kotlinx.coroutines.Job? = null
// 执行记录列表
private val executionRecords = mutableStateOf<List<ExecutionRecord>>(emptyList())
// 是否正在执行(点击发送后立即为 true)
private val isExecuting = mutableStateOf(false)
// 当前执行的记录 ID(用于停止后跳转)
private val currentRecordId = mutableStateOf<String?>(null)
// 是否需要跳转到记录详情(悬浮窗停止后触发)
private val shouldNavigateToRecord = mutableStateOf(false)
private val binderReceivedListener = Shizuku.OnBinderReceivedListener {
Log.d(TAG, "Shizuku binder received")
shizukuAvailable.value = true
if (checkShizukuPermission()) {
Log.d(TAG, "Shizuku permission granted, binding service")
deviceController.bindService()
} else {
Log.d(TAG, "Shizuku permission not granted")
}
}
private val binderDeadListener = Shizuku.OnBinderDeadListener {
Log.d(TAG, "Shizuku binder dead")
shizukuAvailable.value = false
}
private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { _, grantResult ->
Log.d(TAG, "Shizuku permission result: $grantResult")
if (grantResult == PackageManager.PERMISSION_GRANTED) {
deviceController.bindService()
Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// 设置边到边显示,深色状态栏和导航栏
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
deviceController = DeviceController(this)
deviceController.setCacheDir(cacheDir)
settingsManager = SettingsManager(this)
executionRepository = ExecutionRepository(this)
// 加载执行记录
lifecycleScope.launch {
executionRecords.value = executionRepository.getAllRecords()
}
// 添加 Shizuku 监听器
Shizuku.addBinderReceivedListenerSticky(binderReceivedListener)
Shizuku.addBinderDeadListener(binderDeadListener)
Shizuku.addRequestPermissionResultListener(permissionResultListener)
// 检查 Shizuku 状态
checkAndUpdateShizukuStatus()
// 预加载已安装应用
lifecycleScope.launch(Dispatchers.IO) {
AppScanner(this@MainActivity).getApps()
}
setContent {
val settings by settingsManager.settings.collectAsState()
BaoziTheme(themeMode = settings.themeMode) {
val colors = BaoziTheme.colors
// 动态更新系统栏颜色
SideEffect {
val window = this@MainActivity.window
window.statusBarColor = colors.background.toArgb()
window.navigationBarColor = colors.backgroundCard.toArgb()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = !colors.isDark
isAppearanceLightNavigationBars = !colors.isDark
}
}
// 首次启动显示引导画面
if (!settings.hasSeenOnboarding) {
OnboardingScreen(
onComplete = {
settingsManager.setOnboardingSeen()
}
)
} else {
MainApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
var showShizukuHelpDialog by remember { mutableStateOf(false) }
var hasShownShizukuHelp by remember { mutableStateOf(false) }
val settings by settingsManager.settings.collectAsState()
val colors = BaoziTheme.colors
val agent = mobileAgent.value
val agentState by agent?.state?.collectAsState() ?: remember { mutableStateOf(null) }
val logs by agent?.logs?.collectAsState() ?: remember { mutableStateOf(emptyList<String>()) }
val records by remember { executionRecords }
val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission()
val executing by remember { isExecuting }
val navigateToRecord by remember { shouldNavigateToRecord }
val recordId by remember { currentRecordId }
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
if (navigateToRecord && recordId != null) {
// 找到对应的记录并跳转
val record = records.find { it.id == recordId }
if (record != null) {
selectedRecord = record
currentScreen = Screen.History
}
shouldNavigateToRecord.value = false
}
}
// 首次进入且 Shizuku 未连接时,显示帮助引导(只显示一次)
LaunchedEffect(Unit) {
if (!isShizukuAvailable && settings.hasSeenOnboarding && !hasShownShizukuHelp) {
hasShownShizukuHelp = true
showShizukuHelpDialog = true
}
}
Scaffold(
modifier = Modifier.background(colors.background),
containerColor = colors.background,
bottomBar = {
if (selectedRecord == null) {
NavigationBar(
containerColor = colors.background,
contentColor = colors.textPrimary,
tonalElevation = 0.dp
) {
listOf(Screen.Home, Screen.Capabilities, Screen.History, Screen.Settings).forEach { screen ->
val selected = currentScreen == screen
NavigationBarItem(
icon = {
Icon(
imageVector = if (selected) screen.selectedIcon else screen.icon,
contentDescription = screen.title
)
},
label = { Text(screen.title) },
selected = selected,
onClick = { currentScreen = screen },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = if (colors.isDark) colors.textPrimary else Color.White,
selectedTextColor = colors.primary,
unselectedIconColor = colors.textSecondary,
unselectedTextColor = colors.textSecondary,
indicatorColor = colors.primary
)
)
}
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 处理系统返回手势
BackHandler(enabled = selectedRecord != null) {
selectedRecord = null
}
// 详情页优先显示
if (selectedRecord != null) {
HistoryDetailScreen(
record = selectedRecord!!,
onBack = { selectedRecord = null }
)
} else {
// 主页面切换
AnimatedContent(
targetState = currentScreen,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "screen"
) { screen ->
when (screen) {
Screen.Home -> {
// 每次进入首页都检测 Shizuku 状态
LaunchedEffect(Unit) {
checkAndUpdateShizukuStatus()
}
HomeScreen(
agentState = agentState,
logs = logs,
onExecute = { instruction ->
runAgent(
instruction = instruction,
apiKey = settings.apiKey,
baseUrl = settings.baseUrl,
model = settings.model,
maxSteps = settings.maxSteps,
isGUIAgent = settings.currentProvider.isGUIAgent,
providerId = settings.currentProviderId
)
},
onStop = {
mobileAgent.value?.stop()
},
shizukuAvailable = isShizukuAvailable,
currentModel = settings.model,
onRefreshShizuku = { refreshShizukuStatus() },
onShizukuRequired = { showShizukuHelpDialog = true },
isExecuting = executing
)
}
Screen.Capabilities -> CapabilitiesScreen()
Screen.History -> HistoryScreen(
records = records,
onRecordClick = { record -> selectedRecord = record },
onDeleteRecord = { id -> deleteRecord(id) }
)
Screen.Settings -> SettingsScreen(
settings = settings,
onUpdateApiKey = { settingsManager.updateApiKey(it) },
onUpdateBaseUrl = { settingsManager.updateBaseUrl(it) },
onUpdateModel = { settingsManager.updateModel(it) },
onUpdateCachedModels = { settingsManager.updateCachedModels(it) },
onUpdateThemeMode = { settingsManager.updateThemeMode(it) },
onUpdateMaxSteps = { settingsManager.updateMaxSteps(it) },
onUpdateRootModeEnabled = { settingsManager.updateRootModeEnabled(it) },
onUpdateSuCommandEnabled = { settingsManager.updateSuCommandEnabled(it) },
onSelectProvider = { settingsManager.selectProvider(it) },
shizukuAvailable = isShizukuAvailable,
shizukuPrivilegeLevel = if (isShizukuAvailable) {
when (deviceController.getShizukuPrivilegeLevel()) {
DeviceController.ShizukuPrivilegeLevel.ROOT -> "ROOT"
DeviceController.ShizukuPrivilegeLevel.ADB -> "ADB"
else -> "NONE"
}
} else "NONE",
onFetchModels = { onSuccess, onError ->
lifecycleScope.launch {
val result = VLMClient.fetchModels(settings.baseUrl, settings.apiKey)
result.onSuccess { models ->
onSuccess(models)
}.onFailure { error ->
onError(error.message ?: "未知错误")
}
}
}
)
}
}
}
}
}
// Shizuku 帮助对话框
if (showShizukuHelpDialog) {
ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false })
}
}
private fun deleteRecord(id: String) {
lifecycleScope.launch {
executionRepository.deleteRecord(id)
executionRecords.value = executionRepository.getAllRecords()
}
}
override fun onDestroy() {
super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener)
Shizuku.removeBinderDeadListener(binderDeadListener)
Shizuku.removeRequestPermissionResultListener(permissionResultListener)
deviceController.unbindService()
}
private fun checkShizukuPermission(): Boolean {
return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
Log.d(TAG, "checkShizukuPermission: $granted")
granted
} catch (e: Exception) {
Log.e(TAG, "checkShizukuPermission error", e)
false
}
}
private fun checkAndUpdateShizukuStatus() {
Log.d(TAG, "checkAndUpdateShizukuStatus called")
try {
val binderAlive = Shizuku.pingBinder()
Log.d(TAG, "Shizuku pingBinder: $binderAlive")
if (binderAlive) {
shizukuAvailable.value = true
val hasPermission = checkShizukuPermission()
Log.d(TAG, "Shizuku hasPermission: $hasPermission")
if (hasPermission) {
Log.d(TAG, "Binding Shizuku service")
deviceController.bindService()
} else {
Log.d(TAG, "Requesting Shizuku permission")
requestShizukuPermission()
}
} else {
Log.d(TAG, "Shizuku binder not alive")
shizukuAvailable.value = false
}
} catch (e: Exception) {
Log.e(TAG, "checkAndUpdateShizukuStatus error", e)
shizukuAvailable.value = false
}
}
private fun refreshShizukuStatus() {
Log.d(TAG, "refreshShizukuStatus called by user")
Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show()
checkAndUpdateShizukuStatus()
if (shizukuAvailable.value && checkShizukuPermission()) {
Toast.makeText(this, "Shizuku 已连接", Toast.LENGTH_SHORT).show()
} else if (shizukuAvailable.value) {
Toast.makeText(this, "请在弹窗中授权 Shizuku", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
}
}
private fun requestShizukuPermission() {
try {
if (!Shizuku.pingBinder()) {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
return
}
if (Shizuku.isPreV11()) {
Toast.makeText(this, "Shizuku 版本过低", Toast.LENGTH_SHORT).show()
return
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show()
shizukuAvailable.value = true
deviceController.bindService()
return
}
Shizuku.requestPermission(0)
} catch (e: Exception) {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
}
}
private fun runAgent(
instruction: String,
apiKey: String,
baseUrl: String,
model: String,
maxSteps: Int,
isGUIAgent: Boolean = false,
providerId: String = ""
) {
if (instruction.isBlank()) {
Toast.makeText(this, "请输入指令", Toast.LENGTH_SHORT).show()
return
}
// MAI-UI 本地部署不需要 API Key
val requiresApiKey = providerId != "mai_ui"
if (requiresApiKey && apiKey.isBlank()) {
Toast.makeText(this, "请输入 API Key", Toast.LENGTH_SHORT).show()
return
}
// 检查悬浮窗权限
if (!Settings.canDrawOverlays(this)) {
Toast.makeText(this, "请授予悬浮窗权限", Toast.LENGTH_LONG).show()
val intent = android.content.Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
startActivity(intent)
return
}
// 立即设置执行状态为 true,显示停止按钮
isExecuting.value = true
// 根据服务商类型创建相应的客户端
if (isGUIAgent) {
// GUI-Owl 模式
val guiOwlClient = GUIOwlClient(
apiKey = apiKey,
model = model.ifBlank { "pre-gui_owl_7b" }
)
mobileAgent.value = MobileAgent(
vlmClient = null,
controller = deviceController,
context = this,
guiOwlClient = guiOwlClient
)
} else if (providerId == "mai_ui") {
// MAI-UI 模式
val maiuiClient = MAIUIClient(
baseUrl = baseUrl.ifBlank { "http://localhost:8000/v1" },
model = model.ifBlank { "MAI-UI-2B" }
)
mobileAgent.value = MobileAgent(
vlmClient = null,
controller = deviceController,
context = this,
maiuiClient = maiuiClient
)
} else {
// OpenAI 兼容模式 (阿里云 Qwen-VL, OpenAI, OpenRouter 等)
val vlmClient = VLMClient(
apiKey = apiKey,
baseUrl = baseUrl.ifBlank { "https://dashscope.aliyuncs.com/compatible-mode/v1" },
model = model.ifBlank { "qwen3-vl-plus" }
)
mobileAgent.value = MobileAgent(vlmClient, deviceController, this)
}
// 设置停止回调,用于取消协程
mobileAgent.value?.onStopRequested = {
currentExecutionJob?.cancel()
currentExecutionJob = null
}
// 创建执行记录
val record = ExecutionRecord(
title = generateTitle(instruction),
instruction = instruction,
startTime = System.currentTimeMillis(),
status = ExecutionStatus.RUNNING
)
// 保存当前记录 ID,用于停止后跳转
currentRecordId.value = record.id
// 取消之前的任务(如果有)
currentExecutionJob?.cancel()
currentExecutionJob = lifecycleScope.launch {
// 保存初始记录
executionRepository.saveRecord(record)
executionRecords.value = executionRepository.getAllRecords()
try {
val result = mobileAgent.value!!.runInstruction(instruction, maxSteps)
// 更新记录状态
val agentState = mobileAgent.value?.state?.value
val steps = agentState?.executionSteps ?: emptyList()
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = if (result.success) ExecutionStatus.COMPLETED else ExecutionStatus.FAILED,
steps = steps,
logs = currentLogs,
resultMessage = result.message
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
// 重置执行状态
isExecuting.value = false
// 延迟3秒后清空日志,恢复默认状态
kotlinx.coroutines.delay(3000)
mobileAgent.value?.clearLogs()
} catch (e: kotlinx.coroutines.CancellationException) {
// 用户取消任务 - 使用 NonCancellable 确保清理操作完成
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
val agentState = mobileAgent.value?.state?.value
val steps = agentState?.executionSteps ?: emptyList()
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
println("[MainActivity] 取消任务 - steps: ${steps.size}, logs: ${currentLogs.size}")
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = ExecutionStatus.STOPPED,
steps = steps,
logs = currentLogs,
resultMessage = "已取消"
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
// 重置执行状态
isExecuting.value = false
Toast.makeText(this@MainActivity, "任务已停止", Toast.LENGTH_SHORT).show()
mobileAgent.value?.clearLogs()
// 触发跳转到记录详情页
shouldNavigateToRecord.value = true
}
} catch (e: Exception) {
// 更新失败记录
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = ExecutionStatus.FAILED,
logs = currentLogs,
resultMessage = "错误: ${e.message}"
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
// 重置执行状态
isExecuting.value = false
Toast.makeText(this@MainActivity, "错误: ${e.message}", Toast.LENGTH_LONG).show()
// 延迟3秒后清空日志,恢复默认状态
kotlinx.coroutines.delay(3000)
mobileAgent.value?.clearLogs()
}
}
}
private fun generateTitle(instruction: String): String {
// 生成简短标题
val keywords = listOf(
"打开" to "打开应用",
"点" to "点餐",
"发" to "发送消息",
"看" to "浏览内容",
"搜" to "搜索",
"设置" to "调整设置",
"播放" to "播放媒体"
)
for ((key, title) in keywords) {
if (instruction.contains(key)) {
return title
}
}
return if (instruction.length > 10) instruction.take(10) + "..." else instruction
}
}
@@ -0,0 +1,78 @@
package com.roubao.autopilot.agent
/**
* ActionReflector Agent - 反思动作是否成功
*/
class ActionReflector {
/**
* 生成反思 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent verifying whether the last action produced the expected behavior.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Progress Status ###\n")
if (infoPool.completedPlan.isNotEmpty()) {
append("${infoPool.completedPlan}\n\n")
} else {
append("No progress yet.\n\n")
}
append("---\n")
append("The two attached images are phone screenshots taken BEFORE and AFTER your last action.\n\n")
append("### Latest Action ###\n")
append("Action: ${infoPool.lastAction}\n")
append("Expectation: ${infoPool.lastSummary}\n\n")
append("---\n")
append("Carefully examine whether the last action produced the expected behavior.\n\n")
append("Note: For swiping to scroll, if the content before and after is exactly the same, ")
append("the swipe is considered Failed (C) - the page may have reached the bottom.\n\n")
append("Provide your output in the following format:\n\n")
append("### Outcome ###\n")
append("Choose from:\n")
append("A: Successful. The result meets the expectation.\n")
append("B: Failed. The action resulted in a wrong page. Need to return to previous state.\n")
append("C: Failed. The action produced no changes.\n\n")
append("### Error Description ###\n")
append("If failed, describe the error. If successful, put \"None\".\n")
}
/**
* 解析反思响应
*/
fun parseResponse(response: String): ReflectorResult {
val outcomeSection = response
.substringAfter("### Outcome", "")
.substringBefore("### Error Description")
.replace("###", "")
.trim()
val outcome = when {
outcomeSection.contains("A") -> "A"
outcomeSection.contains("B") -> "B"
outcomeSection.contains("C") -> "C"
else -> "C"
}
val errorDescription = response
.substringAfter("### Error Description", "")
.replace("###", "")
.trim()
return ReflectorResult(outcome, errorDescription)
}
}
data class ReflectorResult(
val outcome: String, // A, B, C
val errorDescription: String
)
@@ -0,0 +1,163 @@
package com.roubao.autopilot.agent
import android.graphics.Bitmap
import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
/**
* 对话记忆管理 - 保存完整对话历史,图片用完即删
*
* 参考 Open-AutoGLM 的设计:
* - 保持完整对话历史,让模型看到之前所有操作
* - 图片用完即删,节省 token
*/
class ConversationMemory {
private val messages = mutableListOf<Message>()
/**
* 消息类型
*/
data class Message(
val role: String, // "system", "user", "assistant"
val textContent: String,
var imageBase64: String? = null // 图片用完后置为 null
)
/**
* 添加系统消息(通常只在开始时添加一次)
*/
fun addSystemMessage(text: String) {
messages.add(Message(role = "system", textContent = text))
}
/**
* 添加用户消息(带截图)
*/
fun addUserMessage(text: String, image: Bitmap? = null) {
val imageBase64 = image?.let { bitmapToBase64(it) }
messages.add(Message(role = "user", textContent = text, imageBase64 = imageBase64))
}
/**
* 添加助手消息
*/
fun addAssistantMessage(text: String) {
messages.add(Message(role = "assistant", textContent = text))
}
/**
* 删除最后一条用户消息中的图片(节省 token)
* 在获取模型响应后调用
*/
fun stripLastUserImage() {
for (i in messages.indices.reversed()) {
if (messages[i].role == "user" && messages[i].imageBase64 != null) {
messages[i].imageBase64 = null
println("[ConversationMemory] 已删除第 $i 条消息的图片")
break
}
}
}
/**
* 获取最近 N 条消息(不包括系统消息)
*/
fun getRecentMessages(count: Int): List<Message> {
val nonSystemMessages = messages.filter { it.role != "system" }
return nonSystemMessages.takeLast(count)
}
/**
* 构建 OpenAI 兼容的 messages JSON
* @param includeImages 是否包含图片(最后一条用户消息的图片)
*/
fun toMessagesJson(includeImages: Boolean = true): JSONArray {
val jsonArray = JSONArray()
for ((index, msg) in messages.withIndex()) {
val msgJson = JSONObject()
msgJson.put("role", msg.role)
// 判断是否需要包含图片
val shouldIncludeImage = includeImages &&
msg.imageBase64 != null &&
index == messages.indexOfLast { it.role == "user" }
if (shouldIncludeImage && msg.imageBase64 != null) {
// 多模态消息格式
val contentArray = JSONArray()
contentArray.put(JSONObject().apply {
put("type", "text")
put("text", msg.textContent)
})
contentArray.put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,${msg.imageBase64}")
})
})
msgJson.put("content", contentArray)
} else {
// 纯文本消息
msgJson.put("content", msg.textContent)
}
jsonArray.put(msgJson)
}
return jsonArray
}
/**
* 获取消息数量
*/
fun size(): Int = messages.size
/**
* 清空所有消息
*/
fun clear() {
messages.clear()
}
/**
* 获取 token 估算(粗略)
* 中文约 2 字符/token,英文约 4 字符/token
*/
fun estimateTokens(): Int {
var total = 0
for (msg in messages) {
// 文本 token
total += msg.textContent.length / 3
// 图片 token(约 1000 token 每张)
if (msg.imageBase64 != null) {
total += 1000
}
}
return total
}
/**
* Bitmap 转 Base64
*/
private fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
companion object {
/**
* 创建带系统提示的记忆
*/
fun withSystemPrompt(systemPrompt: String): ConversationMemory {
return ConversationMemory().apply {
addSystemMessage(systemPrompt)
}
}
}
}
@@ -0,0 +1,194 @@
package com.roubao.autopilot.agent
/**
* Executor Agent - 决定具体执行什么动作
*/
class Executor {
companion object {
val GUIDELINES = """
General:
- For any pop-up window, close it (e.g., by clicking 'Don't Allow' or 'Accept') before proceeding.
- For requests that are questions, remember to use the `answer` action to reply before finish!
- If the desired state is already achieved, you can just complete the task.
Action Related:
- Use `open_app` to open an app, do not use the app drawer.
- Consider using `swipe` to reveal additional content.
- If swiping doesn't change the page, it may have reached the bottom.
Text Related:
- To input text: first click the input box, make sure keyboard is visible, then use `type` action.
- To clear text: long press the backspace button in the keyboard.
""".trimIndent()
}
/**
* 生成执行 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone. ")
append("Decide the next action based on the current state.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Overall Plan ###\n")
append("${infoPool.plan}\n\n")
append("### Current Subgoal ###\n")
val subgoals = infoPool.plan.split(Regex("(?<=\\d)\\. ")).take(3)
append("${subgoals.joinToString(". ")}\n\n")
append("### Progress Status ###\n")
if (infoPool.progressStatus.isNotEmpty()) {
append("${infoPool.progressStatus}\n\n")
} else {
append("No progress yet.\n\n")
}
append("### Guidelines ###\n")
append("$GUIDELINES\n\n")
append("---\n")
append("Examine all information and decide on the next action.\n\n")
append("#### Atomic Actions ####\n")
append("- click(coordinate): Click at (x, y). Example: {\"action\": \"click\", \"coordinate\": [x, y]}\n")
append("- double_tap(coordinate): Double tap at (x, y) for zoom or like. Example: {\"action\": \"double_tap\", \"coordinate\": [x, y]}\n")
append("- long_press(coordinate): Long press at (x, y). Example: {\"action\": \"long_press\", \"coordinate\": [x, y]}\n")
append("- type(text): Type text into activated input box. Example: {\"action\": \"type\", \"text\": \"hello\"}\n")
append("- swipe(coordinate, coordinate2): Swipe from point1 to point2. Example: {\"action\": \"swipe\", \"coordinate\": [x1, y1], \"coordinate2\": [x2, y2]}\n")
append("- system_button(button): Press Back/Home/Enter. Example: {\"action\": \"system_button\", \"button\": \"Back\"}\n")
append("- open_app(text): Open an app by name. ALWAYS use this instead of looking for app icons on screen! Example: {\"action\": \"open_app\", \"text\": \"设置\"}\n")
if (infoPool.installedApps.isNotEmpty()) {
append(" Available apps: ${infoPool.installedApps}\n")
}
append("- wait(duration): Wait for page loading. Duration in seconds (1-10). Example: {\"action\": \"wait\", \"duration\": 3}\n")
append("- take_over(message): Request user to manually complete login/captcha/verification. Example: {\"action\": \"take_over\", \"message\": \"请完成登录验证\"}\n")
append("- answer(text): Answer user's question. Example: {\"action\": \"answer\", \"text\": \"The answer is...\"}\n")
append("\n")
append("#### Sensitive Operations ####\n")
append("For payment, password, or privacy-related actions, add 'message' field to request user confirmation:\n")
append("Example: {\"action\": \"click\", \"coordinate\": [500, 800], \"message\": \"确认支付 ¥100\"}\n")
append("The user will see a confirmation dialog and can choose to confirm or cancel.\n")
append("\n")
append("### Latest Action History ###\n")
if (infoPool.actionHistory.isNotEmpty()) {
val numActions = minOf(5, infoPool.actionHistory.size)
val latestActions = infoPool.actionHistory.takeLast(numActions)
val latestSummaries = infoPool.summaryHistory.takeLast(numActions)
val latestOutcomes = infoPool.actionOutcomes.takeLast(numActions)
val latestErrors = infoPool.errorDescriptions.takeLast(numActions)
latestActions.forEachIndexed { i, act ->
val outcome = latestOutcomes.getOrNull(i) ?: "?"
if (outcome == "A") {
append("- Action: $act | Description: ${latestSummaries.getOrNull(i)} | Outcome: Successful\n")
} else {
append("- Action: $act | Description: ${latestSummaries.getOrNull(i)} | Outcome: Failed | Error: ${latestErrors.getOrNull(i)}\n")
}
}
} else {
append("No actions have been taken yet.\n")
}
append("\n")
append("---\n")
append("IMPORTANT:\n")
append("1. Do NOT repeat previously failed actions. Try a different approach.\n")
append("2. Prioritize the current subgoal.\n")
append("3. Always analyze the screen BEFORE deciding on an action.\n\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("1. **Screen**: What app/page is currently shown? Is it the expected page for the current subgoal?\n")
append("2. **Blocker**: Any popup, dialog, keyboard, or loading state that needs to be handled first?\n")
append("3. **Target**: Is the target element visible? If not, should I scroll or navigate?\n")
append("4. **Decision**: Based on the above analysis, what action should I take and why?\n\n")
append("### Action ###\n")
append("A valid JSON specifying the action. Example: {\"action\":\"click\", \"coordinate\": [500, 800]}\n\n")
append("### Description ###\n")
append("A brief description of the chosen action.\n")
}
/**
* 解析执行响应
* 支持两种格式:
* 1. 标准格式 (### Thought / ### Action / ### Description)
* 2. MAI-UI 格式 (<thinking>...</thinking><tool_call>...</tool_call>)
*/
fun parseResponse(response: String): ExecutorResult {
// 检测是否为 MAI-UI 格式
if (response.contains("<tool_call>") || response.contains("<thinking>")) {
return parseMAIUIResponse(response)
}
// 标准格式解析
val thought = response
.substringAfter("### Thought", "")
.substringBefore("### Action")
.replace("###", "")
.trim()
val actionStr = response
.substringAfter("### Action", "")
.substringBefore("### Description")
.replace("###", "")
.replace("```json", "")
.replace("```", "")
.trim()
val description = response
.substringAfter("### Description", "")
.replace("###", "")
.trim()
val action = Action.fromJson(actionStr)
return ExecutorResult(thought, action, actionStr, description)
}
/**
* 解析 MAI-UI 格式响应
* 格式: <thinking>...</thinking><tool_call>{"name": "mobile_use", "arguments": {...}}</tool_call>
*/
private fun parseMAIUIResponse(response: String): ExecutorResult {
val thought = Action.extractThinking(response)
val action = Action.fromMAIUIFormat(response)
// 从 action 生成描述
val description = when (action?.type) {
"click" -> "点击坐标 (${action.x}, ${action.y})"
"long_press" -> "长按坐标 (${action.x}, ${action.y})"
"double_tap" -> "双击坐标 (${action.x}, ${action.y})"
"swipe" -> {
if (action.direction != null) {
"向${action.direction}滑动"
} else {
"从 (${action.x}, ${action.y}) 滑动到 (${action.x2}, ${action.y2})"
}
}
"type" -> "输入文字: ${action.text}"
"open_app" -> "打开应用: ${action.text}"
"system_button" -> "按${action.button}键"
"wait" -> "等待"
"terminate" -> "任务${if (action.status == "success") "完成" else "失败"}"
"answer" -> "回答: ${action.text?.take(30)}"
"take_over" -> "请求用户: ${action.text}"
else -> action?.type ?: "未知动作"
}
val actionStr = action?.toJson() ?: ""
return ExecutorResult(thought, action, actionStr, description)
}
}
data class ExecutorResult(
val thought: String,
val action: Action?,
val actionStr: String,
val description: String
)
@@ -0,0 +1,226 @@
package com.roubao.autopilot.agent
import org.json.JSONObject
/**
* Agent 状态池 - 保存所有执行过程中的信息
*/
data class InfoPool(
// 用户指令
var instruction: String = "",
// 规划相关
var plan: String = "",
var completedPlan: String = "",
var progressStatus: String = "",
var currentSubgoal: String = "",
// 动作历史
val actionHistory: MutableList<Action> = mutableListOf(),
val summaryHistory: MutableList<String> = mutableListOf(),
val actionOutcomes: MutableList<String> = mutableListOf(), // A=成功, B=错误页面, C=无变化
val errorDescriptions: MutableList<String> = mutableListOf(),
// 最近一次动作
var lastAction: Action? = null,
var lastActionThought: String = "",
var lastSummary: String = "",
// 笔记
var importantNotes: String = "",
// 错误处理
var errorFlagPlan: Boolean = false,
val errToManagerThresh: Int = 2,
// 屏幕尺寸
var screenWidth: Int = 1080,
var screenHeight: Int = 2400,
// 额外知识
var additionalKnowledge: String = "",
// Skill 上下文(从 SkillManager 获取的相关技能信息)
var skillContext: String = "",
// 对话记忆(保存完整对话历史,用于 Executor)
var executorMemory: ConversationMemory? = null,
// 已安装应用列表(用于 open_app 动作)
var installedApps: String = ""
)
/**
* 动作定义
* 支持的动作类型:
* - click, double_tap, long_press: 点击类操作
* - swipe, drag: 滑动类操作
* - type: 输入文字
* - system_button: 系统按键 (Back, Home, Enter)
* - open_app, open: 打开应用
* - answer: 回答问题
* - wait: 等待
* - take_over, ask_user: 人机交互
* - terminate: 任务结束
*/
data class Action(
val type: String,
val x: Int? = null,
val y: Int? = null,
val x2: Int? = null,
val y2: Int? = null,
val text: String? = null,
val button: String? = null, // Back, Home, Enter, menu
val duration: Int? = null, // wait 动作的等待时长(秒)
val message: String? = null, // take_over/ask_user 动作的提示消息
val needConfirm: Boolean = false,
val direction: String? = null, // swipe 方向: up, down, left, right
val status: String? = null // terminate 状态: success, fail
) {
companion object {
private const val SCALE_FACTOR = 999 // MAI-UI 坐标缩放因子
/**
* 从 JSON 字符串解析 Action
* 支持两种格式:
* 1. 标准格式: {"action": "click", "coordinate": [x, y]}
* 2. MAI-UI 格式: <tool_call>{"name": "mobile_use", "arguments": {...}}</tool_call>
*/
fun fromJson(json: String): Action? {
val cleanJson = json.trim()
.replace("```json", "")
.replace("```", "")
.trim()
// 检查是否是 MAI-UI 的 <tool_call> 格式
if (cleanJson.contains("<tool_call>")) {
return fromMAIUIFormat(cleanJson)
}
return fromStandardJson(cleanJson)
}
/**
* 解析标准 JSON 格式
*/
private fun fromStandardJson(json: String): Action? {
return try {
val obj = JSONObject(json)
val type = obj.optString("action", "")
Action(
type = type,
x = obj.optJSONArray("coordinate")?.optInt(0),
y = obj.optJSONArray("coordinate")?.optInt(1),
x2 = obj.optJSONArray("coordinate2")?.optInt(0),
y2 = obj.optJSONArray("coordinate2")?.optInt(1),
text = obj.optString("text", null),
button = obj.optString("button", null),
duration = if (obj.has("duration")) obj.optInt("duration", 3) else null,
message = obj.optString("message", null),
needConfirm = obj.optBoolean("need_confirm", false),
direction = obj.optString("direction", null),
status = obj.optString("status", null)
)
} catch (e: Exception) {
null
}
}
/**
* 解析 MAI-UI 的 <tool_call> 格式
* 格式: <tool_call>{"name": "mobile_use", "arguments": {"action": "click", "coordinate": [x, y]}}</tool_call>
*/
fun fromMAIUIFormat(response: String): Action? {
return try {
// 提取 <tool_call> 内容
val toolCallRegex = Regex("<tool_call>\\s*(.+?)\\s*</tool_call>", RegexOption.DOT_MATCHES_ALL)
val match = toolCallRegex.find(response) ?: return null
val toolCallJson = match.groupValues[1].trim()
val obj = JSONObject(toolCallJson)
val arguments = obj.optJSONObject("arguments") ?: return null
val type = arguments.optString("action", "")
// MAI-UI 坐标是 0-999 归一化的,需要标记(在执行时处理)
val coordinate = arguments.optJSONArray("coordinate")
var x = coordinate?.optInt(0)
var y = coordinate?.optInt(1)
// MAI-UI drag 动作使用 start_coordinate 和 end_coordinate
val startCoord = arguments.optJSONArray("start_coordinate")
val endCoord = arguments.optJSONArray("end_coordinate")
var x2: Int? = null
var y2: Int? = null
if (startCoord != null && endCoord != null) {
x = startCoord.optInt(0)
y = startCoord.optInt(1)
x2 = endCoord.optInt(0)
y2 = endCoord.optInt(1)
}
// 映射 MAI-UI 的动作类型到我们的类型
val mappedType = when (type) {
"open" -> "open_app"
"double_click" -> "double_tap"
"drag" -> "swipe" // drag 和 swipe 在执行层面相同
"ask_user" -> "take_over"
else -> type
}
Action(
type = mappedType,
x = x,
y = y,
x2 = x2,
y2 = y2,
text = arguments.optString("text", null),
button = arguments.optString("button", null),
direction = arguments.optString("direction", null),
status = arguments.optString("status", null)
)
} catch (e: Exception) {
println("[Action] MAI-UI 格式解析失败: ${e.message}")
null
}
}
/**
* 从 MAI-UI 响应中提取思考过程
*/
fun extractThinking(response: String): String {
val thinkingRegex = Regex("<thinking>\\s*(.+?)\\s*</thinking>", RegexOption.DOT_MATCHES_ALL)
val match = thinkingRegex.find(response)
return match?.groupValues?.get(1)?.trim() ?: ""
}
}
fun toJson(): String {
val obj = JSONObject()
obj.put("action", type)
if (x != null && y != null) {
val coord = org.json.JSONArray()
coord.put(x)
coord.put(y)
obj.put("coordinate", coord)
}
if (x2 != null && y2 != null) {
val coord2 = org.json.JSONArray()
coord2.put(x2)
coord2.put(y2)
obj.put("coordinate2", coord2)
}
text?.let { obj.put("text", it) }
button?.let { obj.put("button", it) }
duration?.let { obj.put("duration", it) }
message?.let { obj.put("message", it) }
if (needConfirm) obj.put("need_confirm", true)
return obj.toString()
}
override fun toString(): String = toJson()
}
@@ -0,0 +1,178 @@
package com.roubao.autopilot.agent
/**
* Manager Agent - 负责规划和进度管理
*/
class Manager {
/**
* 生成规划 Prompt
*/
fun getPrompt(infoPool: InfoPool): String {
return if (infoPool.plan.isEmpty()) {
getInitialPlanPrompt(infoPool)
} else {
getUpdatePlanPrompt(infoPool)
}
}
private fun getInitialPlanPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone on behalf of a user. ")
append("Your goal is to track progress and devise high-level plans to achieve the user's requests.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("---\n")
append("Make a high-level plan to achieve the user's request. ")
append("If the request is complex, break it down into subgoals. ")
append("The screenshot displays the starting state of the phone.\n\n")
append("IMPORTANT: For requests that explicitly require an answer, ")
append("always add 'perform the `answer` action' as the last step to the plan!\n\n")
// Skill 上下文
if (infoPool.skillContext.isNotEmpty()) {
append("### Available Skills ###\n")
append("${infoPool.skillContext}\n\n")
}
append("### Guidelines ###\n")
append("1. IMPORTANT: If you see the \"肉包\" or \"Baozi\" app interface (this automation tool), press Home button first to go back to the home screen, then proceed with the task.\n")
append("2. ALWAYS use `open_app` action to open apps - NEVER go to home screen to look for app icons! The open_app action can launch any installed app directly.\n")
append("3. Use search to quickly find a file or entry with a specific name.\n")
append("4. If there are relevant skills listed above, follow their suggested steps for better efficiency.\n")
if (infoPool.additionalKnowledge.isNotEmpty()) {
append("5. ${infoPool.additionalKnowledge}\n")
}
append("\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("A detailed explanation of your rationale for the plan.\n\n")
append("### Plan ###\n")
append("1. first subgoal\n")
append("2. second subgoal\n")
append("...\n")
}
private fun getUpdatePlanPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone on behalf of a user. ")
append("Your goal is to track progress and update plans.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
if (infoPool.completedPlan.isNotEmpty() && infoPool.completedPlan != "No completed subgoal.") {
append("### Historical Operations ###\n")
append("${infoPool.completedPlan}\n\n")
}
append("### Current Plan ###\n")
append("${infoPool.plan}\n\n")
append("### Last Action ###\n")
append("${infoPool.lastAction}\n\n")
append("### Last Action Description ###\n")
append("${infoPool.lastSummary}\n\n")
// 最近的动作结果
if (infoPool.actionOutcomes.isNotEmpty()) {
val recentOutcomes = infoPool.actionOutcomes.takeLast(3)
val failCount = recentOutcomes.count { it in listOf("B", "C") }
if (failCount > 0) {
append("### Recent Action Results ###\n")
append("Last ${recentOutcomes.size} actions: ${recentOutcomes.joinToString(", ")} ")
append("(A=success, B=partial, C=failed)\n")
append("Failed attempts: $failCount\n\n")
}
}
if (infoPool.importantNotes.isNotEmpty()) {
append("### Important Notes ###\n")
append("${infoPool.importantNotes}\n\n")
}
// 错误升级
if (infoPool.errorFlagPlan) {
append("### ⚠️ STUCK - Multiple Failed Attempts! ###\n")
append("You have encountered several consecutive failed attempts:\n")
val k = infoPool.errToManagerThresh
val recentActions = infoPool.actionHistory.takeLast(k)
val recentSummaries = infoPool.summaryHistory.takeLast(k)
val recentErrors = infoPool.errorDescriptions.takeLast(k)
recentActions.forEachIndexed { i, act ->
append("- Action: $act | Description: ${recentSummaries.getOrNull(i)} | Failed: ${recentErrors.getOrNull(i)}\n")
}
append("\nIMPORTANT: DO NOT mark as \"Finished\" when there are failed attempts! ")
append("Try a different approach:\n")
append("- Wait for page to load (the UI might be loading)\n")
append("- Try clicking at different coordinates\n")
append("- Scroll to find the correct button\n")
append("- Press Back and try again\n\n")
}
append("---\n")
append("Assess the current status.\n\n")
append("### ⛔ SECURITY: Sensitive Pages - MUST STOP ###\n")
append("ONLY output \"STOP_SENSITIVE\" when the screen is ACTIVELY REQUESTING one of these:\n")
append("- A payment confirmation button that will charge money (确认支付, 立即付款, 确认付款)\n")
append("- A password input field that is focused and waiting for input\n")
append("- Face ID or fingerprint verification dialog\n")
append("DO NOT stop for: price displays, payment method selection, cart pages, or general app navigation.\n\n")
append("### CRITICAL: When to mark \"Finished\" ###\n")
append("ONLY mark as \"Finished\" when ALL of these are true:\n")
append("1. The user's request has been FULLY completed (not partially)\n")
append("2. You can SEE the final success state in the screenshot (e.g., order confirmed, message sent, setting changed)\n")
append("3. The last action was SUCCESSFUL (outcome A), not failed (outcome C)\n")
append("4. There are NO recent consecutive failures\n\n")
append("If any action failed or the task is incomplete, DO NOT say \"Finished\". Instead, update the plan with a new approach.\n\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("Your rationale for the updated plan.\n\n")
append("### Historical Operations ###\n")
append("Add newly completed subgoals on top of existing ones.\n\n")
append("### Plan ###\n")
append("Updated plan or \"Finished\" if truly done.\n")
}
/**
* 解析规划响应
*/
fun parseResponse(response: String): PlanResult {
val thought = response
.substringAfter("### Thought", "")
.substringBefore("### Historical Operations")
.substringBefore("### Plan")
.replace("###", "")
.trim()
val completedSubgoal = if (response.contains("### Historical Operations")) {
response
.substringAfter("### Historical Operations")
.substringBefore("### Plan")
.replace("###", "")
.trim()
} else {
"No completed subgoal."
}
val plan = response
.substringAfter("### Plan")
.replace("###", "")
.trim()
return PlanResult(thought, completedSubgoal, plan)
}
}
data class PlanResult(
val thought: String,
val completedSubgoal: String,
val plan: String
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
package com.roubao.autopilot.agent
/**
* Notetaker Agent - 记录重要信息
*/
class Notetaker {
/**
* 生成笔记 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are a helpful AI assistant for operating mobile phones. ")
append("Your goal is to take notes of important content relevant to the user's request.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Progress Status ###\n")
append("${infoPool.progressStatus}\n\n")
append("### Existing Important Notes ###\n")
if (infoPool.importantNotes.isNotEmpty()) {
append("${infoPool.importantNotes}\n\n")
} else {
append("No important notes recorded.\n\n")
}
append("---\n")
append("Examine the current screen to identify any important content that needs to be recorded.\n\n")
append("IMPORTANT:\n")
append("- Do not take notes on low-level actions\n")
append("- Only keep track of significant textual or visual information relevant to the request\n")
append("- Do not repeat user request or progress status\n")
append("- Do not make up content that you are not sure about\n\n")
append("Provide your output in the following format:\n\n")
append("### Important Notes ###\n")
append("The updated important notes, combining old and new ones. ")
append("If nothing new to record, copy the existing important notes.\n")
}
/**
* 解析笔记响应
*/
fun parseResponse(response: String): String {
return response
.substringAfter("### Important Notes", "")
.replace("###", "")
.trim()
}
}
@@ -0,0 +1,435 @@
package com.roubao.autopilot.controller
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* App 扫描器 - 获取所有已安装应用信息
* 支持:预扫描缓存、拼音匹配、分类、语义搜索
*/
class AppScanner(private val context: Context) {
companion object {
private const val CACHE_FILE = "installed_apps.json"
// 内存缓存 (应用生命周期内有效)
@Volatile
private var cachedApps: List<AppInfo>? = null
// 预编译的正则表达式(避免重复创建)
private val PINYIN_CLEAN_REGEX = Regex("[^a-z0-9\\u4e00-\\u9fa5]")
// 应用分类关键词映射
private val CATEGORY_KEYWORDS = mapOf(
"社交" to listOf("微信", "QQ", "钉钉", "飞书", "Telegram", "WhatsApp", "Line", "微博", "陌陌", "探探"),
"购物" to listOf("淘宝", "京东", "拼多多", "天猫", "苏宁", "唯品会", "得物", "闲鱼", "当当"),
"外卖" to listOf("美团", "饿了么", "肯德基", "麦当劳", "星巴克", "瑞幸", "小美"),
"出行" to listOf("滴滴", "高德", "百度地图", "腾讯地图", "嘀嗒", "哈啰", "曹操", "T3", "花小猪"),
"地图" to listOf("高德", "百度地图", "腾讯地图", "谷歌地图", "导航"),
"音乐" to listOf("网易云", "QQ音乐", "酷狗", "酷我", "Spotify", "Apple Music", "虾米"),
"视频" to listOf("抖音", "快手", "B站", "bilibili", "优酷", "爱奇艺", "腾讯视频", "芒果TV", "西瓜视频"),
"支付" to listOf("支付宝", "微信", "云闪付", "翼支付"),
"笔记" to listOf("印象笔记", "有道云", "Notion", "备忘录", "便签", "笔记", "记事", "OneNote", "滴答"),
"相机" to listOf("相机", "Camera", "拍照", "美颜", "美图", "轻颜"),
"图片" to listOf("相册", "图库", "Photos", "Gallery", "照片"),
"浏览器" to listOf("Chrome", "Safari", "Firefox", "Edge", "浏览器", "UC", "夸克", "Via"),
"办公" to listOf("WPS", "Office", "钉钉", "飞书", "企业微信", "Slack", "Teams"),
"AI" to listOf("ChatGPT", "Claude", "豆包", "文心", "通义", "讯飞", "Copilot", "即梦", "Midjourney"),
"工具" to listOf("计算器", "手电筒", "指南针", "时钟", "闹钟", "日历", "天气", "文件管理"),
"阅读" to listOf("微信读书", "Kindle", "掌阅", "番茄小说", "起点", "知乎", "今日头条"),
"游戏" to listOf("王者荣耀", "和平精英", "原神", "崩坏", "阴阳师", "游戏")
)
// 拼音映射表 (常用应用)
private val PINYIN_MAP = mapOf(
// 社交
"weixin" to "微信", "wechat" to "微信", "wx" to "微信",
"qq" to "QQ",
"dingding" to "钉钉", "dingtalk" to "钉钉",
"feishu" to "飞书", "lark" to "飞书",
"weibo" to "微博",
// 购物
"taobao" to "淘宝", "tb" to "淘宝",
"jingdong" to "京东", "jd" to "京东",
"pinduoduo" to "拼多多", "pdd" to "拼多多",
"xianyu" to "闲鱼",
// 外卖/餐饮
"meituan" to "美团", "mt" to "美团",
"eleme" to "饿了么", "elm" to "饿了么",
"xiaomei" to "小美",
"kfc" to "肯德基", "kendeji" to "肯德基",
"maidanglao" to "麦当劳", "mcdonald" to "麦当劳",
"starbucks" to "星巴克", "xingbake" to "星巴克",
"ruixing" to "瑞幸", "luckin" to "瑞幸",
// 出行/地图
"didi" to "滴滴", "dd" to "滴滴",
"gaode" to "高德", "amap" to "高德",
"baidu" to "百度", "baidumap" to "百度地图",
"ditu" to "地图",
"daohang" to "导航",
// 支付
"zhifubao" to "支付宝", "alipay" to "支付宝", "zfb" to "支付宝",
// 音乐
"wangyiyun" to "网易云", "netease" to "网易云",
"qqmusic" to "QQ音乐", "qqyinyue" to "QQ音乐",
"kugou" to "酷狗",
"yinyue" to "音乐", "music" to "音乐",
// 视频
"douyin" to "抖音", "tiktok" to "抖音", "dy" to "抖音",
"kuaishou" to "快手", "ks" to "快手",
"bilibili" to "哔哩哔哩", "bzhan" to "哔哩哔哩", "b站" to "哔哩哔哩",
"youku" to "优酷",
"iqiyi" to "爱奇艺", "aiqiyi" to "爱奇艺",
"shipin" to "视频", "video" to "视频",
// 笔记/办公
"biji" to "笔记", "note" to "笔记", "notes" to "笔记",
"beiwanglu" to "备忘录", "memo" to "备忘录",
"bianjian" to "便签",
"wps" to "WPS",
// 系统
"shezhi" to "设置", "settings" to "设置",
"xiangji" to "相机", "camera" to "相机", "paizhao" to "相机",
"xiangce" to "相册", "photos" to "相册", "gallery" to "相册", "tuku" to "图库",
"dianhua" to "电话", "phone" to "电话",
"duanxin" to "短信", "message" to "短信", "sms" to "短信",
"liulanqi" to "浏览器", "browser" to "浏览器",
"jisuanqi" to "计算器", "calculator" to "计算器",
"shizhong" to "时钟", "clock" to "时钟",
"naozhong" to "闹钟", "alarm" to "闹钟",
"rili" to "日历", "calendar" to "日历",
"tianqi" to "天气", "weather" to "天气",
"wenjian" to "文件", "file" to "文件"
)
// 语义查询映射 (用户可能说的自然语言)
private val SEMANTIC_MAP = mapOf(
// 功能描述 -> 分类
"拍照" to "相机", "照相" to "相机", "自拍" to "相机", "拍摄" to "相机",
"看照片" to "图片", "看图" to "图片", "图片" to "图片",
"聊天" to "社交", "发消息" to "社交", "通讯" to "社交",
"买东西" to "购物", "购物" to "购物", "网购" to "购物", "下单" to "购物",
"点餐" to "外卖", "叫外卖" to "外卖", "点外卖" to "外卖", "吃饭" to "外卖", "订餐" to "外卖",
"打车" to "出行", "叫车" to "出行", "出行" to "出行", "坐车" to "出行",
"导航" to "地图", "找路" to "地图", "去哪" to "地图", "怎么走" to "地图",
"听歌" to "音乐", "听音乐" to "音乐", "放歌" to "音乐", "播放音乐" to "音乐",
"看视频" to "视频", "刷视频" to "视频", "追剧" to "视频", "看电影" to "视频", "看剧" to "视频",
"付款" to "支付", "支付" to "支付", "扫码" to "支付", "收款" to "支付",
"记笔记" to "笔记", "记事" to "笔记", "记录" to "笔记", "写笔记" to "笔记",
"上网" to "浏览器", "搜索" to "浏览器", "查资料" to "浏览器",
"办公" to "办公", "工作" to "办公", "文档" to "办公",
"画图" to "AI", "生成图片" to "AI", "AI画图" to "AI", "AI" to "AI",
"看书" to "阅读", "阅读" to "阅读", "读书" to "阅读", "看小说" to "阅读",
"玩游戏" to "游戏", "游戏" to "游戏"
)
}
/**
* 应用信息
*/
data class AppInfo(
val packageName: String,
val appName: String,
val pinyin: String, // 拼音(自动生成)
val category: String?, // 分类
val isSystem: Boolean,
val keywords: List<String> // 关键词(用于搜索)
)
/**
* 搜索结果
*/
data class SearchResult(
val app: AppInfo,
val score: Float, // 匹配分数 0-1
val matchType: String // 匹配类型:exact/contains/pinyin/category/semantic
)
/**
* 获取应用列表 (优先内存 -> 文件 -> 扫描)
*/
fun getApps(): List<AppInfo> {
cachedApps?.let { return it }
val cacheFile = File(context.filesDir, CACHE_FILE)
if (cacheFile.exists()) {
val loaded = loadFromFile(cacheFile)
if (loaded.isNotEmpty()) {
cachedApps = loaded
println("[AppScanner] 从文件加载 ${loaded.size} 个应用")
return loaded
}
}
return refreshApps()
}
/**
* 强制刷新应用列表
*/
fun refreshApps(): List<AppInfo> {
println("[AppScanner] 扫描已安装应用...")
val apps = scanAllApps()
cachedApps = apps
val cacheFile = File(context.filesDir, CACHE_FILE)
saveToFile(apps, cacheFile)
println("[AppScanner] 已缓存 ${apps.size} 个应用")
return apps
}
/**
* 扫描所有已安装应用
*/
private fun scanAllApps(): List<AppInfo> {
val pm = context.packageManager
val apps = mutableListOf<AppInfo>()
try {
// 使用 0 作为 flag,获取所有应用(不过滤)
val packages = pm.getInstalledApplications(0)
for (appInfo in packages) {
val appName = pm.getApplicationLabel(appInfo).toString()
val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
val pinyin = toPinyin(appName)
val category = detectCategory(appName, appInfo.packageName)
val keywords = generateKeywords(appName, appInfo.packageName, category)
apps.add(AppInfo(
packageName = appInfo.packageName,
appName = appName,
pinyin = pinyin,
category = category,
isSystem = isSystem,
keywords = keywords
))
}
} catch (e: Exception) {
e.printStackTrace()
}
return apps.sortedBy { it.appName }
}
/**
* 智能搜索应用
* @param query 搜索词(支持:应用名、拼音、分类、语义描述)
* @param topK 返回前 K 个结果
* @param includeSystem 是否包含系统应用
*/
fun searchApps(query: String, topK: Int = 5, includeSystem: Boolean = true): List<SearchResult> {
val apps = getApps()
val lowerQuery = query.lowercase().trim()
val results = mutableListOf<SearchResult>()
// 先检查是否是语义查询,转换为分类
val semanticCategory = SEMANTIC_MAP[lowerQuery]
val pinyinMapped = PINYIN_MAP[lowerQuery]
for (app in apps) {
if (!includeSystem && app.isSystem) continue
var score = 0f
var matchType = ""
// 1. 精确匹配应用名 (最高优先级)
if (app.appName.equals(query, ignoreCase = true)) {
score = 1.0f
matchType = "exact"
}
// 2. 拼音映射精确匹配
else if (pinyinMapped != null && app.appName.contains(pinyinMapped)) {
score = 0.95f
matchType = "pinyin_exact"
}
// 3. 应用名包含查询词
else if (app.appName.lowercase().contains(lowerQuery)) {
score = 0.9f
matchType = "contains"
}
// 4. 拼音包含
else if (app.pinyin.contains(lowerQuery)) {
score = 0.8f
matchType = "pinyin"
}
// 5. 关键词匹配
else if (app.keywords.any { it.contains(lowerQuery) || lowerQuery.contains(it) }) {
score = 0.7f
matchType = "keyword"
}
// 6. 分类匹配(语义查询)
else if (semanticCategory != null && app.category == semanticCategory) {
score = 0.6f
matchType = "semantic"
}
// 7. 包名包含
else if (app.packageName.lowercase().contains(lowerQuery)) {
score = 0.5f
matchType = "package"
}
if (score > 0) {
// 非系统应用加分
if (!app.isSystem) score += 0.05f
results.add(SearchResult(app, score.coerceAtMost(1f), matchType))
}
}
return results
.sortedByDescending { it.score }
.take(topK)
}
/**
* 根据名称模糊搜索包名 (兼容旧接口)
*/
fun findPackage(query: String): String? {
val results = searchApps(query, topK = 1)
return results.firstOrNull()?.app?.packageName
}
/**
* 按分类获取应用
*/
fun getAppsByCategory(category: String): List<AppInfo> {
return getApps().filter { it.category == category }
}
/**
* 获取所有分类
*/
fun getAllCategories(): List<String> {
return getApps().mapNotNull { it.category }.distinct().sorted()
}
/**
* 格式化搜索结果给 LLM
*/
fun formatSearchResultsForLLM(results: List<SearchResult>): String {
if (results.isEmpty()) return "未找到匹配的应用"
return buildString {
append("找到以下应用,请选择最合适的:\n")
results.forEachIndexed { index, result ->
val app = result.app
val categoryStr = app.category?.let { " [$it]" } ?: ""
append("${index + 1}. ${app.appName}$categoryStr (${app.packageName})\n")
}
}
}
// ========== 辅助方法 ==========
/**
* 简单拼音转换(仅处理常见中文字符)
*/
private fun toPinyin(text: String): String {
// 简单实现:保留英文数字,中文用首字母拼音
// 完整拼音需要引入 pinyin4j 库,这里用简化版本
return text.lowercase()
.replace(PINYIN_CLEAN_REGEX, "")
}
/**
* 检测应用分类
*/
private fun detectCategory(appName: String, packageName: String): String? {
val lowerName = appName.lowercase()
val lowerPackage = packageName.lowercase()
for ((category, keywords) in CATEGORY_KEYWORDS) {
for (keyword in keywords) {
if (lowerName.contains(keyword.lowercase()) ||
lowerPackage.contains(keyword.lowercase())) {
return category
}
}
}
return null
}
/**
* 生成搜索关键词
*/
private fun generateKeywords(appName: String, packageName: String, category: String?): List<String> {
val keywords = mutableListOf<String>()
// 从包名提取关键词
val packageParts = packageName.split(".")
keywords.addAll(packageParts.filter { it.length > 2 })
// 添加分类
category?.let { keywords.add(it) }
// 从拼音映射反向添加
for ((pinyin, name) in PINYIN_MAP) {
if (appName.contains(name)) {
keywords.add(pinyin)
}
}
return keywords.distinct()
}
// ========== 缓存相关 ==========
private fun saveToFile(apps: List<AppInfo>, file: File) {
try {
val jsonArray = JSONArray()
for (app in apps) {
val obj = JSONObject()
obj.put("package", app.packageName)
obj.put("name", app.appName)
obj.put("pinyin", app.pinyin)
obj.put("category", app.category ?: "")
obj.put("system", app.isSystem)
obj.put("keywords", JSONArray(app.keywords))
jsonArray.put(obj)
}
file.writeText(jsonArray.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun loadFromFile(file: File): List<AppInfo> {
val apps = mutableListOf<AppInfo>()
try {
val jsonArray = JSONArray(file.readText())
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val keywordsArray = obj.optJSONArray("keywords")
val keywords = mutableListOf<String>()
if (keywordsArray != null) {
for (j in 0 until keywordsArray.length()) {
keywords.add(keywordsArray.getString(j))
}
}
apps.add(AppInfo(
packageName = obj.getString("package"),
appName = obj.getString("name"),
pinyin = obj.optString("pinyin", ""),
category = obj.optString("category", null)?.takeIf { it.isNotEmpty() },
isSystem = obj.optBoolean("system", false),
keywords = keywords
))
}
} catch (e: Exception) {
e.printStackTrace()
}
return apps
}
}
@@ -0,0 +1,552 @@
package com.roubao.autopilot.controller
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.roubao.autopilot.App
import com.roubao.autopilot.IShellService
import com.roubao.autopilot.service.ShellService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import rikka.shizuku.Shizuku
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 设备控制器 - 通过 Shizuku UserService 执行 shell 命令
*/
class DeviceController(private val context: Context? = null) {
companion object {
// 使用 /data/local/tmp,shell 用户有权限访问
private const val SCREENSHOT_PATH = "/data/local/tmp/autopilot_screen.png"
}
private var shellService: IShellService? = null
private var serviceBound = false
private val mainHandler = Handler(Looper.getMainLooper())
private val clipboardManager: ClipboardManager? by lazy {
context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
}
private val userServiceArgs = Shizuku.UserServiceArgs(
ComponentName(
"com.roubao.autopilot",
ShellService::class.java.name
)
)
.daemon(false)
.processNameSuffix("shell")
.debuggable(true)
.version(1)
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
shellService = IShellService.Stub.asInterface(service)
serviceBound = true
println("[DeviceController] ShellService connected")
}
override fun onServiceDisconnected(name: ComponentName?) {
shellService = null
serviceBound = false
println("[DeviceController] ShellService disconnected")
}
}
/**
* 绑定 Shizuku UserService
*/
fun bindService() {
if (!isShizukuAvailable()) {
println("[DeviceController] Shizuku not available")
return
}
try {
Shizuku.bindUserService(userServiceArgs, serviceConnection)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 解绑服务
*/
fun unbindService() {
try {
Shizuku.unbindUserService(userServiceArgs, serviceConnection, true)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 检查 Shizuku 是否可用
*/
fun isShizukuAvailable(): Boolean {
return try {
Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED
} catch (e: Exception) {
false
}
}
/**
* 检查服务是否可用
*/
fun isAvailable(): Boolean {
return serviceBound && shellService != null
}
/**
* Shizuku 权限级别
*/
enum class ShizukuPrivilegeLevel {
NONE, // 未连接
ADB, // ADB 模式 (UID 2000)
ROOT // Root 模式 (UID 0)
}
/**
* 获取当前 Shizuku 权限级别
* UID 0 = root, UID 2000 = shell (ADB)
*/
fun getShizukuPrivilegeLevel(): ShizukuPrivilegeLevel {
if (!isAvailable()) {
return ShizukuPrivilegeLevel.NONE
}
return try {
val uid = Shizuku.getUid()
println("[DeviceController] Shizuku UID: $uid")
when (uid) {
0 -> ShizukuPrivilegeLevel.ROOT
else -> ShizukuPrivilegeLevel.ADB
}
} catch (e: Exception) {
e.printStackTrace()
ShizukuPrivilegeLevel.NONE
}
}
/**
* 执行 shell 命令 (本地,无权限)
*/
private fun execLocal(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = reader.readText()
process.waitFor()
reader.close()
output
} catch (e: Exception) {
e.printStackTrace()
""
}
}
/**
* 执行 shell 命令 (通过 Shizuku)
*/
private fun exec(command: String): String {
return try {
shellService?.exec(command) ?: execLocal(command)
} catch (e: Exception) {
e.printStackTrace()
execLocal(command)
}
}
/**
* 点击屏幕
*/
fun tap(x: Int, y: Int) {
exec("input tap $x $y")
}
/**
* 长按
*/
fun longPress(x: Int, y: Int, durationMs: Int = 1000) {
exec("input swipe $x $y $x $y $durationMs")
}
/**
* 双击
*/
fun doubleTap(x: Int, y: Int) {
exec("input tap $x $y && input tap $x $y")
}
/**
* 滑动
*/
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int = 500) {
exec("input swipe $x1 $y1 $x2 $y2 $durationMs")
}
/**
* 输入文本 (使用剪贴板方式,支持中文)
*/
fun type(text: String) {
// 检查是否包含非 ASCII 字符
val hasNonAscii = text.any { it.code > 127 }
if (hasNonAscii) {
// 中文等使用剪贴板方式
typeViaClipboard(text)
} else {
// 纯英文数字使用 input text
val escaped = text.replace("'", "'\\''")
exec("input text '$escaped'")
}
}
/**
* 通过剪贴板方式输入中文
* 使用 Android ClipboardManager API 设置剪贴板,然后发送粘贴按键
*/
private fun typeViaClipboard(text: String) {
println("[DeviceController] 尝试输入中文: $text")
// 方法1: 使用 Android 剪贴板 API + 粘贴 (最可靠,不需要额外 App)
if (clipboardManager != null) {
try {
// 使用 CountDownLatch 等待剪贴板设置完成
val latch = CountDownLatch(1)
var clipboardSet = false
// 必须在主线程操作剪贴板
mainHandler.post {
try {
val clip = ClipData.newPlainText("baozi_input", text)
clipboardManager?.setPrimaryClip(clip)
clipboardSet = true
println("[DeviceController] ✅ 已设置剪贴板: $text")
} catch (e: Exception) {
println("[DeviceController] ❌ 设置剪贴板异常: ${e.message}")
} finally {
latch.countDown()
}
}
// 等待剪贴板设置完成 (最多等 1 秒)
val success = latch.await(1, TimeUnit.SECONDS)
if (!success) {
println("[DeviceController] ❌ 等待剪贴板超时")
return
}
if (!clipboardSet) {
println("[DeviceController] ❌ 剪贴板设置失败")
return
}
// 稍等一下确保剪贴板生效
Thread.sleep(200)
// 发送粘贴按键 (KEYCODE_PASTE = 279)
exec("input keyevent 279")
println("[DeviceController] ✅ 已发送粘贴按键")
return
} catch (e: Exception) {
println("[DeviceController] ❌ 剪贴板方式失败: ${e.message}")
e.printStackTrace()
}
} else {
println("[DeviceController] ❌ ClipboardManager 为 null,Context 未设置")
}
// 方法2: 使用 ADB Keyboard 广播 (备选,需要安装 ADBKeyboard)
val escaped = text.replace("\"", "\\\"")
val adbKeyboardResult = exec("am broadcast -a ADB_INPUT_TEXT --es msg \"$escaped\"")
println("[DeviceController] ADBKeyboard 广播结果: $adbKeyboardResult")
if (adbKeyboardResult.contains("result=0")) {
println("[DeviceController] ✅ ADBKeyboard 输入成功")
return
}
// 方法3: 使用 cmd input text (Android 12+ 可能支持 UTF-8)
println("[DeviceController] 尝试 cmd input text...")
exec("cmd input text '$text'")
}
/**
* 输入文本 (逐字符,兼容性更好)
*/
fun typeCharByChar(text: String) {
text.forEach { char ->
when {
char == ' ' -> exec("input text %s")
char == '\n' -> exec("input keyevent 66")
char.isLetterOrDigit() && char.code <= 127 -> exec("input text $char")
char in "-.,!?@'/:;()" -> exec("input text \"$char\"")
else -> {
// 非 ASCII 字符使用广播
exec("am broadcast -a ADB_INPUT_TEXT --es msg \"$char\"")
}
}
}
}
/**
* 返回键
*/
fun back() {
exec("input keyevent 4")
}
/**
* Home 键
*/
fun home() {
exec("input keyevent 3")
}
/**
* 回车键
*/
fun enter() {
exec("input keyevent 66")
}
private var cacheDir: File? = null
fun setCacheDir(dir: File) {
cacheDir = dir
}
/**
* 截图结果
*/
data class ScreenshotResult(
val bitmap: Bitmap,
val isSensitive: Boolean = false, // 是否是敏感页面(截图失败)
val isFallback: Boolean = false // 是否是降级的黑屏占位图
)
/**
* 截图 - 使用 /data/local/tmp 并设置全局可读权限
* 失败时返回黑屏占位图(降级处理)
*/
suspend fun screenshotWithFallback(): ScreenshotResult = withContext(Dispatchers.IO) {
try {
// 截图到 /data/local/tmp 并设置权限让 App 可读
val output = exec("screencap -p $SCREENSHOT_PATH && chmod 666 $SCREENSHOT_PATH")
delay(500)
// 检查是否截图失败(敏感页面保护)
if (output.contains("Status: -1") || output.contains("Failed") || output.contains("error")) {
println("[DeviceController] Screenshot blocked (sensitive screen), returning fallback")
return@withContext createFallbackScreenshot(isSensitive = true)
}
// 尝试直接读取
val file = File(SCREENSHOT_PATH)
if (file.exists() && file.canRead() && file.length() > 0) {
println("[DeviceController] Reading screenshot from: $SCREENSHOT_PATH, size: ${file.length()}")
val bitmap = BitmapFactory.decodeFile(SCREENSHOT_PATH)
if (bitmap != null) {
return@withContext ScreenshotResult(bitmap)
}
}
// 如果无法直接读取,通过 shell cat 读取二进制数据
println("[DeviceController] Cannot read directly, trying shell cat...")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $SCREENSHOT_PATH"))
val bytes = process.inputStream.readBytes()
process.waitFor()
if (bytes.isNotEmpty()) {
println("[DeviceController] Read ${bytes.size} bytes via shell")
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap != null) {
return@withContext ScreenshotResult(bitmap)
}
}
println("[DeviceController] Screenshot file empty or not accessible, returning fallback")
createFallbackScreenshot(isSensitive = false)
} catch (e: Exception) {
e.printStackTrace()
println("[DeviceController] Screenshot exception, returning fallback")
createFallbackScreenshot(isSensitive = false)
}
}
/**
* 创建黑屏占位图(降级处理)
*/
private fun createFallbackScreenshot(isSensitive: Boolean): ScreenshotResult {
val (width, height) = getScreenSize()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// 默认是黑色,无需填充
return ScreenshotResult(
bitmap = bitmap,
isSensitive = isSensitive,
isFallback = true
)
}
/**
* 截图 - 使用 /data/local/tmp 并设置全局可读权限
* @deprecated 使用 screenshotWithFallback() 代替
*/
suspend fun screenshot(): Bitmap? = withContext(Dispatchers.IO) {
try {
// 截图到 /data/local/tmp 并设置权限让 App 可读
exec("screencap -p $SCREENSHOT_PATH && chmod 666 $SCREENSHOT_PATH")
delay(500)
// 尝试直接读取
val file = File(SCREENSHOT_PATH)
if (file.exists() && file.canRead() && file.length() > 0) {
println("[DeviceController] Reading screenshot from: $SCREENSHOT_PATH, size: ${file.length()}")
return@withContext BitmapFactory.decodeFile(SCREENSHOT_PATH)
}
// 如果无法直接读取,通过 shell cat 读取二进制数据
println("[DeviceController] Cannot read directly, trying shell cat...")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $SCREENSHOT_PATH"))
val bytes = process.inputStream.readBytes()
process.waitFor()
if (bytes.isNotEmpty()) {
println("[DeviceController] Read ${bytes.size} bytes via shell")
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} else {
println("[DeviceController] Screenshot file empty or not accessible")
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* 获取屏幕尺寸(考虑屏幕方向)
*/
fun getScreenSize(): Pair<Int, Int> {
val output = exec("wm size")
// 输出格式: Physical size: 1080x2400
val match = Regex("(\\d+)x(\\d+)").find(output)
val (physicalWidth, physicalHeight) = if (match != null) {
val (w, h) = match.destructured
Pair(w.toInt(), h.toInt())
} else {
Pair(1080, 2400)
}
// 检测屏幕方向
val orientation = getScreenOrientation()
return if (orientation == 1 || orientation == 3) {
// 横屏:交换宽高
Pair(physicalHeight, physicalWidth)
} else {
// 竖屏
Pair(physicalWidth, physicalHeight)
}
}
/**
* 获取屏幕方向
* @return 0=竖屏, 1=横屏(90°), 2=倒置竖屏, 3=横屏(270°)
*/
private fun getScreenOrientation(): Int {
val output = exec("dumpsys window displays | grep mCurrentOrientation")
// 输出格式: mCurrentOrientation=0 或 mCurrentOrientation=1
val match = Regex("mCurrentOrientation=(\\d)").find(output)
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
/**
* 打开 App - 支持包名或应用名
*/
fun openApp(appNameOrPackage: String) {
// 常见应用名到包名的映射 (作为备选)
val packageMap = mapOf(
"settings" to "com.android.settings",
"设置" to "com.android.settings",
"chrome" to "com.android.chrome",
"浏览器" to "com.android.browser",
"camera" to "com.android.camera",
"相机" to "com.android.camera",
"phone" to "com.android.dialer",
"电话" to "com.android.dialer",
"contacts" to "com.android.contacts",
"联系人" to "com.android.contacts",
"messages" to "com.android.mms",
"短信" to "com.android.mms",
"gallery" to "com.android.gallery3d",
"相册" to "com.android.gallery3d",
"clock" to "com.android.deskclock",
"时钟" to "com.android.deskclock",
"calculator" to "com.android.calculator2",
"计算器" to "com.android.calculator2",
"calendar" to "com.android.calendar",
"日历" to "com.android.calendar",
"files" to "com.android.documentsui",
"文件" to "com.android.documentsui"
)
val lowerName = appNameOrPackage.lowercase().trim()
val finalPackage: String
if (appNameOrPackage.contains(".")) {
// 已经是包名格式
finalPackage = appNameOrPackage
} else if (packageMap.containsKey(lowerName)) {
// 从内置映射中查找
finalPackage = packageMap[lowerName]!!
} else {
// 使用 AppScanner 搜索应用
val appScanner = App.getInstance().appScanner
val searchResults = appScanner.searchApps(appNameOrPackage, topK = 1)
if (searchResults.isNotEmpty()) {
finalPackage = searchResults[0].app.packageName
println("[DeviceController] AppScanner found: ${searchResults[0].app.appName} -> $finalPackage")
} else {
// 找不到,直接用原始输入尝试
finalPackage = appNameOrPackage
println("[DeviceController] App not found in AppScanner: $appNameOrPackage")
}
}
// 使用 monkey 命令启动应用 (最可靠)
val result = exec("monkey -p $finalPackage -c android.intent.category.LAUNCHER 1 2>/dev/null")
println("[DeviceController] openApp: $appNameOrPackage -> $finalPackage, result: $result")
}
/**
* 通过 Intent 打开
*/
fun openIntent(action: String, data: String? = null) {
val cmd = buildString {
append("am start -a $action")
if (data != null) {
append(" -d \"$data\"")
}
}
exec(cmd)
}
/**
* 打开 DeepLink
*/
fun openDeepLink(uri: String) {
exec("am start -a android.intent.action.VIEW -d \"$uri\"")
}
}
@@ -0,0 +1,209 @@
package com.roubao.autopilot.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* 执行步骤记录
*/
data class ExecutionStep(
val stepNumber: Int,
val timestamp: Long,
val action: String,
val description: String,
val thought: String,
val outcome: String, // A=成功, B=部分成功, C=失败
val screenshotPath: String? = null
) {
fun toJson(): JSONObject = JSONObject().apply {
put("stepNumber", stepNumber)
put("timestamp", timestamp)
put("action", action)
put("description", description)
put("thought", thought)
put("outcome", outcome)
put("screenshotPath", screenshotPath ?: "")
}
companion object {
fun fromJson(json: JSONObject): ExecutionStep = ExecutionStep(
stepNumber = json.optInt("stepNumber", 0),
timestamp = json.optLong("timestamp", 0),
action = json.optString("action", ""),
description = json.optString("description", ""),
thought = json.optString("thought", ""),
outcome = json.optString("outcome", ""),
screenshotPath = json.optString("screenshotPath", "").ifEmpty { null }
)
}
}
/**
* 执行记录
*/
data class ExecutionRecord(
val id: String = UUID.randomUUID().toString(),
val title: String,
val instruction: String,
val startTime: Long,
val endTime: Long = 0,
val status: ExecutionStatus = ExecutionStatus.RUNNING,
val steps: List<ExecutionStep> = emptyList(),
val logs: List<String> = emptyList(),
val resultMessage: String = ""
) {
val duration: Long get() = if (endTime > 0) endTime - startTime else System.currentTimeMillis() - startTime
val formattedStartTime: String get() {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return sdf.format(Date(startTime))
}
val formattedDuration: String get() {
val seconds = duration / 1000
return if (seconds < 60) "${seconds}秒" else "${seconds / 60}分${seconds % 60}秒"
}
fun toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("title", title)
put("instruction", instruction)
put("startTime", startTime)
put("endTime", endTime)
put("status", status.name)
put("resultMessage", resultMessage)
put("steps", JSONArray().apply {
steps.forEach { put(it.toJson()) }
})
put("logs", JSONArray().apply {
logs.forEach { put(it) }
})
}
companion object {
fun fromJson(json: JSONObject): ExecutionRecord {
val stepsArray = json.optJSONArray("steps") ?: JSONArray()
val steps = mutableListOf<ExecutionStep>()
for (i in 0 until stepsArray.length()) {
steps.add(ExecutionStep.fromJson(stepsArray.getJSONObject(i)))
}
val logsArray = json.optJSONArray("logs") ?: JSONArray()
val logs = mutableListOf<String>()
for (i in 0 until logsArray.length()) {
logs.add(logsArray.optString(i, ""))
}
return ExecutionRecord(
id = json.optString("id", UUID.randomUUID().toString()),
title = json.optString("title", ""),
instruction = json.optString("instruction", ""),
startTime = json.optLong("startTime", 0),
endTime = json.optLong("endTime", 0),
status = try {
ExecutionStatus.valueOf(json.optString("status", "COMPLETED"))
} catch (e: Exception) {
ExecutionStatus.COMPLETED
},
steps = steps,
logs = logs,
resultMessage = json.optString("resultMessage", "")
)
}
}
}
enum class ExecutionStatus {
RUNNING,
COMPLETED,
FAILED,
STOPPED
}
/**
* 执行记录仓库
*/
class ExecutionRepository(private val context: Context) {
private val historyFile: File
get() = File(context.filesDir, "execution_history.json")
/**
* 获取所有记录
*/
suspend fun getAllRecords(): List<ExecutionRecord> = withContext(Dispatchers.IO) {
try {
if (!historyFile.exists()) return@withContext emptyList()
val json = historyFile.readText()
val array = JSONArray(json)
val records = mutableListOf<ExecutionRecord>()
for (i in 0 until array.length()) {
records.add(ExecutionRecord.fromJson(array.getJSONObject(i)))
}
records.sortedByDescending { it.startTime }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/**
* 获取单条记录
*/
suspend fun getRecord(id: String): ExecutionRecord? = withContext(Dispatchers.IO) {
getAllRecords().find { it.id == id }
}
/**
* 保存记录
*/
suspend fun saveRecord(record: ExecutionRecord) = withContext(Dispatchers.IO) {
try {
val records = getAllRecords().toMutableList()
val existingIndex = records.indexOfFirst { it.id == record.id }
if (existingIndex >= 0) {
records[existingIndex] = record
} else {
records.add(0, record)
}
// 只保留最近100条记录
val trimmedRecords = records.take(100)
val array = JSONArray().apply {
trimmedRecords.forEach { put(it.toJson()) }
}
historyFile.writeText(array.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 删除记录
*/
suspend fun deleteRecord(id: String) = withContext(Dispatchers.IO) {
try {
val records = getAllRecords().filter { it.id != id }
val array = JSONArray().apply {
records.forEach { put(it.toJson()) }
}
historyFile.writeText(array.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 清空所有记录
*/
suspend fun clearAll() = withContext(Dispatchers.IO) {
try {
historyFile.delete()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,352 @@
package com.roubao.autopilot.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.roubao.autopilot.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* API 提供商配置
*/
data class ApiProvider(
val id: String,
val name: String,
val baseUrl: String,
val defaultModel: String,
val isGUIAgent: Boolean = false // 是否为 GUI Agent 专用协议(非 OpenAI 兼容)
) {
companion object {
val GUI_OWL = ApiProvider(
id = "gui_owl",
name = "GUI-Owl (阿里云)",
baseUrl = "https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server",
defaultModel = "pre-gui_owl_7b",
isGUIAgent = true
)
val MAI_UI = ApiProvider(
id = "mai_ui",
name = "MAI-UI (本地部署)",
baseUrl = "http://localhost:8000/v1", // vLLM 默认地址
defaultModel = "MAI-UI-2B" // 支持 MAI-UI-2B 或 MAI-UI-8B
)
val ALIYUN = ApiProvider(
id = "aliyun",
name = "阿里云 (Qwen-VL)",
baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultModel = "qwen3-vl-plus"
)
val OPENAI = ApiProvider(
id = "openai",
name = "OpenAI",
baseUrl = "https://api.openai.com/v1",
defaultModel = "gpt-4o"
)
val OPENROUTER = ApiProvider(
id = "openrouter",
name = "OpenRouter",
baseUrl = "https://openrouter.ai/api/v1",
defaultModel = "anthropic/claude-3.5-sonnet"
)
val CUSTOM = ApiProvider(
id = "custom",
name = "自定义",
baseUrl = "",
defaultModel = ""
)
val ALL = listOf(GUI_OWL, MAI_UI, ALIYUN, OPENAI, OPENROUTER, CUSTOM)
}
}
/**
* 服务商配置(每个服务商独立保存)
*/
data class ProviderConfig(
val apiKey: String = "",
val model: String = "",
val cachedModels: List<String> = emptyList(),
val customBaseUrl: String = "" // 仅 custom 服务商使用
)
/**
* 默认推荐模型
*/
const val DEFAULT_MODEL = "qwen3-vl-plus"
/**
* 应用设置
*/
data class AppSettings(
val currentProviderId: String = ApiProvider.ALIYUN.id, // 当前选中的服务商
val providerConfigs: Map<String, ProviderConfig> = emptyMap(), // 每个服务商的配置
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val hasSeenOnboarding: Boolean = false,
val maxSteps: Int = 25,
val rootModeEnabled: Boolean = false,
val suCommandEnabled: Boolean = false
) {
// 便捷属性:获取当前服务商的配置
val currentConfig: ProviderConfig
get() = providerConfigs[currentProviderId] ?: ProviderConfig()
val currentProvider: ApiProvider
get() = ApiProvider.ALL.find { it.id == currentProviderId } ?: ApiProvider.ALIYUN
val apiKey: String get() = currentConfig.apiKey
val model: String get() = currentConfig.model.ifEmpty { currentProvider.defaultModel }
val cachedModels: List<String> get() = currentConfig.cachedModels
val baseUrl: String
get() = when {
currentProviderId == "custom" -> currentConfig.customBaseUrl
// MAI-UI 支持自定义 URL(用于远程部署)
currentProviderId == "mai_ui" && currentConfig.customBaseUrl.isNotEmpty() -> currentConfig.customBaseUrl
else -> currentProvider.baseUrl
}
}
/**
* 设置管理器
*/
class SettingsManager(context: Context) {
// 普通设置存储
private val prefs: SharedPreferences =
context.getSharedPreferences("baozi_settings", Context.MODE_PRIVATE)
// 加密存储(用于敏感数据如 API Key)
private val securePrefs: SharedPreferences by lazy {
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
// 加密失败时回退到普通存储(不应该发生)
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
prefs
}
}
private val _settings = MutableStateFlow(loadSettings())
val settings: StateFlow<AppSettings> = _settings
init {
// 迁移旧的明文 API Key 到加密存储
migrateApiKeyToSecureStorage()
}
/**
* 迁移旧的明文 API Key 到加密存储
*/
private fun migrateApiKeyToSecureStorage() {
val oldApiKey = prefs.getString("api_key", null)
if (!oldApiKey.isNullOrEmpty()) {
// 保存到加密存储
securePrefs.edit().putString("api_key", oldApiKey).apply()
// 删除旧的明文存储
prefs.edit().remove("api_key").apply()
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
}
}
private fun loadSettings(): AppSettings {
val themeModeStr = prefs.getString("theme_mode", ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name
val themeMode = try {
ThemeMode.valueOf(themeModeStr)
} catch (e: Exception) {
ThemeMode.SYSTEM
}
// 加载当前选中的服务商
val currentProviderId = prefs.getString("current_provider_id", ApiProvider.ALIYUN.id) ?: ApiProvider.ALIYUN.id
// 加载每个服务商的配置
val providerConfigs = mutableMapOf<String, ProviderConfig>()
for (provider in ApiProvider.ALL) {
val config = loadProviderConfig(provider.id)
providerConfigs[provider.id] = config
}
// 迁移旧数据(如果有)
val oldApiKey = securePrefs.getString("api_key", null)
val oldModel = prefs.getString("model", null)
val oldBaseUrl = prefs.getString("base_url", null)
val oldCachedModels = prefs.getStringSet("cached_models", null)
if (oldApiKey != null || oldModel != null) {
// 找到旧数据对应的服务商
val oldProviderId = when (oldBaseUrl) {
ApiProvider.ALIYUN.baseUrl -> ApiProvider.ALIYUN.id
ApiProvider.OPENAI.baseUrl -> ApiProvider.OPENAI.id
ApiProvider.OPENROUTER.baseUrl -> ApiProvider.OPENROUTER.id
else -> "custom"
}
// 迁移到新格式
val migratedConfig = ProviderConfig(
apiKey = oldApiKey ?: "",
model = oldModel ?: "",
cachedModels = oldCachedModels?.toList() ?: emptyList(),
customBaseUrl = if (oldProviderId == "custom") oldBaseUrl ?: "" else ""
)
providerConfigs[oldProviderId] = migratedConfig
saveProviderConfig(oldProviderId, migratedConfig)
// 清除旧数据
securePrefs.edit().remove("api_key").apply()
prefs.edit()
.remove("model")
.remove("base_url")
.remove("cached_models")
.putString("current_provider_id", oldProviderId)
.apply()
android.util.Log.d("SettingsManager", "Migrated old settings to provider: $oldProviderId")
}
return AppSettings(
currentProviderId = currentProviderId,
providerConfigs = providerConfigs,
themeMode = themeMode,
hasSeenOnboarding = prefs.getBoolean("has_seen_onboarding", false),
maxSteps = prefs.getInt("max_steps", 25),
rootModeEnabled = prefs.getBoolean("root_mode_enabled", false),
suCommandEnabled = prefs.getBoolean("su_command_enabled", false)
)
}
/**
* 加载指定服务商的配置
*/
private fun loadProviderConfig(providerId: String): ProviderConfig {
val prefix = "provider_${providerId}_"
return ProviderConfig(
apiKey = securePrefs.getString("${prefix}api_key", "") ?: "",
model = prefs.getString("${prefix}model", "") ?: "",
cachedModels = prefs.getStringSet("${prefix}cached_models", emptySet())?.toList() ?: emptyList(),
customBaseUrl = prefs.getString("${prefix}custom_base_url", "") ?: ""
)
}
/**
* 保存指定服务商的配置
*/
private fun saveProviderConfig(providerId: String, config: ProviderConfig) {
val prefix = "provider_${providerId}_"
securePrefs.edit().putString("${prefix}api_key", config.apiKey).apply()
prefs.edit()
.putString("${prefix}model", config.model)
.putStringSet("${prefix}cached_models", config.cachedModels.toSet())
.putString("${prefix}custom_base_url", config.customBaseUrl)
.apply()
}
/**
* 更新当前服务商的配置
*/
private fun updateCurrentConfig(update: (ProviderConfig) -> ProviderConfig) {
val currentId = _settings.value.currentProviderId
val currentConfig = _settings.value.currentConfig
val newConfig = update(currentConfig)
saveProviderConfig(currentId, newConfig)
val newConfigs = _settings.value.providerConfigs.toMutableMap()
newConfigs[currentId] = newConfig
_settings.value = _settings.value.copy(providerConfigs = newConfigs)
}
fun updateApiKey(apiKey: String) {
updateCurrentConfig { it.copy(apiKey = apiKey) }
}
fun updateBaseUrl(baseUrl: String) {
// 自定义服务商和 MAI-UI 可以修改 URL
val providerId = _settings.value.currentProviderId
if (providerId == "custom" || providerId == "mai_ui") {
updateCurrentConfig { it.copy(customBaseUrl = baseUrl) }
}
}
fun updateModel(model: String) {
updateCurrentConfig { it.copy(model = model) }
}
/**
* 更新缓存的模型列表(从 API 获取后调用)
*/
fun updateCachedModels(models: List<String>) {
val distinctModels = models.distinct()
updateCurrentConfig { it.copy(cachedModels = distinctModels) }
}
/**
* 清空缓存的模型列表
*/
fun clearCachedModels() {
updateCurrentConfig { it.copy(cachedModels = emptyList()) }
}
/**
* 选择服务商(切换时自动加载该服务商的配置)
*/
fun selectProvider(provider: ApiProvider) {
prefs.edit().putString("current_provider_id", provider.id).apply()
_settings.value = _settings.value.copy(currentProviderId = provider.id)
}
/**
* 获取当前服务商
*/
fun getCurrentProvider(): ApiProvider {
return _settings.value.currentProvider
}
/**
* 判断是否使用自定义 URL
*/
fun isCustomUrl(): Boolean {
return _settings.value.currentProviderId == "custom"
}
fun updateThemeMode(themeMode: ThemeMode) {
prefs.edit().putString("theme_mode", themeMode.name).apply()
_settings.value = _settings.value.copy(themeMode = themeMode)
}
fun setOnboardingSeen() {
prefs.edit().putBoolean("has_seen_onboarding", true).apply()
_settings.value = _settings.value.copy(hasSeenOnboarding = true)
}
fun updateMaxSteps(maxSteps: Int) {
val validSteps = maxSteps.coerceIn(5, 100) // 限制范围 5-100
prefs.edit().putInt("max_steps", validSteps).apply()
_settings.value = _settings.value.copy(maxSteps = validSteps)
}
fun updateRootModeEnabled(enabled: Boolean) {
prefs.edit().putBoolean("root_mode_enabled", enabled).apply()
_settings.value = _settings.value.copy(rootModeEnabled = enabled)
// 关闭 Root 模式时,同时关闭 su -c
if (!enabled) {
updateSuCommandEnabled(false)
}
}
fun updateSuCommandEnabled(enabled: Boolean) {
prefs.edit().putBoolean("su_command_enabled", enabled).apply()
_settings.value = _settings.value.copy(suCommandEnabled = enabled)
}
}
@@ -0,0 +1,35 @@
package com.roubao.autopilot.service
import com.roubao.autopilot.IShellService
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.system.exitProcess
/**
* Shizuku UserService - 在 shell/root 权限下执行命令
*/
class ShellService : IShellService.Stub() {
override fun destroy() {
exitProcess(0)
}
override fun exec(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
val output = reader.readText()
val error = errorReader.readText()
process.waitFor()
reader.close()
errorReader.close()
if (output.isNotEmpty()) output else error
} catch (e: Exception) {
"Error: ${e.message}"
}
}
}
@@ -0,0 +1,299 @@
package com.roubao.autopilot.skills
import com.roubao.autopilot.tools.ToolManager
/**
* 执行类型
*/
enum class ExecutionType {
/** 委托:通过 DeepLink 打开 App */
DELEGATION,
/** GUI 自动化:通过截图-操作循环 */
GUI_AUTOMATION
}
/**
* 关联应用配置
*/
data class RelatedApp(
val packageName: String,
val name: String,
val type: ExecutionType,
val deepLink: String? = null,
val steps: List<String>? = null,
val priority: Int = 0,
val description: String? = null
)
/**
* Skill 参数定义
*/
data class SkillParam(
val name: String,
val type: String, // string, int, boolean
val description: String,
val required: Boolean = false,
val defaultValue: Any? = null,
val examples: List<String> = emptyList()
)
/**
* Skill 配置(意图定义)
*/
data class SkillConfig(
val id: String,
val name: String,
val description: String,
val category: String,
val keywords: List<String>,
val params: List<SkillParam>,
val relatedApps: List<RelatedApp>,
val promptHint: String? = null // 提示词约束,如"内容不超过100字"
)
/**
* Skill 执行计划
*
* 根据用户意图和本地已安装 App 生成的执行方案
*/
data class ExecutionPlan(
val skillId: String,
val skillName: String,
val app: RelatedApp,
val params: Map<String, Any?>,
val isInstalled: Boolean,
val promptHint: String? = null // 提示词约束
) {
/**
* 生成给 Agent 的上下文信息
*/
fun toAgentContext(): String {
return buildString {
append("【任务】${skillName}\n")
append("【目标应用】${app.name} (${app.packageName})\n")
append("【执行方式】${if (app.type == ExecutionType.DELEGATION) "快捷跳转" else "GUI 自动化"}\n")
if (!promptHint.isNullOrBlank()) {
append("【重要提示】⚠️ $promptHint\n")
}
if (!app.steps.isNullOrEmpty()) {
append("【操作步骤】\n")
app.steps.forEachIndexed { index, step ->
append(" ${index + 1}. $step\n")
}
}
if (params.isNotEmpty()) {
append("【参数】\n")
params.forEach { (key, value) ->
if (key != "_raw_query" && value != null) {
append(" $key: $value\n")
}
}
}
}
}
}
/**
* Skill 执行结果
*/
sealed class SkillResult {
/**
* 委托成功:已通过 DeepLink 跳转
*/
data class Delegated(
val app: RelatedApp,
val deepLink: String,
val message: String
) : SkillResult()
/**
* GUI 自动化:返回执行计划给 Agent
*/
data class NeedAutomation(
val plan: ExecutionPlan,
val message: String
) : SkillResult()
/**
* 失败
*/
data class Failed(
val error: String,
val suggestion: String? = null
) : SkillResult()
/**
* 无可用应用
*/
data class NoAvailableApp(
val skillName: String,
val requiredApps: List<String>
) : SkillResult()
}
/**
* Skill 意图匹配器
*/
class Skill(val config: SkillConfig) {
/**
* 计算与用户查询的匹配分数
* @return 0-1 之间的分数
*/
fun matchScore(query: String): Float {
val lowerQuery = query.lowercase()
// 精确匹配关键词(最高分)
for (keyword in config.keywords) {
if (lowerQuery.contains(keyword.lowercase())) {
return 0.9f
}
}
// 匹配 Skill 名称
if (lowerQuery.contains(config.name.lowercase())) {
return 0.8f
}
// 模糊匹配描述
val descWords = config.description.split(" ", ",", "、", "/")
val matchedWords = descWords.count { lowerQuery.contains(it.lowercase()) }
if (matchedWords > 0) {
return (0.3f + 0.3f * matchedWords / descWords.size).coerceAtMost(0.7f)
}
return 0f
}
/**
* 从查询中提取参数
*/
fun extractParams(query: String): Map<String, Any?> {
val params = mutableMapOf<String, Any?>()
for (param in config.params) {
when (param.name) {
"food", "item", "song", "book", "keyword" -> {
// 提取关键内容(去掉意图关键词后的部分)
var content = query
for (kw in config.keywords) {
content = content.replace(kw, "", ignoreCase = true)
}
content = content.trim()
if (content.isNotEmpty()) {
params[param.name] = content
}
}
"destination", "address", "location" -> {
// 提取目的地
val patterns = listOf(
"去(.+?)$",
"到(.+?)$",
"导航(.+?)$",
"(.+?)怎么走"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.groupValues[1].trim()
break
}
}
}
"contact" -> {
// 提取联系人
val patterns = listOf(
"给(.+?)发",
"跟(.+?)说",
"告诉(.+?)"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.groupValues[1].trim()
break
}
}
}
"message", "content", "prompt" -> {
// 保存原始查询作为内容
params[param.name] = query
}
"time" -> {
// 提取时间
val patterns = listOf(
"(\\d{1,2}[点::]\\d{0,2})",
"(\\d{1,2}点)",
"(早上|上午|中午|下午|晚上|明天).{0,5}(\\d{1,2}[点::]?\\d{0,2}?)"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.value
break
}
}
}
}
// 设置默认值
if (!params.containsKey(param.name) && param.defaultValue != null) {
params[param.name] = param.defaultValue
}
}
// 保存原始查询
params["_raw_query"] = query
return params
}
/**
* 生成 DeepLink(替换参数)
*/
fun generateDeepLink(app: RelatedApp, params: Map<String, Any?>): String {
var deepLink = app.deepLink ?: return ""
for ((key, value) in params) {
if (value != null && key != "_raw_query") {
deepLink = deepLink.replace("{$key}", value.toString())
}
}
// 清理未替换的占位符
deepLink = deepLink.replace(Regex("\\{[^}]+\\}"), "")
return deepLink
}
}
/**
* Skill 匹配结果
*/
data class SkillMatch(
val skill: Skill,
val score: Float,
val params: Map<String, Any?>
)
/**
* 可用应用匹配结果
*/
data class AvailableAppMatch(
val skill: Skill,
val app: RelatedApp,
val params: Map<String, Any?>,
val score: Float
)
/**
* LLM 意图匹配结果
*/
data class LLMIntentMatch(
val skillId: String,
val confidence: Float,
val reasoning: String
)
@@ -0,0 +1,503 @@
package com.roubao.autopilot.skills
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.vlm.VLMClient
import org.json.JSONObject
/**
* Skill 管理器
*
* 作为 Skill 层的统一入口,负责:
* - 初始化和加载 Skills
* - 意图识别和 Skill 匹配(使用 LLM 语义理解)
* - 基于已安装 App 选择最佳执行方案
* - Skill 执行调度
*/
class SkillManager private constructor(
private val context: Context,
private val toolManager: ToolManager,
private val appScanner: AppScanner
) {
private val registry: SkillRegistry = SkillRegistry.init(context, appScanner)
// VLM 客户端(用于意图匹配)
private var vlmClient: VLMClient? = null
/**
* 设置 VLM 客户端(用于 LLM 意图匹配)
*/
fun setVLMClient(client: VLMClient) {
this.vlmClient = client
}
/**
* 初始化:加载 Skills 配置
*/
fun initialize() {
val loadedCount = registry.loadFromAssets("skills.json")
println("[SkillManager] 已加载 $loadedCount 个 Skills")
}
/**
* 刷新已安装应用列表
*/
fun refreshInstalledApps() {
registry.refreshInstalledApps()
}
/**
* 处理用户意图(新方法:返回最佳可用应用)
*
* @param query 用户输入
* @return 可用应用匹配结果,如果没有则返回 null
*/
fun matchAvailableApp(query: String): AvailableAppMatch? {
return registry.getBestAvailableApp(query, minScore = 0.3f)
}
/**
* 获取所有匹配的可用应用
*/
fun matchAllAvailableApps(query: String): List<AvailableAppMatch> {
return registry.matchAvailableApps(query, minScore = 0.2f)
}
/**
* 使用 LLM 进行意图匹配(异步方法)
*
* @param query 用户输入
* @return 匹配的 Skill ID,如果没有匹配返回 null
*/
suspend fun matchIntentWithLLM(query: String): LLMIntentMatch? {
val client = vlmClient ?: return null
// 构建 Skills 列表描述
val skillsInfo = buildString {
append("可用技能列表:\n")
for (skill in registry.getAll()) {
val config = skill.config
// 只展示有已安装应用的 Skill
val installedApps = config.relatedApps.filter { registry.isAppInstalled(it.packageName) }
if (installedApps.isNotEmpty()) {
append("- ID: ${config.id}\n")
append(" 名称: ${config.name}\n")
append(" 描述: ${config.description}\n")
append(" 关键词: ${config.keywords.joinToString(", ")}\n")
append(" 可用应用: ${installedApps.joinToString(", ") { it.name }}\n\n")
}
}
}
val prompt = """你是一个意图识别助手。根据用户输入,判断最匹配的技能。
$skillsInfo
用户输入: "$query"
请分析用户意图,返回 JSON 格式:
{
"skill_id": "匹配的技能ID,如果没有匹配返回 null",
"confidence": 0.0-1.0 的置信度,
"reasoning": "简短的匹配理由"
}
注意:
1. 只返回 JSON,不要有其他文字
2. 如果用户意图明确匹配某个技能,即使措辞不同也要识别
3. 如果确实没有匹配的技能,skill_id 返回 null
4. 例如"点个汉堡"、"帮我点外卖"、"想吃炸鸡" 都应该匹配 order_food
5. "附近好吃的"、"推荐美食" 应该匹配 find_food"""
return try {
val result = client.predict(prompt)
result.getOrNull()?.let { response ->
parseIntentResponse(response)
}
} catch (e: Exception) {
println("[SkillManager] LLM 意图匹配失败: ${e.message}")
null
}
}
/**
* 解析 LLM 返回的意图匹配结果
*/
private fun parseIntentResponse(response: String): LLMIntentMatch? {
return try {
// 提取 JSON(可能被 markdown 包裹)
val jsonStr = response
.replace("```json", "")
.replace("```", "")
.trim()
val json = JSONObject(jsonStr)
val skillId = json.optString("skill_id", null)?.takeIf { it != "null" && it.isNotEmpty() }
val confidence = json.optDouble("confidence", 0.0).toFloat()
val reasoning = json.optString("reasoning", "")
if (skillId != null) {
LLMIntentMatch(
skillId = skillId,
confidence = confidence,
reasoning = reasoning
)
} else {
null
}
} catch (e: Exception) {
println("[SkillManager] 解析意图响应失败: ${e.message}")
null
}
}
/**
* 使用 LLM 匹配意图并返回可用应用(组合方法)
*/
suspend fun matchAvailableAppWithLLM(query: String): AvailableAppMatch? {
// 先尝试 LLM 匹配
val llmMatch = matchIntentWithLLM(query)
if (llmMatch != null && llmMatch.confidence >= 0.5f) {
println("[SkillManager] LLM 匹配: ${llmMatch.skillId} (置信度: ${llmMatch.confidence})")
println("[SkillManager] 理由: ${llmMatch.reasoning}")
// 获取对应的 Skill 和已安装应用
val skill = registry.get(llmMatch.skillId)
if (skill != null) {
println("[SkillManager] 找到 Skill: ${skill.config.name}")
println("[SkillManager] 关联应用: ${skill.config.relatedApps.map { "${it.name}(${it.packageName})" }}")
// 检查每个应用的安装状态
for (app in skill.config.relatedApps) {
val installed = registry.isAppInstalled(app.packageName)
println("[SkillManager] ${app.name}(${app.packageName}): ${if (installed) "已安装" else "未安装"}")
}
val availableApp = skill.config.relatedApps
.filter { registry.isAppInstalled(it.packageName) }
.maxByOrNull { it.priority }
if (availableApp != null) {
println("[SkillManager] 选中应用: ${availableApp.name}")
val params = skill.extractParams(query)
return AvailableAppMatch(
skill = skill,
app = availableApp,
params = params,
score = llmMatch.confidence
)
} else {
println("[SkillManager] 没有可用应用(都未安装)")
}
} else {
println("[SkillManager] 未找到 Skill: ${llmMatch.skillId}")
}
}
// 如果 LLM 匹配失败,回退到关键词匹配
println("[SkillManager] LLM 未匹配或无可用应用,回退到关键词匹配")
return matchAvailableApp(query)
}
/**
* 生成给 Agent 的上下文提示(使用 LLM 匹配)
*/
suspend fun generateAgentContextWithLLM(query: String): String {
// 使用 LLM 匹配
val match = matchAvailableAppWithLLM(query)
if (match == null) {
return "未找到相关技能或可用应用,请使用通用 GUI 自动化完成任务。"
}
return buildString {
val config = match.skill.config
val app = match.app
append("根据用户意图,已匹配到技能:\n\n")
append("【${config.name}】(置信度: ${(match.score * 100).toInt()}%)\n")
append("描述: ${config.description}\n\n")
// 显示提示词约束(如小红书100字限制)
if (!config.promptHint.isNullOrBlank()) {
append("⚠️ 重要提示: ${config.promptHint}\n\n")
}
val typeLabel = when (app.type) {
ExecutionType.DELEGATION -> "🚀委托(快速)"
ExecutionType.GUI_AUTOMATION -> "🤖GUI自动化"
}
append("推荐应用: ${app.name} $typeLabel\n")
if (app.type == ExecutionType.DELEGATION && app.deepLink != null) {
append("DeepLink: ${app.deepLink}\n")
}
if (!app.steps.isNullOrEmpty()) {
append("操作步骤: ${app.steps.joinToString(" → ")}\n")
}
app.description?.let {
append("说明: $it\n")
}
append("\n建议:")
if (app.type == ExecutionType.DELEGATION) {
append("使用 DeepLink 直接打开 ${app.name},可快速完成任务。")
} else {
append("通过 GUI 自动化操作 ${app.name} 完成任务。")
}
}
}
/**
* 执行 Skill(核心执行方法)
*
* @param match 可用应用匹配结果
* @return 执行结果
*/
suspend fun execute(match: AvailableAppMatch): SkillResult {
val skill = match.skill
val app = match.app
val params = match.params
println("[SkillManager] 执行: ${skill.config.name} -> ${app.name} (${app.type})")
return when (app.type) {
ExecutionType.DELEGATION -> {
// 委托模式:通过 DeepLink 打开
executeDelegation(skill, app, params)
}
ExecutionType.GUI_AUTOMATION -> {
// GUI 自动化模式:返回执行计划
executeAutomation(skill, app, params)
}
}
}
/**
* 执行委托(DeepLink)
*/
private fun executeDelegation(
skill: Skill,
app: RelatedApp,
params: Map<String, Any?>
): SkillResult {
val deepLink = skill.generateDeepLink(app, params)
if (deepLink.isEmpty()) {
return SkillResult.Failed(
error = "无法生成 DeepLink",
suggestion = "尝试使用 GUI 自动化方式"
)
}
return try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 明确指定目标包名,避免系统选择其他能响应此 scheme 的应用
setPackage(app.packageName)
}
context.startActivity(intent)
SkillResult.Delegated(
app = app,
deepLink = deepLink,
message = "已打开 ${app.name}"
)
} catch (e: Exception) {
// 如果指定包名失败,尝试不指定包名的方式
println("[SkillManager] 指定包名打开失败,尝试通用方式: ${e.message}")
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallbackIntent)
SkillResult.Delegated(
app = app,
deepLink = deepLink,
message = "已打开 ${app.name}(通用方式)"
)
} catch (e2: Exception) {
SkillResult.Failed(
error = "打开 ${app.name} 失败: ${e2.message}",
suggestion = "请确认应用已安装并支持 DeepLink"
)
}
}
}
/**
* 执行 GUI 自动化(返回执行计划给 Agent)
*/
private fun executeAutomation(
skill: Skill,
app: RelatedApp,
params: Map<String, Any?>
): SkillResult {
val plan = ExecutionPlan(
skillId = skill.config.id,
skillName = skill.config.name,
app = app,
params = params,
isInstalled = true,
promptHint = skill.config.promptHint
)
return SkillResult.NeedAutomation(
plan = plan,
message = "需要通过 GUI 自动化操作 ${app.name}"
)
}
/**
* 判断是否应该使用快速路径
*
* 条件:
* 1. 高置信度匹配 (score >= 0.8)
* 2. 最佳应用是委托类型 (delegation)
* 3. 应用已安装
*/
fun shouldUseFastPath(query: String): AvailableAppMatch? {
val match = matchAvailableApp(query) ?: return null
// 只有委托类型且高置信度才走快速路径
if (match.app.type == ExecutionType.DELEGATION && match.score >= 0.8f) {
return match
}
return null
}
/**
* 生成给 Agent 的上下文提示
*
* 包含:匹配的意图、可用应用列表、推荐操作步骤
*/
fun generateAgentContext(query: String): String {
val matches = matchAllAvailableApps(query)
if (matches.isEmpty()) {
return "未找到相关技能或可用应用,请使用通用 GUI 自动化完成任务。"
}
return buildString {
append("根据用户意图,匹配到以下可用方案:\n\n")
// 按 Skill 分组
val groupedBySkill = matches.groupBy { it.skill.config.id }
for ((_, skillMatches) in groupedBySkill) {
val firstMatch = skillMatches.first()
val config = firstMatch.skill.config
append("【${config.name}】(置信度: ${(firstMatch.score * 100).toInt()}%)\n")
for ((index, match) in skillMatches.withIndex()) {
val app = match.app
val typeLabel = when (app.type) {
ExecutionType.DELEGATION -> "🚀委托(快速)"
ExecutionType.GUI_AUTOMATION -> "🤖GUI自动化"
}
append(" ${index + 1}. ${app.name} $typeLabel (优先级: ${app.priority})\n")
if (app.type == ExecutionType.DELEGATION && app.deepLink != null) {
append(" DeepLink: ${app.deepLink}\n")
}
if (!app.steps.isNullOrEmpty()) {
append(" 步骤: ${app.steps.joinToString(" → ")}\n")
}
app.description?.let {
append(" 说明: $it\n")
}
}
append("\n")
}
append("建议:优先使用委托模式(🚀),速度更快。如果委托失败再使用 GUI 自动化(🤖)。")
}
}
/**
* 获取 Skill 信息
*/
fun getSkillInfo(skillId: String): SkillConfig? {
return registry.get(skillId)?.config
}
/**
* 获取所有 Skills 描述(给 LLM)
*/
fun getSkillsDescription(): String {
return registry.getSkillsDescription()
}
/**
* 获取所有 Skills
*/
fun getAllSkills(): List<Skill> {
return registry.getAll()
}
/**
* 按分类获取 Skills
*/
fun getSkillsByCategory(category: String): List<Skill> {
return registry.getByCategory(category)
}
/**
* 检查意图是否有可用应用
*/
fun hasAvailableApp(query: String): Boolean {
return matchAvailableApp(query) != null
}
/**
* 获取意图的所有关联应用(不管是否安装)
*/
fun getAllRelatedApps(query: String): List<RelatedApp> {
val skillMatch = registry.matchBest(query) ?: return emptyList()
return skillMatch.skill.config.relatedApps
}
/**
* 获取缺失的应用推荐(用户没装但可以装的)
*/
fun getMissingAppSuggestions(query: String): List<RelatedApp> {
val skillMatch = registry.matchBest(query) ?: return emptyList()
return skillMatch.skill.config.relatedApps
.filter { !registry.isAppInstalled(it.packageName) }
.sortedByDescending { it.priority }
}
companion object {
@Volatile
private var instance: SkillManager? = null
fun init(context: Context, toolManager: ToolManager, appScanner: AppScanner): SkillManager {
return instance ?: synchronized(this) {
instance ?: SkillManager(context.applicationContext, toolManager, appScanner).also {
it.initialize()
instance = it
}
}
}
fun getInstance(): SkillManager {
return instance ?: throw IllegalStateException("SkillManager 未初始化,请先调用 init()")
}
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,341 @@
package com.roubao.autopilot.skills
import android.content.Context
import com.roubao.autopilot.controller.AppScanner
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
/**
* Skill 注册表
*
* 管理所有 Skills 的注册、查找和匹配
* 核心功能:
* - 从 skills.json 加载意图定义
* - 查询本地已安装 App,筛选可用应用
* - 根据优先级选择最佳执行方案
*/
class SkillRegistry private constructor(
private val context: Context,
private val appScanner: AppScanner
) {
private val skills = mutableMapOf<String, Skill>()
private val categoryIndex = mutableMapOf<String, MutableList<Skill>>()
// 缓存已安装 App 的包名集合(启动时刷新)
private var installedPackages: Set<String> = emptySet()
/**
* 初始化:刷新已安装应用列表
*/
fun refreshInstalledApps() {
val apps = appScanner.getApps()
installedPackages = apps.map { it.packageName }.toSet()
println("[SkillRegistry] 已缓存 ${installedPackages.size} 个已安装应用")
// 调试:检查美团相关的应用
val meituanApps = installedPackages.filter { it.contains("meituan") || it.contains("dianping") }
println("[SkillRegistry] 美团相关应用: $meituanApps")
// 检查小美的 DeepLink 是否可用(间接检测安装状态)
try {
val pm = context.packageManager
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse("beam://www.meituan.com/home")
}
val resolveInfo = pm.resolveActivity(intent, 0)
if (resolveInfo != null) {
val pkgName = resolveInfo.activityInfo.packageName
println("[SkillRegistry] 小美 DeepLink 可用,包名: $pkgName")
if (!installedPackages.contains(pkgName)) {
installedPackages = installedPackages + pkgName
println("[SkillRegistry] 添加 $pkgName 到已安装列表")
}
} else {
println("[SkillRegistry] 小美 DeepLink 不可用")
}
} catch (e: Exception) {
println("[SkillRegistry] 检查小美失败: ${e.message}")
}
}
/**
* 检查包名是否已安装
*/
fun isAppInstalled(packageName: String): Boolean {
return installedPackages.contains(packageName)
}
/**
* 从 assets/skills.json 加载 Skills
*/
fun loadFromAssets(filename: String = "skills.json"): Int {
try {
val jsonString = context.assets.open(filename).bufferedReader().use { it.readText() }
return loadFromJson(jsonString)
} catch (e: IOException) {
println("[SkillRegistry] 无法加载 $filename: ${e.message}")
return 0
}
}
/**
* 从 JSON 字符串加载 Skills
*/
fun loadFromJson(jsonString: String): Int {
var loadedCount = 0
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val config = parseSkillConfig(obj)
register(Skill(config))
loadedCount++
}
println("[SkillRegistry] 已加载 $loadedCount 个 Skills")
} catch (e: Exception) {
println("[SkillRegistry] JSON 解析错误: ${e.message}")
e.printStackTrace()
}
return loadedCount
}
/**
* 解析单个 Skill 配置(新结构)
*/
private fun parseSkillConfig(obj: JSONObject): SkillConfig {
// 解析参数
val params = mutableListOf<SkillParam>()
val paramsArray = obj.optJSONArray("params")
if (paramsArray != null) {
for (i in 0 until paramsArray.length()) {
val paramObj = paramsArray.getJSONObject(i)
val examples = mutableListOf<String>()
val examplesArray = paramObj.optJSONArray("examples")
if (examplesArray != null) {
for (j in 0 until examplesArray.length()) {
examples.add(examplesArray.getString(j))
}
}
params.add(SkillParam(
name = paramObj.getString("name"),
type = paramObj.optString("type", "string"),
description = paramObj.optString("description", ""),
required = paramObj.optBoolean("required", false),
defaultValue = paramObj.opt("default"),
examples = examples
))
}
}
// 解析关键词
val keywords = mutableListOf<String>()
val keywordsArray = obj.optJSONArray("keywords")
if (keywordsArray != null) {
for (i in 0 until keywordsArray.length()) {
keywords.add(keywordsArray.getString(i))
}
}
// 解析关联应用列表(新结构)
val relatedApps = mutableListOf<RelatedApp>()
val appsArray = obj.optJSONArray("related_apps")
if (appsArray != null) {
for (i in 0 until appsArray.length()) {
val appObj = appsArray.getJSONObject(i)
// 解析执行类型
val typeStr = appObj.optString("type", "gui_automation")
val type = when (typeStr.lowercase()) {
"delegation" -> ExecutionType.DELEGATION
else -> ExecutionType.GUI_AUTOMATION
}
// 解析操作步骤
val steps = mutableListOf<String>()
val stepsArray = appObj.optJSONArray("steps")
if (stepsArray != null) {
for (j in 0 until stepsArray.length()) {
steps.add(stepsArray.getString(j))
}
}
relatedApps.add(RelatedApp(
packageName = appObj.getString("package"),
name = appObj.getString("name"),
type = type,
deepLink = appObj.optString("deep_link", null)?.takeIf { it.isNotEmpty() },
steps = if (steps.isEmpty()) null else steps,
priority = appObj.optInt("priority", 0),
description = appObj.optString("description", null)?.takeIf { it.isNotEmpty() }
))
}
}
return SkillConfig(
id = obj.getString("id"),
name = obj.getString("name"),
description = obj.optString("description", ""),
category = obj.optString("category", "通用"),
keywords = keywords,
params = params,
relatedApps = relatedApps,
promptHint = obj.optString("prompt_hint", null)?.takeIf { it.isNotEmpty() }
)
}
/**
* 注册 Skill
*/
fun register(skill: Skill) {
skills[skill.config.id] = skill
// 更新分类索引
val category = skill.config.category
categoryIndex.getOrPut(category) { mutableListOf() }.add(skill)
println("[SkillRegistry] 注册 Skill: ${skill.config.id} (${skill.config.relatedApps.size} 关联应用)")
}
/**
* 获取 Skill
*/
fun get(id: String): Skill? = skills[id]
/**
* 获取所有 Skills
*/
fun getAll(): List<Skill> = skills.values.toList()
/**
* 按分类获取 Skills
*/
fun getByCategory(category: String): List<Skill> {
return categoryIndex[category] ?: emptyList()
}
/**
* 获取所有分类
*/
fun getAllCategories(): List<String> = categoryIndex.keys.toList()
/**
* 匹配用户意图(基于关键词)
*/
fun match(query: String, topK: Int = 3, minScore: Float = 0.3f): List<SkillMatch> {
val matches = mutableListOf<SkillMatch>()
for (skill in skills.values) {
val score = skill.matchScore(query)
if (score >= minScore) {
val params = skill.extractParams(query)
matches.add(SkillMatch(skill, score, params))
}
}
return matches
.sortedByDescending { it.score }
.take(topK)
}
/**
* 获取最佳匹配
*/
fun matchBest(query: String, minScore: Float = 0.3f): SkillMatch? {
return match(query, topK = 1, minScore = minScore).firstOrNull()
}
/**
* 匹配意图并返回可用应用(核心方法)
*
* 1. 匹配用户意图到 Skill
* 2. 筛选出已安装的关联应用
* 3. 按优先级排序
*/
fun matchAvailableApps(
query: String,
minScore: Float = 0.3f
): List<AvailableAppMatch> {
val skillMatches = match(query, topK = 5, minScore = minScore)
val results = mutableListOf<AvailableAppMatch>()
for (skillMatch in skillMatches) {
val skill = skillMatch.skill
val params = skillMatch.params
// 筛选已安装的应用,按优先级排序
val availableApps = skill.config.relatedApps
.filter { isAppInstalled(it.packageName) }
.sortedByDescending { it.priority }
for (app in availableApps) {
results.add(AvailableAppMatch(
skill = skill,
app = app,
params = params,
score = skillMatch.score
))
}
}
// 按 (匹配分数 * 0.5 + 应用优先级 * 0.01) 综合排序
return results.sortedByDescending { it.score * 0.5f + it.app.priority * 0.01f }
}
/**
* 获取意图的最佳可用应用
*/
fun getBestAvailableApp(query: String, minScore: Float = 0.3f): AvailableAppMatch? {
return matchAvailableApps(query, minScore).firstOrNull()
}
/**
* 生成 Skills 描述(给 LLM)
*/
fun getSkillsDescription(): String {
return buildString {
append("可用技能列表:\n\n")
for ((category, categorySkills) in categoryIndex) {
append("【$category】\n")
for (skill in categorySkills) {
val config = skill.config
append("- ${config.name}: ${config.description}\n")
if (config.keywords.isNotEmpty()) {
append(" 关键词: ${config.keywords.joinToString(", ")}\n")
}
// 显示已安装的应用
val installedApps = config.relatedApps.filter { isAppInstalled(it.packageName) }
if (installedApps.isNotEmpty()) {
val appNames = installedApps.map {
val typeIcon = if (it.type == ExecutionType.DELEGATION) "🚀" else "🤖"
"$typeIcon${it.name}"
}
append(" 可用应用: ${appNames.joinToString(", ")}\n")
}
}
append("\n")
}
}
}
companion object {
@Volatile
private var instance: SkillRegistry? = null
fun init(context: Context, appScanner: AppScanner): SkillRegistry {
return instance ?: synchronized(this) {
instance ?: SkillRegistry(context.applicationContext, appScanner).also {
it.refreshInstalledApps()
instance = it
}
}
}
fun getInstance(): SkillRegistry {
return instance ?: throw IllegalStateException("SkillRegistry 未初始化,请先调用 init()")
}
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,152 @@
package com.roubao.autopilot.tools
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* 剪贴板工具
*
* 提供剪贴板的读写功能
*/
class ClipboardTool(private val context: Context) : Tool {
override val name = "clipboard"
override val displayName = "剪贴板"
override val description = "读取或写入系统剪贴板内容"
override val params = listOf(
ToolParam(
name = "action",
type = "string",
description = "操作类型:read(读取)或 write(写入)",
required = true
),
ToolParam(
name = "text",
type = "string",
description = "要写入的文本(action=write 时必填)",
required = false
),
ToolParam(
name = "label",
type = "string",
description = "剪贴板标签(可选)",
required = false,
defaultValue = "roubao"
)
)
private val clipboardManager: ClipboardManager by lazy {
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
}
private val mainHandler = Handler(Looper.getMainLooper())
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val action = params["action"] as? String
?: return ToolResult.Error("缺少 action 参数")
return when (action.lowercase()) {
"read" -> readClipboard()
"write" -> {
val text = params["text"] as? String
?: return ToolResult.Error("write 操作需要 text 参数")
val label = params["label"] as? String ?: "roubao"
writeClipboard(text, label)
}
else -> ToolResult.Error("不支持的操作: $action(只支持 read/write)")
}
}
/**
* 读取剪贴板内容
*/
private suspend fun readClipboard(): ToolResult = suspendCancellableCoroutine { cont ->
mainHandler.post {
try {
val clip = clipboardManager.primaryClip
if (clip == null || clip.itemCount == 0) {
cont.resume(ToolResult.Success(data = "", message = "剪贴板为空"))
} else {
val text = clip.getItemAt(0).coerceToText(context).toString()
cont.resume(ToolResult.Success(
data = text,
message = "已读取剪贴板内容(${text.length} 字符)"
))
}
} catch (e: Exception) {
cont.resume(ToolResult.Error("读取剪贴板失败: ${e.message}"))
}
}
}
/**
* 写入剪贴板内容
*/
private suspend fun writeClipboard(text: String, label: String): ToolResult = suspendCancellableCoroutine { cont ->
mainHandler.post {
try {
val clip = ClipData.newPlainText(label, text)
clipboardManager.setPrimaryClip(clip)
cont.resume(ToolResult.Success(
data = text,
message = "已写入剪贴板(${text.length} 字符)"
))
} catch (e: Exception) {
cont.resume(ToolResult.Error("写入剪贴板失败: ${e.message}"))
}
}
}
/**
* 同步读取(用于非协程环境)
*/
fun readSync(): String? {
var result: String? = null
val latch = java.util.concurrent.CountDownLatch(1)
mainHandler.post {
try {
val clip = clipboardManager.primaryClip
if (clip != null && clip.itemCount > 0) {
result = clip.getItemAt(0).coerceToText(context).toString()
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
latch.countDown()
}
}
latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
return result
}
/**
* 同步写入(用于非协程环境)
*/
fun writeSync(text: String, label: String = "roubao"): Boolean {
var success = false
val latch = java.util.concurrent.CountDownLatch(1)
mainHandler.post {
try {
val clip = ClipData.newPlainText(label, text)
clipboardManager.setPrimaryClip(clip)
success = true
} catch (e: Exception) {
e.printStackTrace()
} finally {
latch.countDown()
}
}
latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
return success
}
}
@@ -0,0 +1,108 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.DeviceController
/**
* DeepLink 工具
*
* 通过 Intent 打开应用的特定页面或功能
* 这是实现 delegation 类型 Skill 的核心工具
*/
class DeepLinkTool(private val deviceController: DeviceController) : Tool {
override val name = "deep_link"
override val displayName = "深度链接"
override val description = "通过 DeepLink/Intent 打开应用的特定页面或功能"
override val params = listOf(
ToolParam(
name = "uri",
type = "string",
description = "DeepLink URI(如:weixin://、alipays://、amap://)",
required = true
),
ToolParam(
name = "action",
type = "string",
description = "Intent Action(默认 VIEW)",
required = false,
defaultValue = "android.intent.action.VIEW"
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val uri = params["uri"] as? String
?: return ToolResult.Error("缺少 uri 参数")
return try {
deviceController.openDeepLink(uri)
ToolResult.Success(
data = mapOf("uri" to uri),
message = "已打开: $uri"
)
} catch (e: Exception) {
ToolResult.Error("打开 DeepLink 失败: ${e.message}")
}
}
companion object {
/**
* 常用 DeepLink 模板
*/
val TEMPLATES = mapOf(
// ========== 外卖类 ==========
"meituan_food" to "imeituan://www.meituan.com/waimai/home",
"meituan_search" to "imeituan://www.meituan.com/search?q={query}",
"eleme_home" to "eleme://search",
"xiaomei_chat" to "xiaomei://chat?message={query}", // 小美 AI 对话
// ========== 出行类 ==========
"amap_route" to "amap://route/plan?sourceApplication=roubao&dlat={lat}&dlon={lon}&dname={destination}&dev=0&t=0",
"amap_navi" to "amap://navi?sourceApplication=roubao&lat={lat}&lon={lon}&dev=0",
"amap_search" to "amap://search?keyword={query}&sourceApplication=roubao",
"didi_call" to "diditaxi://",
"baidu_map_route" to "baidumap://map/direction?destination={destination}&mode=driving&src=roubao",
// ========== 社交类 ==========
"weixin_scan" to "weixin://scanqrcode",
"weixin_pay" to "weixin://pay",
"alipay_scan" to "alipays://platformapi/startapp?appId=10000007",
"alipay_pay" to "alipays://platformapi/startapp?appId=20000056",
// ========== 支付类 ==========
"alipay_transfer" to "alipays://platformapi/startapp?appId=20000200&actionType=toAccount&account={account}",
// ========== 音乐类 ==========
"netease_play" to "orpheus://song/{id}",
"netease_search" to "orpheus://search?keyword={query}",
"qqmusic_search" to "qqmusic://qq.com/ui/search?key={query}",
// ========== 视频类 ==========
"douyin_search" to "snssdk1128://search?keyword={query}",
"bilibili_search" to "bilibili://search?keyword={query}",
"bilibili_video" to "bilibili://video/{bvid}",
// ========== AI 类(delegation 目标)==========
"doubao_chat" to "doubao://chat?message={query}",
"tongyi_chat" to "tongyi://chat?message={query}",
// ========== 通用 ==========
"web" to "{url}",
"tel" to "tel:{phone}",
"sms" to "sms:{phone}?body={message}",
"email" to "mailto:{email}?subject={subject}&body={body}"
)
/**
* 根据模板生成 DeepLink
*/
fun fromTemplate(templateName: String, params: Map<String, String>): String? {
val template = TEMPLATES[templateName] ?: return null
var result = template
for ((key, value) in params) {
result = result.replace("{$key}", value)
}
return result
}
}
}
@@ -0,0 +1,164 @@
package com.roubao.autopilot.tools
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* HTTP 请求工具
*
* 用于调用外部 API,如:
* - 调用 AI 服务
* - 获取天气信息
* - 查询数据
*/
class HttpTool : Tool {
override val name = "http_request"
override val displayName = "HTTP 请求"
override val description = "发送 HTTP 请求调用外部 API"
override val params = listOf(
ToolParam(
name = "url",
type = "string",
description = "请求 URL",
required = true
),
ToolParam(
name = "method",
type = "string",
description = "HTTP 方法(GET/POST/PUT/DELETE)",
required = false,
defaultValue = "GET"
),
ToolParam(
name = "headers",
type = "object",
description = "请求头(JSON 格式)",
required = false
),
ToolParam(
name = "body",
type = "string",
description = "请求体(POST/PUT 时使用)",
required = false
),
ToolParam(
name = "timeout",
type = "int",
description = "超时时间(毫秒)",
required = false,
defaultValue = 30000
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult = withContext(Dispatchers.IO) {
val urlStr = params["url"] as? String
?: return@withContext ToolResult.Error("缺少 url 参数")
val method = (params["method"] as? String)?.uppercase() ?: "GET"
val timeout = (params["timeout"] as? Number)?.toInt() ?: 30000
val body = params["body"] as? String
@Suppress("UNCHECKED_CAST")
val headers = params["headers"] as? Map<String, String> ?: emptyMap()
try {
val url = URL(urlStr)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.doInput = true
// 设置请求头
headers.forEach { (key, value) ->
connection.setRequestProperty(key, value)
}
// 默认 Content-Type
if (body != null && !headers.containsKey("Content-Type")) {
connection.setRequestProperty("Content-Type", "application/json")
}
// 发送请求体
if (body != null && (method == "POST" || method == "PUT")) {
connection.doOutput = true
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body)
writer.flush()
}
}
// 读取响应
val responseCode = connection.responseCode
val inputStream = if (responseCode >= 400) {
connection.errorStream
} else {
connection.inputStream
}
val response = BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
connection.disconnect()
if (responseCode >= 400) {
return@withContext ToolResult.Error(
"HTTP $responseCode: $response",
code = responseCode
)
}
ToolResult.Success(
data = mapOf(
"status_code" to responseCode,
"body" to response
),
message = "请求成功 (HTTP $responseCode)"
)
} catch (e: Exception) {
ToolResult.Error("请求失败: ${e.message}")
}
}
/**
* 简化的 GET 请求
*/
suspend fun get(url: String, headers: Map<String, String> = emptyMap()): ToolResult {
return execute(mapOf(
"url" to url,
"method" to "GET",
"headers" to headers
))
}
/**
* 简化的 POST 请求
*/
suspend fun post(url: String, body: String, headers: Map<String, String> = emptyMap()): ToolResult {
return execute(mapOf(
"url" to url,
"method" to "POST",
"body" to body,
"headers" to headers
))
}
/**
* POST JSON 请求
*/
suspend fun postJson(url: String, json: JSONObject, headers: Map<String, String> = emptyMap()): ToolResult {
val allHeaders = headers.toMutableMap()
allHeaders["Content-Type"] = "application/json"
return post(url, json.toString(), allHeaders)
}
}
@@ -0,0 +1,56 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
/**
* 打开应用工具
*
* 支持:
* - 通过包名打开
* - 通过应用名打开(自动搜索包名)
*/
class OpenAppTool(
private val deviceController: DeviceController,
private val appScanner: AppScanner
) : Tool {
override val name = "open_app"
override val displayName = "打开应用"
override val description = "打开指定的应用程序"
override val params = listOf(
ToolParam(
name = "app",
type = "string",
description = "应用名称或包名(如:微信、com.tencent.mm)",
required = true
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val app = params["app"] as? String
?: return ToolResult.Error("缺少 app 参数")
// 判断是包名还是应用名
val packageName = if (app.contains(".")) {
// 已经是包名格式
app
} else {
// 需要搜索包名
val results = appScanner.searchApps(app, topK = 1)
results.firstOrNull()?.app?.packageName
?: return ToolResult.Error("未找到应用: $app")
}
return try {
deviceController.openApp(packageName)
ToolResult.Success(
data = mapOf("package_name" to packageName),
message = "已打开应用: $app"
)
} catch (e: Exception) {
ToolResult.Error("打开应用失败: ${e.message}")
}
}
}
@@ -0,0 +1,83 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.AppScanner
/**
* 搜索应用工具
*
* 支持:
* - 应用名搜索
* - 拼音搜索
* - 语义搜索(如"点外卖"会匹配外卖类应用)
* - 分类搜索
*/
class SearchAppsTool(private val appScanner: AppScanner) : Tool {
override val name = "search_apps"
override val displayName = "搜索应用"
override val description = "在已安装应用中搜索,支持应用名、拼音、语义描述等多种方式"
override val params = listOf(
ToolParam(
name = "query",
type = "string",
description = "搜索词(应用名/拼音/描述,如:微信、weixin、聊天)",
required = true
),
ToolParam(
name = "top_k",
type = "int",
description = "返回结果数量",
required = false,
defaultValue = 5
),
ToolParam(
name = "include_system",
type = "boolean",
description = "是否包含系统应用",
required = false,
defaultValue = true
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val query = params["query"] as? String
?: return ToolResult.Error("缺少 query 参数")
val topK = (params["top_k"] as? Number)?.toInt() ?: 5
val includeSystem = params["include_system"] as? Boolean ?: true
val results = appScanner.searchApps(query, topK, includeSystem)
if (results.isEmpty()) {
return ToolResult.Success(
data = emptyList<Map<String, Any>>(),
message = "未找到匹配\"$query\"的应用"
)
}
val data = results.map { result ->
mapOf(
"package_name" to result.app.packageName,
"app_name" to result.app.appName,
"category" to (result.app.category ?: ""),
"score" to result.score,
"match_type" to result.matchType,
"is_system" to result.app.isSystem
)
}
return ToolResult.Success(
data = data,
message = "找到 ${results.size} 个匹配的应用"
)
}
/**
* 快捷方法:直接获取最佳匹配的包名
*/
fun findBestMatch(query: String): String? {
val results = appScanner.searchApps(query, topK = 1)
return results.firstOrNull()?.app?.packageName
}
}
@@ -0,0 +1,150 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.data.SettingsManager
/**
* Shell 命令工具
*
* 通过 Shizuku 执行 shell 命令
* 注意:这是一个底层工具,主要供其他工具或高级场景使用
*/
class ShellTool(
private val deviceController: DeviceController,
private val settingsManager: SettingsManager? = null
) : Tool {
override val name = "shell"
override val displayName = "Shell 命令"
override val description = "执行 shell 命令(需要 Shizuku 权限)"
override val params = listOf(
ToolParam(
name = "command",
type = "string",
description = "要执行的 shell 命令",
required = true
)
)
// 安全白名单:允许执行的命令前缀
private val ALLOWED_PREFIXES = listOf(
"input ", // 输入操作
"am ", // Activity Manager
"pm ", // Package Manager
"wm ", // Window Manager
"screencap ", // 截图
"monkey ", // 启动应用
"dumpsys ", // 系统信息
"getprop ", // 系统属性
"settings ", // 系统设置
"content ", // Content Provider
"cmd ", // 通用命令
"ls ", // 文件列表
"cat ", // 读文件
"echo " // 输出
)
// 基础黑名单:始终禁止的危险命令
private val BASE_BLOCKED_COMMANDS = listOf(
"rm -rf",
"rm -r /",
"format",
"mkfs",
"dd if=",
"reboot",
"shutdown",
"> /dev",
"chmod 777 /"
)
// su -c 命令(需要特殊权限才能使用)
private val SU_COMMAND = "su -c"
/**
* 获取当前生效的黑名单
* 根据设置决定是否允许 su -c
*/
private fun getBlockedCommands(): List<String> {
val settings = settingsManager?.settings?.value
val suEnabled = settings?.rootModeEnabled == true && settings.suCommandEnabled == true
return if (suEnabled) {
BASE_BLOCKED_COMMANDS // su -c 已启用,不在黑名单中
} else {
BASE_BLOCKED_COMMANDS + SU_COMMAND // su -c 禁用
}
}
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val command = params["command"] as? String
?: return ToolResult.Error("缺少 command 参数")
// 安全检查
val securityCheck = checkSecurity(command)
if (securityCheck != null) {
return ToolResult.Error(securityCheck)
}
// 由于 DeviceController.exec 是 private,这里需要通过已有的公开方法
// 或者扩展 DeviceController
// 暂时只支持特定的安全命令
return try {
val result = executeCommand(command)
ToolResult.Success(
data = result,
message = "命令执行完成"
)
} catch (e: Exception) {
ToolResult.Error("执行失败: ${e.message}")
}
}
/**
* 安全检查
*/
private fun checkSecurity(command: String): String? {
val lowerCmd = command.lowercase().trim()
val blockedCommands = getBlockedCommands()
// 检查黑名单
for (blocked in blockedCommands) {
if (lowerCmd.contains(blocked.lowercase())) {
// 特殊提示 su -c 命令
if (blocked == SU_COMMAND) {
return "安全限制:su -c 命令需要在设置中开启「Root 模式」和「允许 su -c」"
}
return "安全限制:禁止执行此类命令"
}
}
// 检查白名单(可选,如果需要更严格的控制可以启用)
// val isAllowed = ALLOWED_PREFIXES.any { lowerCmd.startsWith(it.lowercase()) }
// if (!isAllowed) {
// return "安全限制:命令不在允许列表中"
// }
return null
}
/**
* 执行命令
* 注意:这里需要扩展 DeviceController 或使用反射
* 暂时使用简化实现
*/
private fun executeCommand(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val output = process.inputStream.bufferedReader().readText()
val error = process.errorStream.bufferedReader().readText()
process.waitFor()
if (error.isNotBlank()) {
"Output: $output\nError: $error"
} else {
output
}
} catch (e: Exception) {
"Error: ${e.message}"
}
}
}
@@ -0,0 +1,144 @@
package com.roubao.autopilot.tools
import org.json.JSONObject
/**
* Tool 执行结果
*/
sealed class ToolResult {
data class Success(val data: Any?, val message: String = "") : ToolResult()
data class Error(val error: String, val code: Int = -1) : ToolResult()
val isSuccess: Boolean get() = this is Success
fun getDataOrNull(): Any? = (this as? Success)?.data
fun toJson(): JSONObject = JSONObject().apply {
when (this@ToolResult) {
is Success -> {
put("success", true)
put("data", data?.toString() ?: "")
put("message", message)
}
is Error -> {
put("success", false)
put("error", error)
put("code", code)
}
}
}
}
/**
* Tool 参数定义
*/
data class ToolParam(
val name: String,
val type: String, // string, int, boolean, list
val description: String,
val required: Boolean = true,
val defaultValue: Any? = null
)
/**
* Tool 接口 - 所有工具的基类
*
* Tool 是原子能力层,提供单一、可复用的功能
* - 每个 Tool 完成一个独立的操作
* - Tool 之间可以组合使用
* - Tool 的输入输出是结构化的
*/
interface Tool {
/** 工具名称(唯一标识) */
val name: String
/** 工具显示名称 */
val displayName: String
/** 工具描述 */
val description: String
/** 参数定义 */
val params: List<ToolParam>
/**
* 执行工具
* @param params 参数 Map
* @return 执行结果
*/
suspend fun execute(params: Map<String, Any?>): ToolResult
/**
* 验证参数
*/
fun validateParams(params: Map<String, Any?>): List<String> {
val errors = mutableListOf<String>()
for (param in this.params) {
if (param.required && !params.containsKey(param.name)) {
errors.add("缺少必填参数: ${param.name}")
}
}
return errors
}
/**
* 生成给 LLM 的工具描述
*/
fun toLLMDescription(): String {
val paramsDesc = params.joinToString("\n") { p ->
val required = if (p.required) "(必填)" else "(可选)"
" - ${p.name}: ${p.type} $required - ${p.description}"
}
return """
|工具: $name
|说明: $description
|参数:
|$paramsDesc
""".trimMargin()
}
}
/**
* Tool 注册表 - 管理所有可用工具
*/
object ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) {
tools[tool.name] = tool
println("[ToolRegistry] 注册工具: ${tool.name}")
}
fun get(name: String): Tool? = tools[name]
fun getAll(): List<Tool> = tools.values.toList()
fun getNames(): List<String> = tools.keys.toList()
fun contains(name: String): Boolean = tools.containsKey(name)
/**
* 生成所有工具的描述(给 LLM)
*/
fun getAllDescriptions(): String {
return tools.values.joinToString("\n\n") { it.toLLMDescription() }
}
/**
* 根据名称执行工具
*/
suspend fun execute(toolName: String, params: Map<String, Any?>): ToolResult {
val tool = tools[toolName] ?: return ToolResult.Error("未找到工具: $toolName")
val errors = tool.validateParams(params)
if (errors.isNotEmpty()) {
return ToolResult.Error("参数错误: ${errors.joinToString(", ")}")
}
return try {
tool.execute(params)
} catch (e: Exception) {
ToolResult.Error("执行失败: ${e.message}")
}
}
}
@@ -0,0 +1,109 @@
package com.roubao.autopilot.tools
import android.content.Context
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
/**
* 工具管理器
*
* 负责初始化、注册和管理所有 Tools
* 作为 Tool 层的统一入口
*/
class ToolManager private constructor(
private val context: Context,
private val deviceController: DeviceController,
private val appScanner: AppScanner
) {
// 持有各个工具的引用(方便直接调用)
lateinit var searchAppsTool: SearchAppsTool
private set
lateinit var openAppTool: OpenAppTool
private set
lateinit var clipboardTool: ClipboardTool
private set
lateinit var deepLinkTool: DeepLinkTool
private set
lateinit var shellTool: ShellTool
private set
lateinit var httpTool: HttpTool
private set
/**
* 初始化所有工具
*/
private fun initialize() {
// 创建工具实例
searchAppsTool = SearchAppsTool(appScanner)
openAppTool = OpenAppTool(deviceController, appScanner)
clipboardTool = ClipboardTool(context)
deepLinkTool = DeepLinkTool(deviceController)
shellTool = ShellTool(deviceController)
httpTool = HttpTool()
// 注册到全局 Registry
ToolRegistry.register(searchAppsTool)
ToolRegistry.register(openAppTool)
ToolRegistry.register(clipboardTool)
ToolRegistry.register(deepLinkTool)
ToolRegistry.register(shellTool)
ToolRegistry.register(httpTool)
println("[ToolManager] 已初始化 ${ToolRegistry.getAll().size} 个工具")
}
/**
* 执行工具
*/
suspend fun execute(toolName: String, params: Map<String, Any?>): ToolResult {
return ToolRegistry.execute(toolName, params)
}
/**
* 获取所有工具描述(给 LLM)
*/
fun getToolDescriptions(): String {
return ToolRegistry.getAllDescriptions()
}
/**
* 获取可用工具列表
*/
fun getAvailableTools(): List<Tool> {
return ToolRegistry.getAll()
}
companion object {
@Volatile
private var instance: ToolManager? = null
/**
* 初始化单例
*/
fun init(
context: Context,
deviceController: DeviceController,
appScanner: AppScanner
): ToolManager {
return instance ?: synchronized(this) {
instance ?: ToolManager(context, deviceController, appScanner).also {
it.initialize()
instance = it
}
}
}
/**
* 获取单例
*/
fun getInstance(): ToolManager {
return instance ?: throw IllegalStateException("ToolManager 未初始化,请先调用 init()")
}
/**
* 检查是否已初始化
*/
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,501 @@
package com.roubao.autopilot.ui
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.IBinder
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.roubao.autopilot.MainActivity
import com.roubao.autopilot.R
/**
* 七彩悬浮窗服务 - 显示当前执行步骤
* 放在屏幕顶部状态栏下方,不影响截图识别
*/
class OverlayService : Service() {
private var windowManager: WindowManager? = null
private var overlayView: View? = null
private var textView: TextView? = null
private var actionButton: TextView? = null
private var cancelButton: TextView? = null // 确认模式下的取消按钮
private var divider: View? = null
private var divider2: View? = null // 确认模式下第二个分隔线
private var animator: ValueAnimator? = null
companion object {
private var instance: OverlayService? = null
private var stopCallback: (() -> Unit)? = null
private var continueCallback: (() -> Unit)? = null
private var confirmCallback: ((Boolean) -> Unit)? = null // 敏感操作确认回调
private var isTakeOverMode = false
private var isConfirmMode = false // 敏感操作确认模式
// 等待 instance 回调队列
private val pendingCallbacks = mutableListOf<() -> Unit>()
fun show(context: Context, text: String, onStop: (() -> Unit)? = null) {
stopCallback = onStop
isTakeOverMode = false
isConfirmMode = false
instance?.updateText(text) ?: run {
val intent = Intent(context, OverlayService::class.java).apply {
putExtra("text", text)
}
ContextCompat.startForegroundService(context, intent)
}
instance?.setNormalMode()
}
fun hide(context: Context) {
stopCallback = null
continueCallback = null
confirmCallback = null
isTakeOverMode = false
isConfirmMode = false
pendingCallbacks.clear()
// 只有当 service 已经启动完成时才停止它
// 否则会导致 ForegroundServiceDidNotStartInTimeException
if (instance != null) {
context.stopService(Intent(context, OverlayService::class.java))
}
}
fun update(text: String) {
instance?.updateText(text)
}
/** 截图时临时隐藏悬浮窗 */
fun setVisible(visible: Boolean) {
instance?.overlayView?.post {
instance?.overlayView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
}
/** 显示人机协作模式 - 等待用户手动完成操作 */
fun showTakeOver(message: String, onContinue: () -> Unit) {
val action: () -> Unit = {
println("[OverlayService] showTakeOver: $message")
continueCallback = onContinue
isTakeOverMode = true
isConfirmMode = false
instance?.setTakeOverMode(message)
Unit
}
if (instance != null) {
action()
} else {
// 悬浮窗尚未启动,加入等待队列
println("[OverlayService] showTakeOver: instance is null, queuing...")
pendingCallbacks.add(action)
}
}
/** 显示敏感操作确认模式 - 用户确认或取消 */
fun showConfirm(message: String, onConfirm: (Boolean) -> Unit) {
val action: () -> Unit = {
println("[OverlayService] showConfirm: $message")
confirmCallback = onConfirm
isConfirmMode = true
isTakeOverMode = false
instance?.setConfirmMode(message)
Unit
}
if (instance != null) {
action()
} else {
// 悬浮窗尚未启动,加入等待队列
println("[OverlayService] showConfirm: instance is null, queuing...")
pendingCallbacks.add(action)
}
}
/** 当 instance 可用时执行等待中的回调 */
private fun processPendingCallbacks() {
println("[OverlayService] processPendingCallbacks: ${pendingCallbacks.size} pending")
pendingCallbacks.forEach { it.invoke() }
pendingCallbacks.clear()
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
instance = this
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
// 必须第一时间调用 startForeground,否则会崩溃
startForegroundNotification()
// 创建悬浮窗(可能因权限问题失败)
try {
createOverlayView()
} catch (e: Exception) {
println("[OverlayService] createOverlayView failed: ${e.message}")
}
// 处理在 service 启动前排队的回调
processPendingCallbacks()
}
private fun startForegroundNotification() {
val channelId = "baozi_overlay"
val channelName = "肉包状态"
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "显示肉包执行状态"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("肉包运行中")
.setContentText("正在执行自动化任务...")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(1001, notification)
} catch (e: Exception) {
println("[OverlayService] startForegroundNotification error: ${e.message}")
// 降级:使用最简单的通知确保 startForeground 被调用
try {
val fallbackNotification = NotificationCompat.Builder(this, channelId)
.setContentTitle("肉包")
.setSmallIcon(R.mipmap.ic_launcher)
.build()
startForeground(1001, fallbackNotification)
} catch (e2: Exception) {
println("[OverlayService] fallback startForeground also failed: ${e2.message}")
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val text = intent?.getStringExtra("text") ?: "AutoPilot"
updateText(text)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
instance = null
animator?.cancel()
overlayView?.let { windowManager?.removeView(it) }
}
@SuppressLint("ClickableViewAccessibility")
private fun createOverlayView() {
// 容器
val container = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(16, 12, 16, 12)
}
// 七彩渐变背景
val gradientDrawable = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 30f
setStroke(2, Color.WHITE)
}
container.background = gradientDrawable
// 状态文字
textView = TextView(this).apply {
text = "肉包"
textSize = 13f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
}
container.addView(textView)
// 分隔线
divider = View(this).apply {
setBackgroundColor(Color.WHITE)
alpha = 0.5f
}
val dividerParams = LinearLayout.LayoutParams(2, 36).apply {
setMargins(12, 0, 12, 0)
}
container.addView(divider, dividerParams)
// 动作按钮(停止/继续/确认)
actionButton = TextView(this).apply {
text = "⏹ 停止"
textSize = 13f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
setOnClickListener {
when {
isConfirmMode -> {
// 确认模式:点击确认
confirmCallback?.invoke(true)
confirmCallback = null
isConfirmMode = false
setNormalMode()
}
isTakeOverMode -> {
// 人机协作模式:点击继续
continueCallback?.invoke()
continueCallback = null
isTakeOverMode = false
setNormalMode()
}
else -> {
// 正常模式:点击停止
stopCallback?.invoke()
hide(this@OverlayService)
}
}
}
}
container.addView(actionButton)
// 第二个分隔线(确认模式用)
divider2 = View(this).apply {
setBackgroundColor(Color.WHITE)
alpha = 0.5f
visibility = View.GONE
}
val divider2Params = LinearLayout.LayoutParams(2, 36).apply {
setMargins(12, 0, 12, 0)
}
container.addView(divider2, divider2Params)
// 取消按钮(确认模式用)
cancelButton = TextView(this).apply {
text = "❌ 取消"
textSize = 13f
setTextColor(Color.parseColor("#FF6B6B")) // 红色
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
visibility = View.GONE
setOnClickListener {
if (isConfirmMode) {
confirmCallback?.invoke(false)
confirmCallback = null
isConfirmMode = false
setNormalMode()
}
}
}
container.addView(cancelButton)
// 动画:七彩渐变流动效果
startRainbowAnimation(gradientDrawable)
val params = WindowManager.LayoutParams().apply {
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON // 保持屏幕常亮
format = PixelFormat.TRANSLUCENT
width = WindowManager.LayoutParams.WRAP_CONTENT
height = WindowManager.LayoutParams.WRAP_CONTENT
gravity = Gravity.TOP or Gravity.START
x = 100
y = 200
}
// 添加拖动功能(只拦截文字区域,不影响按钮点击)
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
var isDragging = false
val dragThreshold = 20f // 增大阈值,避免误触
// 只在文字区域启用拖动,按钮区域不拦截
textView?.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = params.x
initialY = params.y
initialTouchX = event.rawX
initialTouchY = event.rawY
isDragging = false
true
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.rawX - initialTouchX
val deltaY = event.rawY - initialTouchY
if (kotlin.math.abs(deltaX) > dragThreshold || kotlin.math.abs(deltaY) > dragThreshold) {
isDragging = true
}
if (isDragging) {
params.x = initialX + deltaX.toInt()
params.y = initialY + deltaY.toInt()
windowManager?.updateViewLayout(container, params)
}
true
}
MotionEvent.ACTION_UP -> {
isDragging
}
else -> false
}
}
overlayView = container
windowManager?.addView(overlayView, params)
}
private fun startRainbowAnimation(drawable: GradientDrawable) {
val colors = intArrayOf(
Color.parseColor("#FF6B6B"), // 红
Color.parseColor("#FFA94D"), // 橙
Color.parseColor("#FFE066"), // 黄
Color.parseColor("#69DB7C"), // 绿
Color.parseColor("#4DABF7"), // 蓝
Color.parseColor("#9775FA"), // 紫
Color.parseColor("#F783AC"), // 粉
Color.parseColor("#FF6B6B") // 回到红
)
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 3000
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener { animation ->
val fraction = animation.animatedValue as Float
val index = (fraction * (colors.size - 1)).toInt()
val nextIndex = minOf(index + 1, colors.size - 1)
val localFraction = (fraction * (colors.size - 1)) - index
val color1 = interpolateColor(colors[index], colors[nextIndex], localFraction)
val color2 = interpolateColor(
colors[(index + 2) % colors.size],
colors[(nextIndex + 2) % colors.size],
localFraction
)
val color3 = interpolateColor(
colors[(index + 4) % colors.size],
colors[(nextIndex + 4) % colors.size],
localFraction
)
drawable.colors = intArrayOf(color1, color2, color3)
drawable.orientation = GradientDrawable.Orientation.LEFT_RIGHT
}
start()
}
}
private fun interpolateColor(startColor: Int, endColor: Int, fraction: Float): Int {
val startA = Color.alpha(startColor)
val startR = Color.red(startColor)
val startG = Color.green(startColor)
val startB = Color.blue(startColor)
val endA = Color.alpha(endColor)
val endR = Color.red(endColor)
val endG = Color.green(endColor)
val endB = Color.blue(endColor)
return Color.argb(
(startA + (endA - startA) * fraction).toInt(),
(startR + (endR - startR) * fraction).toInt(),
(startG + (endG - startG) * fraction).toInt(),
(startB + (endB - startB) * fraction).toInt()
)
}
private fun updateText(text: String) {
textView?.post {
textView?.text = text
}
}
/** 切换到人机协作模式 */
private fun setTakeOverMode(message: String) {
println("[OverlayService] setTakeOverMode: $message")
overlayView?.post {
// 确保悬浮窗可见
overlayView?.visibility = View.VISIBLE
textView?.text = "🖐 $message"
actionButton?.text = "✅ 继续"
actionButton?.setTextColor(Color.parseColor("#90EE90")) // 浅绿色
// 隐藏取消按钮(人机协作只有继续按钮)
divider2?.visibility = View.GONE
cancelButton?.visibility = View.GONE
}
}
/** 切换到正常模式 */
private fun setNormalMode() {
println("[OverlayService] setNormalMode")
overlayView?.post {
actionButton?.text = "⏹ 停止"
actionButton?.setTextColor(Color.WHITE)
// 隐藏取消按钮和第二分隔线
divider2?.visibility = View.GONE
cancelButton?.visibility = View.GONE
}
}
/** 切换到敏感操作确认模式 */
private fun setConfirmMode(message: String) {
println("[OverlayService] setConfirmMode: $message")
overlayView?.post {
// 确保悬浮窗可见
overlayView?.visibility = View.VISIBLE
textView?.text = "⚠️ $message"
actionButton?.text = "✅ 确认"
actionButton?.setTextColor(Color.parseColor("#90EE90")) // 浅绿色
// 显示取消按钮和第二分隔线
divider2?.visibility = View.VISIBLE
cancelButton?.visibility = View.VISIBLE
}
}
}
@@ -0,0 +1,452 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.ui.theme.BaoziTheme
/**
* 工具信息(用于展示)
*/
data class ToolInfo(
val name: String,
val description: String
)
/**
* Agent 角色信息
*/
data class AgentInfo(
val name: String,
val icon: String,
val role: String,
val description: String,
val responsibilities: List<String>
)
/**
* 预定义的 Agents 列表
*/
val agentsList = listOf(
AgentInfo(
name = "Manager",
icon = "🎯",
role = "规划者",
description = "负责理解用户意图,制定高层次的执行计划,并跟踪任务进度。",
responsibilities = listOf(
"分析用户请求,理解真实意图",
"将复杂任务分解为可执行的子目标",
"制定执行计划和步骤顺序",
"根据执行反馈动态调整计划"
)
),
AgentInfo(
name = "Executor",
icon = "⚡",
role = "执行者",
description = "负责分析当前屏幕状态,决定具体的操作动作。",
responsibilities = listOf(
"分析屏幕截图,理解界面元素",
"根据计划选择下一步操作",
"确定点击、滑动、输入等具体动作",
"输出精确的操作坐标和参数"
)
),
AgentInfo(
name = "Reflector",
icon = "🔍",
role = "反思者",
description = "负责评估操作结果,判断动作是否成功执行。",
responsibilities = listOf(
"对比操作前后的屏幕变化",
"判断操作是否达到预期效果",
"识别异常情况(如弹窗、错误)",
"提供反馈帮助调整后续策略"
)
),
AgentInfo(
name = "Notetaker",
icon = "📝",
role = "记录者",
description = "负责记录执行过程中的关键信息,供其他 Agent 参考。",
responsibilities = listOf(
"记录任务执行的重要节点",
"保存中间结果和状态信息",
"为后续步骤提供上下文参考",
"生成执行摘要和日志"
)
)
)
/**
* 能力展示页面
*
* 展示 Agents 和 Tools(只读)
*/
@Composable
fun CapabilitiesScreen() {
val colors = BaoziTheme.colors
// 获取 Tools
val tools = remember {
if (ToolManager.isInitialized()) {
ToolManager.getInstance().getAvailableTools().map { tool ->
ToolInfo(name = tool.name, description = tool.description)
}
} else {
emptyList()
}
}
// 额外的内置工具(不在 ToolManager 中但是系统能力)
val builtInTools = listOf(
ToolInfo("screenshot", "截取当前屏幕,获取界面图像供 AI 分析"),
ToolInfo("tap", "点击屏幕指定坐标位置"),
ToolInfo("swipe", "在屏幕上滑动,支持上下左右方向"),
ToolInfo("type", "输入文本内容到当前焦点位置"),
ToolInfo("press_key", "按下系统按键(Home、Back、Enter 等)")
)
val allTools = tools + builtInTools
// Tab 状态
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Agents (${agentsList.size})", "Tools (${allTools.size})")
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column {
Text(
text = "能力",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = "${agentsList.size} 个 Agent,${allTools.size} 个工具",
fontSize = 14.sp,
color = colors.textSecondary
)
}
}
// Tab 切换
TabRow(
selectedTabIndex = selectedTab,
containerColor = colors.background,
contentColor = colors.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
color = if (selectedTab == index) colors.primary else colors.textSecondary
)
}
)
}
}
// 内容区域
when (selectedTab) {
0 -> AgentsListView()
1 -> ToolsListView(tools = allTools)
}
}
}
@Composable
fun AgentsListView() {
val colors = BaoziTheme.colors
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 架构说明卡片
item(key = "arch_intro") {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.primary.copy(alpha = 0.1f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "🧠 多 Agent 协作架构",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "肉包采用多 Agent 协作架构,每个 Agent 专注于特定职责,通过协作完成复杂的手机自动化任务。",
fontSize = 13.sp,
color = colors.textSecondary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Manager → Executor → Reflector → Notetaker",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = colors.textHint
)
}
}
}
// Agent 列表
items(agentsList, key = { it.name }) { agent ->
AgentCard(agent = agent)
}
}
}
@Composable
fun AgentCard(agent: AgentInfo) {
val colors = BaoziTheme.colors
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Agent 图标
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(colors.primary.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Text(
text = agent.icon,
fontSize = 28.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = agent.name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(colors.secondary.copy(alpha = 0.2f))
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = agent.role,
fontSize = 11.sp,
color = colors.secondary
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = agent.description,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis
)
}
Icon(
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (expanded) "收起" else "展开",
tint = colors.textHint
)
}
// 展开显示职责列表
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.padding(top = 16.dp)
) {
Text(
text = "职责",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(8.dp))
agent.responsibilities.forEach { responsibility ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "•",
fontSize = 14.sp,
color = colors.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = responsibility,
fontSize = 13.sp,
color = colors.textSecondary
)
}
}
}
}
}
}
}
@Composable
fun ToolsListView(tools: List<ToolInfo>) {
if (tools.isEmpty()) {
EmptyState(message = "暂无工具")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tools, key = { it.name }) { tool ->
ToolCard(tool = tool)
}
}
}
}
@Composable
fun ToolCard(tool: ToolInfo) {
val colors = BaoziTheme.colors
// 根据工具名获取图标
val toolIcon = when (tool.name) {
"search_apps" -> "🔍"
"open_app" -> "📱"
"deep_link" -> "🔗"
"clipboard" -> "📋"
"shell" -> "💻"
"http" -> "🌐"
else -> "🔧"
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 工具图标
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(colors.secondary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text(
text = toolIcon,
fontSize = 20.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = tool.name,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tool.description,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
fun EmptyState(message: String) {
val colors = BaoziTheme.colors
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📦",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
fontSize = 16.sp,
color = colors.textSecondary
)
}
}
}
@@ -0,0 +1,595 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.data.ExecutionRecord
import com.roubao.autopilot.data.ExecutionStatus
import com.roubao.autopilot.data.ExecutionStep
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import com.roubao.autopilot.ui.theme.Secondary
@Composable
fun HistoryScreen(
records: List<ExecutionRecord>,
onRecordClick: (ExecutionRecord) -> Unit,
onDeleteRecord: (String) -> Unit
) {
val colors = BaoziTheme.colors
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column {
Text(
text = "执行记录",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = "共 ${records.size} 条记录",
fontSize = 14.sp,
color = colors.textSecondary
)
}
}
if (records.isEmpty()) {
// 空状态
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📝",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无执行记录",
fontSize = 16.sp,
color = colors.textSecondary
)
Text(
text = "执行任务后记录会显示在这里",
fontSize = 14.sp,
color = colors.textHint
)
}
}
} else {
// 记录列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = records,
key = { it.id }
) { record ->
HistoryRecordCard(
record = record,
onClick = { onRecordClick(record) },
onDelete = { onDeleteRecord(record.id) }
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryRecordCard(
record: ExecutionRecord,
onClick: () -> Unit,
onDelete: () -> Unit
) {
val colors = BaoziTheme.colors
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
containerColor = colors.backgroundCard,
title = { Text("删除记录", color = colors.textPrimary) },
text = { Text("确定要删除这条执行记录吗?", color = colors.textSecondary) },
confirmButton = {
TextButton(onClick = {
onDelete()
showDeleteDialog = false
}) {
Text("删除", color = colors.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("取消", color = colors.textSecondary)
}
}
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 状态图标
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
when (record.status) {
ExecutionStatus.COMPLETED -> colors.success.copy(alpha = 0.2f)
ExecutionStatus.FAILED -> colors.error.copy(alpha = 0.2f)
ExecutionStatus.STOPPED -> colors.warning.copy(alpha = 0.2f)
ExecutionStatus.RUNNING -> colors.primary.copy(alpha = 0.2f)
}
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = when (record.status) {
ExecutionStatus.COMPLETED -> Icons.Default.CheckCircle
ExecutionStatus.FAILED -> Icons.Default.Warning
ExecutionStatus.STOPPED -> Icons.Default.PlayArrow
ExecutionStatus.RUNNING -> Icons.Default.PlayArrow
},
contentDescription = null,
tint = when (record.status) {
ExecutionStatus.COMPLETED -> colors.success
ExecutionStatus.FAILED -> colors.error
ExecutionStatus.STOPPED -> colors.warning
ExecutionStatus.RUNNING -> colors.primary
},
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// 内容
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = record.instruction,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 状态标签
val (statusText, statusColor) = when (record.status) {
ExecutionStatus.COMPLETED -> "已完成" to colors.success
ExecutionStatus.FAILED -> "失败" to colors.error
ExecutionStatus.STOPPED -> "已取消" to colors.warning
ExecutionStatus.RUNNING -> "执行中" to colors.primary
}
Text(
text = statusText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = statusColor,
modifier = Modifier
.background(
statusColor.copy(alpha = 0.15f),
RoundedCornerShape(4.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = record.formattedStartTime,
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = "${record.steps.size}步",
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = record.formattedDuration,
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
}
}
// 删除按钮
IconButton(
onClick = { showDeleteDialog = true }
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
tint = colors.textHint
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryDetailScreen(
record: ExecutionRecord,
onBack: () -> Unit
) {
val colors = BaoziTheme.colors
// Tab 状态:0 = 时间线,1 = 日志
var selectedTab by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部栏
TopAppBar(
title = {
Column {
Text(
text = record.title,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Text(
text = record.formattedStartTime,
fontSize = 12.sp,
color = colors.textSecondary
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回",
tint = colors.textPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = colors.background
)
)
// 任务信息卡片
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "任务指令",
fontSize = 12.sp,
color = colors.textHint
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = record.instruction,
fontSize = 15.sp,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("状态", fontSize = 12.sp, color = colors.textHint)
Text(
text = when (record.status) {
ExecutionStatus.COMPLETED -> "已完成"
ExecutionStatus.FAILED -> "失败"
ExecutionStatus.STOPPED -> "已停止"
ExecutionStatus.RUNNING -> "执行中"
},
fontSize = 14.sp,
color = when (record.status) {
ExecutionStatus.COMPLETED -> colors.success
ExecutionStatus.FAILED -> colors.error
ExecutionStatus.STOPPED -> colors.warning
ExecutionStatus.RUNNING -> colors.primary
}
)
}
Column {
Text("步骤数", fontSize = 12.sp, color = colors.textHint)
Text("${record.steps.size}", fontSize = 14.sp, color = colors.textPrimary)
}
Column {
Text("耗时", fontSize = 12.sp, color = colors.textHint)
Text(record.formattedDuration, fontSize = 14.sp, color = colors.textPrimary)
}
}
}
}
// Tab 切换
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 时间线 Tab
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (selectedTab == 0) colors.primary
else colors.backgroundCard
)
.clickable { selectedTab = 0 }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "执行时间线",
fontSize = 14.sp,
fontWeight = if (selectedTab == 0) FontWeight.Medium else FontWeight.Normal,
color = if (selectedTab == 0) Color.White else colors.textSecondary
)
}
// 日志 Tab
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (selectedTab == 1) colors.primary
else colors.backgroundCard
)
.clickable { selectedTab = 1 }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "执行日志",
fontSize = 14.sp,
fontWeight = if (selectedTab == 1) FontWeight.Medium else FontWeight.Normal,
color = if (selectedTab == 1) Color.White else colors.textSecondary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 内容区域
when (selectedTab) {
0 -> {
// 时间线列表
if (record.steps.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无执行步骤",
fontSize = 14.sp,
color = colors.textHint
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(record.steps) { step ->
TimelineItem(step = step, isLast = step == record.steps.lastOrNull())
}
}
}
}
1 -> {
// 日志列表
if (record.logs.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无执行日志",
fontSize = 14.sp,
color = colors.textHint
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(record.logs) { log ->
val logColor = when {
log.contains("❌") -> colors.error
log.contains("✅") -> colors.success
log.contains("📋") || log.contains("🎬") -> colors.secondary
log.contains("Step") || log.contains("=====") -> colors.primary
log.contains("⛔") -> colors.error
else -> colors.textSecondary
}
Text(
text = log,
fontSize = 12.sp,
color = logColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
}
}
}
}
@Composable
fun TimelineItem(
step: ExecutionStep,
isLast: Boolean
) {
val colors = BaoziTheme.colors
Row(modifier = Modifier.fillMaxWidth()) {
// 时间线指示器
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// 圆点
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(
when (step.outcome) {
"A" -> colors.success
"B" -> colors.warning
"?" -> colors.textHint // 进行中被取消
else -> colors.error
}
)
)
// 连接线
if (!isLast) {
Box(
modifier = Modifier
.width(2.dp)
.height(80.dp)
.background(colors.backgroundInput)
)
}
}
Spacer(modifier = Modifier.width(16.dp))
// 步骤内容
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = if (isLast) 0.dp else 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Step ${step.stepNumber}",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.primary
)
Text(
text = step.action,
fontSize = 12.sp,
color = colors.secondary
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = step.description,
fontSize = 13.sp,
color = colors.textPrimary
)
if (step.thought.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = step.thought,
fontSize = 12.sp,
color = colors.textSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@@ -0,0 +1,557 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.agent.AgentState
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import com.roubao.autopilot.ui.theme.Secondary
/**
* 预设命令
*/
data class PresetCommand(
val icon: String,
val title: String,
val command: String
)
val presetCommands = listOf(
PresetCommand("🍔", "点汉堡", "帮我点个附近好吃的汉堡"),
PresetCommand("📕", "发小红书", "帮我发一条小红书,内容是今日份好心情"),
PresetCommand("📺", "刷B站", "打开B站搜索肉包,找到第一个视频点个赞"),
PresetCommand("✈️", "旅游攻略", "用小美帮我查一下三亚旅游攻略"),
PresetCommand("🎵", "听音乐", "打开网易云音乐播放每日推荐"),
PresetCommand("🛒", "点外卖", "帮我在美团点一份猪脚饭")
)
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
agentState: AgentState?,
logs: List<String>,
onExecute: (String) -> Unit,
onStop: () -> Unit,
shizukuAvailable: Boolean,
currentModel: String = "",
onRefreshShizuku: () -> Unit = {},
onShizukuRequired: () -> Unit = {},
isExecuting: Boolean = false
) {
val colors = BaoziTheme.colors
var inputText by remember { mutableStateOf("") }
// 使用 isExecuting 或 agentState?.isRunning 来判断是否运行中
val isRunning = isExecuting || agentState?.isRunning == true
val listState = rememberLazyListState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
// 记录上一次的运行状态,用于检测任务结束
var wasRunning by remember { mutableStateOf(false) }
// 任务结束时清空输入框
LaunchedEffect(isRunning) {
if (wasRunning && !isRunning) {
// 从运行中变为未运行,说明任务结束
inputText = ""
}
wasRunning = isRunning
}
// 自动滚动到底部
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.imePadding()
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "肉包",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = if (shizukuAvailable) "准备就绪,告诉我你想做什么" else "请先连接 Shizuku",
fontSize = 14.sp,
color = if (shizukuAvailable) colors.textSecondary else colors.error
)
}
// 未连接时显示刷新按钮
if (!shizukuAvailable) {
IconButton(
onClick = onRefreshShizuku,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(colors.backgroundCard)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "刷新 Shizuku 状态",
tint = colors.primary
)
}
}
}
}
// 内容区域
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (isRunning || logs.isNotEmpty()) {
// 执行中或有日志时显示日志
ExecutionLogView(
logs = logs,
isRunning = isRunning,
currentStep = agentState?.currentStep ?: 0,
currentModel = currentModel,
listState = listState,
modifier = Modifier.fillMaxSize()
)
} else {
// 空闲时显示预设命令
PresetCommandsView(
onCommandClick = { command ->
if (shizukuAvailable) {
inputText = command
} else {
onShizukuRequired()
}
},
modifier = Modifier.fillMaxSize()
)
}
}
// 底部输入区域
InputArea(
inputText = inputText,
onInputChange = { inputText = it },
onExecute = {
if (inputText.isNotBlank()) {
// 收起键盘并清除焦点
keyboardController?.hide()
focusManager.clearFocus()
onExecute(inputText)
}
},
onStop = {
// 停止任务并清空输入框
inputText = ""
onStop()
},
isRunning = isRunning,
enabled = shizukuAvailable,
onInputClick = {
if (!shizukuAvailable) {
onShizukuRequired()
}
}
)
}
}
@Composable
fun PresetCommandsView(
onCommandClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Column(
modifier = modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "试试这些指令",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 16.dp)
)
presetCommands.chunked(2).forEach { rowCommands ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowCommands.forEach { preset ->
PresetCommandCard(
preset = preset,
onClick = { onCommandClick(preset.command) },
modifier = Modifier.weight(1f)
)
}
// 如果是奇数,补一个空白
if (rowCommands.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
fun PresetCommandCard(
preset: PresetCommand,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Card(
modifier = modifier
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = colors.backgroundCard
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = preset.icon,
fontSize = 24.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = preset.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Text(
text = preset.command,
fontSize = 11.sp,
color = colors.textSecondary,
maxLines = 1
)
}
}
}
}
@Composable
fun ExecutionLogView(
logs: List<String>,
isRunning: Boolean,
currentStep: Int,
currentModel: String,
listState: androidx.compose.foundation.lazy.LazyListState,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 执行状态指示器
if (isRunning) {
ExecutingIndicator(currentStep = currentStep, currentModel = currentModel)
}
// 日志列表
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(logs) { log ->
LogItem(log = log)
}
}
}
}
@Composable
fun ExecutingIndicator(currentStep: Int, currentModel: String = "") {
val colors = BaoziTheme.colors
val infiniteTransition = rememberInfiniteTransition(label = "executing")
val animatedProgress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "progress"
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
// 动画圆点
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(
Brush.sweepGradient(
listOf(Primary, Secondary, Primary)
)
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "正在执行 Step $currentStep",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.primary
)
}
// 显示当前模型
if (currentModel.isNotEmpty()) {
Text(
text = currentModel,
fontSize = 11.sp,
color = colors.textHint,
maxLines = 1
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(colors.backgroundInput)
) {
Box(
modifier = Modifier
.fillMaxWidth(animatedProgress)
.fillMaxHeight()
.background(
Brush.horizontalGradient(
listOf(Primary, Secondary)
)
)
)
}
}
}
}
}
@Composable
fun LogItem(log: String) {
val colors = BaoziTheme.colors
val logColor = when {
log.contains("❌") -> colors.error
log.contains("✅") -> colors.success
log.contains("📋") || log.contains("🎬") -> colors.secondary
log.contains("Step") || log.contains("=====") -> colors.primary
log.contains("⛔") -> colors.error
else -> colors.textSecondary
}
Text(
text = log,
fontSize = 12.sp,
color = logColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
@Composable
fun InputArea(
inputText: String,
onInputChange: (String) -> Unit,
onExecute: () -> Unit,
onStop: () -> Unit,
isRunning: Boolean,
enabled: Boolean,
onInputClick: () -> Unit = {}
) {
val colors = BaoziTheme.colors
Surface(
modifier = Modifier.fillMaxWidth(),
color = colors.backgroundCard,
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isRunning) Arrangement.Center else Arrangement.Start
) {
if (isRunning) {
// 运行中只显示停止按钮
Button(
onClick = onStop,
colors = ButtonDefaults.buttonColors(
containerColor = colors.error
),
shape = RoundedCornerShape(24.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "停止",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "停止执行",
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
} else {
// 非运行状态显示输入框和发送按钮
// 输入框
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(24.dp))
.background(colors.backgroundInput)
.then(
if (!enabled) {
Modifier.clickable { onInputClick() }
} else {
Modifier
}
)
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
if (enabled) {
// Shizuku 已连接,显示可编辑的输入框
BasicTextField(
value = inputText,
onValueChange = onInputChange,
textStyle = TextStyle(
color = colors.textPrimary,
fontSize = 15.sp
),
cursorBrush = SolidColor(colors.primary),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box {
if (inputText.isEmpty()) {
Text(
text = "告诉肉包你想做什么...",
color = colors.textHint,
fontSize = 15.sp
)
}
innerTextField()
}
}
)
} else {
// Shizuku 未连接,显示提示文字
Text(
text = "请先连接 Shizuku",
color = colors.textHint,
fontSize = 15.sp
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// 发送按钮
IconButton(
onClick = onExecute,
enabled = enabled && inputText.isNotBlank(),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (inputText.isNotBlank() && enabled) colors.primary
else colors.backgroundInput
)
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "发送",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
@@ -0,0 +1,247 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import kotlinx.coroutines.launch
data class OnboardingPage(
val icon: ImageVector,
val title: String,
val description: String,
val iconColor: Color = Primary
)
val onboardingPages = listOf(
OnboardingPage(
icon = Icons.Outlined.Star,
title = "欢迎使用肉包",
description = "肉包是一个智能自动化助手,\n可以帮你操作手机完成各种任务",
iconColor = Color(0xFF6366F1) // Indigo
),
OnboardingPage(
icon = Icons.Outlined.Settings,
title = "AI 驱动",
description = "基于先进的视觉语言模型,\n肉包能够理解屏幕内容并做出智能决策",
iconColor = Color(0xFF8B5CF6) // Violet
),
OnboardingPage(
icon = Icons.Outlined.Home,
title = "简单易用",
description = "只需用自然语言描述你想做的事,\n肉包会自动帮你完成",
iconColor = Color(0xFF06B6D4) // Cyan
),
OnboardingPage(
icon = Icons.Filled.Lock,
title = "安全可靠",
description = "遇到敏感页面(如支付、密码)会自动停止,\n保护你的账户安全",
iconColor = Color(0xFF10B981) // Emerald
)
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnboardingScreen(
onComplete: () -> Unit
) {
val colors = BaoziTheme.colors
val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
colors.background,
colors.backgroundCard
)
)
)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 页面内容
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
OnboardingPageContent(
page = onboardingPages[page],
modifier = Modifier.fillMaxSize()
)
}
// 指示器
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(onboardingPages.size) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.size(if (isSelected) 24.dp else 8.dp, 8.dp)
.clip(CircleShape)
.background(
if (isSelected) Primary else colors.textHint.copy(alpha = 0.3f)
)
.animateContentSize()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 底部按钮
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// 跳过按钮
TextButton(
onClick = onComplete,
modifier = Modifier.weight(1f)
) {
Text(
text = "跳过",
color = colors.textSecondary,
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
// 下一步/开始按钮
Button(
onClick = {
if (pagerState.currentPage < onboardingPages.size - 1) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onComplete()
}
},
modifier = Modifier
.weight(2f)
.height(52.dp),
shape = RoundedCornerShape(26.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Primary
)
) {
Text(
text = if (pagerState.currentPage < onboardingPages.size - 1) "下一步" else "开始使用",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun OnboardingPageContent(
page: OnboardingPage,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Column(
modifier = modifier.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 动画图标
val infiniteTransition = rememberInfiniteTransition(label = "icon")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.08f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
Box(
modifier = Modifier
.size(160.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
page.iconColor.copy(alpha = 0.15f),
page.iconColor.copy(alpha = 0.05f),
colors.background.copy(alpha = 0f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = page.icon,
contentDescription = page.title,
modifier = Modifier.size((72 * scale).dp),
tint = page.iconColor
)
}
Spacer(modifier = Modifier.height(48.dp))
// 标题
Text(
text = page.title,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// 描述
Text(
text = page.description,
fontSize = 16.sp,
color = colors.textSecondary,
textAlign = TextAlign.Center,
lineHeight = 26.sp
)
}
}
@@ -0,0 +1,180 @@
package com.roubao.autopilot.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// 主色调
val Primary = Color(0xFFD67744)
val PrimaryDark = Color(0xFFB85E2E)
val PrimaryLight = Color(0xFFE89060)
val Secondary = Color(0xFFEFB773)
val SecondaryDark = Color(0xFFD69B52)
val SecondaryLight = Color(0xFFF5CB94)
// 深色主题背景色
val BackgroundDark = Color(0xFF1A1A1A)
val BackgroundCard = Color(0xFF252525)
val BackgroundInput = Color(0xFF2A2A2A)
val SurfaceVariant = Color(0xFF303030)
// 深色主题文字颜色
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB0B0B0)
val TextHint = Color(0xFF666666)
// 浅色主题背景色 - 纯白简洁风格
val BackgroundLight = Color(0xFFFFFFFF)
val BackgroundCardLight = Color(0xFFF5F5F5)
val BackgroundInputLight = Color(0xFFEEEEEE)
val SurfaceVariantLight = Color(0xFFE0E0E0)
// 浅色主题文字颜色
val TextPrimaryLight = Color(0xFF212121)
val TextSecondaryLight = Color(0xFF757575)
val TextHintLight = Color(0xFFBDBDBD)
// 状态颜色
val Success = Color(0xFF4CAF50)
val Error = Color(0xFFF44336)
val Warning = Color(0xFFFF9800)
// 主题颜色数据类
data class BaoziColors(
val primary: Color,
val primaryDark: Color,
val primaryLight: Color,
val secondary: Color,
val background: Color,
val backgroundCard: Color,
val backgroundInput: Color,
val surfaceVariant: Color,
val textPrimary: Color,
val textSecondary: Color,
val textHint: Color,
val success: Color,
val error: Color,
val warning: Color,
val isDark: Boolean
)
// 深色主题颜色
val DarkBaoziColors = BaoziColors(
primary = Primary,
primaryDark = PrimaryDark,
primaryLight = PrimaryLight,
secondary = Secondary,
background = BackgroundDark,
backgroundCard = BackgroundCard,
backgroundInput = BackgroundInput,
surfaceVariant = SurfaceVariant,
textPrimary = TextPrimary,
textSecondary = TextSecondary,
textHint = TextHint,
success = Success,
error = Error,
warning = Warning,
isDark = true
)
// 浅色主题颜色
val LightBaoziColors = BaoziColors(
primary = Primary,
primaryDark = PrimaryDark,
primaryLight = PrimaryLight,
secondary = Secondary,
background = BackgroundLight,
backgroundCard = BackgroundCardLight,
backgroundInput = BackgroundInputLight,
surfaceVariant = SurfaceVariantLight,
textPrimary = TextPrimaryLight,
textSecondary = TextSecondaryLight,
textHint = TextHintLight,
success = Success,
error = Error,
warning = Warning,
isDark = false
)
// CompositionLocal 用于访问当前主题颜色
val LocalBaoziColors = staticCompositionLocalOf { DarkBaoziColors }
// Material 3 深色配色方案
private val DarkColorScheme = darkColorScheme(
primary = Primary,
onPrimary = Color.White,
primaryContainer = PrimaryDark,
onPrimaryContainer = Color.White,
secondary = Secondary,
onSecondary = Color.Black,
secondaryContainer = SecondaryDark,
onSecondaryContainer = Color.White,
background = BackgroundDark,
onBackground = TextPrimary,
surface = BackgroundCard,
onSurface = TextPrimary,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = TextSecondary,
error = Error,
onError = Color.White
)
// Material 3 浅色配色方案
private val LightColorScheme = lightColorScheme(
primary = Primary,
onPrimary = Color.White,
primaryContainer = PrimaryLight,
onPrimaryContainer = Color.Black,
secondary = Secondary,
onSecondary = Color.Black,
secondaryContainer = SecondaryLight,
onSecondaryContainer = Color.Black,
background = BackgroundLight,
onBackground = TextPrimaryLight,
surface = BackgroundCardLight,
onSurface = TextPrimaryLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = TextSecondaryLight,
error = Error,
onError = Color.White
)
// 主题模式枚举
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM
}
@Composable
fun BaoziTheme(
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit
) {
val isDarkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
val colorScheme = if (isDarkTheme) DarkColorScheme else LightColorScheme
val baoziColors = if (isDarkTheme) DarkBaoziColors else LightBaoziColors
CompositionLocalProvider(LocalBaoziColors provides baoziColors) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
}
// 便捷访问当前主题颜色
object BaoziTheme {
val colors: BaoziColors
@Composable
get() = LocalBaoziColors.current
}
@@ -0,0 +1,245 @@
package com.roubao.autopilot.utils
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.*
/**
* 全局崩溃捕获器
* 捕获未处理的异常并保存到本地文件
*/
class CrashHandler private constructor() : Thread.UncaughtExceptionHandler {
private var context: Context? = null
private var defaultHandler: Thread.UncaughtExceptionHandler? = null
companion object {
private const val LOG_DIR = "crash_logs"
private const val MAX_LOG_FILES = 10 // 最多保留10个日志文件
@Volatile
private var instance: CrashHandler? = null
fun getInstance(): CrashHandler {
return instance ?: synchronized(this) {
instance ?: CrashHandler().also { instance = it }
}
}
/**
* 获取日志目录
*/
fun getLogDir(context: Context): File {
val dir = File(context.filesDir, LOG_DIR)
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
/**
* 获取所有日志文件
*/
fun getLogFiles(context: Context): List<File> {
val dir = getLogDir(context)
return dir.listFiles()
?.filter { it.isFile && it.name.endsWith(".log") }
?.sortedByDescending { it.lastModified() }
?: emptyList()
}
/**
* 导出日志(合并所有日志到一个文件)
*/
fun exportLogs(context: Context): File? {
val logFiles = getLogFiles(context)
if (logFiles.isEmpty()) {
return null
}
val exportFile = File(context.cacheDir, "roubao_logs_${System.currentTimeMillis()}.txt")
try {
FileWriter(exportFile).use { writer ->
// 写入设备信息
writer.write("========== 设备信息 ==========\n")
writer.write("设备: ${Build.MANUFACTURER} ${Build.MODEL}\n")
writer.write("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
writer.write("应用版本: ${getAppVersion(context)}\n")
writer.write("导出时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())}\n")
writer.write("\n")
// 合并所有日志
logFiles.forEach { file ->
writer.write("========== ${file.name} ==========\n")
writer.write(file.readText())
writer.write("\n\n")
}
}
return exportFile
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
/**
* 分享日志文件
*/
fun shareLogs(context: Context) {
val exportFile = exportLogs(context)
if (exportFile == null) {
android.widget.Toast.makeText(context, "没有日志可导出", android.widget.Toast.LENGTH_SHORT).show()
return
}
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
exportFile
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "肉包 App 日志")
putExtra(Intent.EXTRA_TEXT, "请查看附件中的日志文件")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "分享日志"))
} catch (e: Exception) {
e.printStackTrace()
android.widget.Toast.makeText(context, "分享失败: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
}
}
/**
* 清除所有日志
*/
fun clearLogs(context: Context) {
val dir = getLogDir(context)
dir.listFiles()?.forEach { it.delete() }
}
/**
* 获取日志统计信息
*/
fun getLogStats(context: Context): String {
val files = getLogFiles(context)
if (files.isEmpty()) {
return "暂无日志"
}
val totalSize = files.sumOf { it.length() }
val sizeStr = if (totalSize > 1024) "${totalSize / 1024} KB" else "$totalSize B"
return "${files.size} 个文件, $sizeStr"
}
private fun getAppVersion(context: Context): String {
return try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
"${pInfo.versionName} (${pInfo.longVersionCode})"
} catch (e: Exception) {
"Unknown"
}
}
}
/**
* 初始化
*/
fun init(context: Context) {
this.context = context.applicationContext
defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
// 清理旧日志
cleanOldLogs()
}
override fun uncaughtException(thread: Thread, throwable: Throwable) {
// 保存崩溃日志
saveCrashLog(throwable)
// 调用默认处理器(让系统显示崩溃对话框或直接退出)
defaultHandler?.uncaughtException(thread, throwable)
}
/**
* 保存崩溃日志
*/
private fun saveCrashLog(throwable: Throwable) {
val ctx = context ?: return
try {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "crash_$timestamp.log"
val file = File(getLogDir(ctx), fileName)
PrintWriter(FileWriter(file)).use { writer ->
// 时间
writer.println("========== 崩溃时间 ==========")
writer.println(SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()))
writer.println()
// 设备信息
writer.println("========== 设备信息 ==========")
writer.println("设备: ${Build.MANUFACTURER} ${Build.MODEL}")
writer.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
writer.println("CPU ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
writer.println("应用版本: ${getAppVersion(ctx)}")
writer.println()
// 异常信息
writer.println("========== 异常信息 ==========")
throwable.printStackTrace(writer)
}
println("[CrashHandler] 崩溃日志已保存: $fileName")
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 记录普通日志(非崩溃)
*/
fun log(tag: String, message: String, throwable: Throwable? = null) {
val ctx = context ?: return
try {
val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
val fileName = "log_$today.log"
val file = File(getLogDir(ctx), fileName)
FileWriter(file, true).use { writer ->
val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
writer.appendLine("[$time] [$tag] $message")
throwable?.let {
val sw = java.io.StringWriter()
it.printStackTrace(PrintWriter(sw))
writer.appendLine(sw.toString())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 清理旧日志,只保留最近的文件
*/
private fun cleanOldLogs() {
val ctx = context ?: return
val files = getLogFiles(ctx)
if (files.size > MAX_LOG_FILES) {
files.drop(MAX_LOG_FILES).forEach { it.delete() }
}
}
}
@@ -0,0 +1,295 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* GUI-Owl API 客户端
* 专用于阿里云 GUI Agent 服务(非 OpenAI 兼容格式)
*
* API 特点:
* - 专用端点: https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server
* - 返回直接的操作指令: Click(x,y), Swipe(x1,y1,x2,y2) 等
* - 支持 session_id 管理多轮操作
*/
class GUIOwlClient(
private val apiKey: String,
private val model: String = "pre-gui_owl_7b",
private val deviceType: String = "mobile",
private val thoughtLanguage: String = "chinese"
) {
companion object {
private const val TAG = "GUIOwlClient"
private const val ENDPOINT = "https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server"
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
// 会话 ID(用于关联多轮操作)
private var sessionId: String = ""
/**
* GUI-Owl 响应结果
*/
data class GUIOwlResponse(
val thought: String, // 思考过程
val operation: String, // 操作指令: Click (x, y, x, y) / Swipe (x1, y1, x2, y2) 等
val explanation: String, // 操作说明
val sessionId: String, // 会话 ID
val rawResponse: String // 原始响应
)
/**
* 解析操作指令为 Action
*/
data class ParsedAction(
val type: String, // click, swipe, type, etc.
val x: Int? = null,
val y: Int? = null,
val x2: Int? = null,
val y2: Int? = null,
val text: String? = null
)
/**
* 调用 GUI-Owl 进行界面理解和操作推理
*
* @param instruction 用户指令
* @param imageUrl 截图 URL(支持 http/https 或 data:image/... base64)
* @param addInfo 额外的操作提示信息
* @return GUIOwlResponse
*/
suspend fun predict(
instruction: String,
imageUrl: String,
addInfo: String = ""
): Result<GUIOwlResponse> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
for (attempt in 1..MAX_RETRIES) {
try {
val messagesArray = JSONArray().apply {
put(JSONObject().put("image", imageUrl))
put(JSONObject().put("instruction", instruction))
put(JSONObject().put("session_id", sessionId))
put(JSONObject().put("device_type", deviceType))
put(JSONObject().put("pipeline_type", "agent"))
put(JSONObject().put("model_name", model))
put(JSONObject().put("thought_language", thoughtLanguage))
put(JSONObject().put("param_list", JSONArray().apply {
put(JSONObject().put("add_info", addInfo))
}))
}
val dataObj = JSONObject().apply {
put("messages", messagesArray)
}
val contentArray = JSONArray().apply {
put(JSONObject().apply {
put("type", "data")
put("data", dataObj)
})
}
val inputArray = JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", contentArray)
})
}
val requestBody = JSONObject().apply {
put("app_id", "gui-owl")
put("input", inputArray)
}
val request = Request.Builder()
.url(ENDPOINT)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
println("[$TAG] 请求: instruction=$instruction")
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
// 更新 session_id
val newSessionId = json.optString("session_id", "")
if (newSessionId.isNotEmpty()) {
sessionId = newSessionId
}
// 解析 output
val outputArray = json.optJSONArray("output")
if (outputArray != null && outputArray.length() > 0) {
val output = outputArray.getJSONObject(0)
val contentArr = output.optJSONArray("content")
if (contentArr != null && contentArr.length() > 0) {
val content = contentArr.getJSONObject(0)
val data = content.optJSONObject("data")
if (data != null) {
val result = GUIOwlResponse(
thought = data.optString("Thought", ""),
operation = data.optString("Operation", ""),
explanation = data.optString("Explanation", ""),
sessionId = sessionId,
rawResponse = responseBody
)
println("[$TAG] 响应: operation=${result.operation}")
return@withContext Result.success(result)
}
}
}
lastException = Exception("Invalid response format: $responseBody")
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: Exception) {
println("[$TAG] 请求失败 (attempt $attempt): ${e.message}")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 使用 Bitmap 调用 GUI-Owl
* 自动将 Bitmap 转换为 base64 data URL
*/
suspend fun predict(
instruction: String,
image: Bitmap,
addInfo: String = ""
): Result<GUIOwlResponse> {
val imageUrl = bitmapToDataUrl(image)
return predict(instruction, imageUrl, addInfo)
}
/**
* 解析操作指令字符串为 ParsedAction
*
* 支持的格式:
* - Click (x, y, x, y) 或 Click (x, y)
* - Swipe (x1, y1, x2, y2)
* - Type (text)
* - Long_press (x, y)
* - Scroll (direction)
*/
fun parseOperation(operation: String): ParsedAction? {
val trimmed = operation.trim()
// Click (x, y, x, y) 或 Click (x, y)
val clickPattern = Regex("""Click\s*\(\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*\d+\s*,\s*\d+)?\s*\)""", RegexOption.IGNORE_CASE)
clickPattern.find(trimmed)?.let { match ->
val x = match.groupValues[1].toIntOrNull() ?: return null
val y = match.groupValues[2].toIntOrNull() ?: return null
return ParsedAction(type = "click", x = x, y = y)
}
// Swipe (x1, y1, x2, y2)
val swipePattern = Regex("""Swipe\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)""", RegexOption.IGNORE_CASE)
swipePattern.find(trimmed)?.let { match ->
val x1 = match.groupValues[1].toIntOrNull() ?: return null
val y1 = match.groupValues[2].toIntOrNull() ?: return null
val x2 = match.groupValues[3].toIntOrNull() ?: return null
val y2 = match.groupValues[4].toIntOrNull() ?: return null
return ParsedAction(type = "swipe", x = x1, y = y1, x2 = x2, y2 = y2)
}
// Long_press (x, y) 或 LongPress (x, y)
val longPressPattern = Regex("""Long[_\s]?[Pp]ress\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)""", RegexOption.IGNORE_CASE)
longPressPattern.find(trimmed)?.let { match ->
val x = match.groupValues[1].toIntOrNull() ?: return null
val y = match.groupValues[2].toIntOrNull() ?: return null
return ParsedAction(type = "long_press", x = x, y = y)
}
// Type (text) 或 Input (text)
val typePattern = Regex("""(?:Type|Input)\s*\(\s*["\']?(.+?)["\']?\s*\)""", RegexOption.IGNORE_CASE)
typePattern.find(trimmed)?.let { match ->
val text = match.groupValues[1]
return ParsedAction(type = "type", text = text)
}
// Scroll (direction) 或 Scroll_down / Scroll_up
val scrollPattern = Regex("""Scroll[_\s]?(up|down|left|right)?""", RegexOption.IGNORE_CASE)
scrollPattern.find(trimmed)?.let { match ->
val direction = match.groupValues.getOrNull(1)?.lowercase() ?: "down"
return ParsedAction(type = "scroll", text = direction)
}
// Back
if (trimmed.contains("Back", ignoreCase = true)) {
return ParsedAction(type = "system_button", text = "Back")
}
// Home
if (trimmed.contains("Home", ignoreCase = true)) {
return ParsedAction(type = "system_button", text = "Home")
}
// FINISH / DONE / COMPLETE
if (trimmed.contains(Regex("FINISH|DONE|COMPLETE|Finished", RegexOption.IGNORE_CASE))) {
return ParsedAction(type = "finish")
}
println("[$TAG] 无法解析操作: $operation")
return null
}
/**
* 重置会话(开始新任务时调用)
*/
fun resetSession() {
sessionId = ""
}
/**
* 获取当前会话 ID
*/
fun getSessionId(): String = sessionId
/**
* Bitmap 转换为 data URL
*/
private fun bitmapToDataUrl(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[$TAG] 图片压缩: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
return "data:image/jpeg;base64,$base64"
}
}
@@ -0,0 +1,390 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* MAI-UI 专用客户端
* 实现 MAI-UI 的特定 prompt 格式和对话历史管理
*/
class MAIUIClient(
private val baseUrl: String = "http://localhost:8000/v1",
private val model: String = "MAI-UI-2B",
private val historyN: Int = 3 // 保留的历史图片数量
) {
companion object {
private const val SCALE_FACTOR = 999
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
// 对话历史
private val historyImages = mutableListOf<String>() // Base64 encoded images
private val historyResponses = mutableListOf<String>() // Assistant responses
// 可用应用列表 (会在运行时更新)
private var availableApps: List<String> = emptyList()
/**
* 系统提示词 (参考 MAI-UI 官方实现)
*/
private fun getSystemPrompt(): String = """
You are a GUI agent. You are given a task and your action history, with screenshots. You need to perform the next action to complete the task.
## Output Format
For each function call, return the thinking process in <thinking> </thinking> tags, and a json object with function name and arguments within <tool_call></tool_call> XML tags:
```
<thinking>
...
</thinking>
<tool_call>
{"name": "mobile_use", "arguments": <args-json-object>}
</tool_call>
```
## Action Space
{"action": "click", "coordinate": [x, y]}
{"action": "long_press", "coordinate": [x, y]}
{"action": "type", "text": ""}
{"action": "swipe", "direction": "up or down or left or right", "coordinate": [x, y]}
{"action": "open", "text": "app_name"}
{"action": "drag", "start_coordinate": [x1, y1], "end_coordinate": [x2, y2]}
{"action": "system_button", "button": "button_name"}
{"action": "wait"}
{"action": "terminate", "status": "success or fail"}
{"action": "answer", "text": "xxx"}
{"action": "ask_user", "text": "xxx"}
## Note
- Write a small plan and finally summarize your next action (with its target element) in one sentence in <thinking></thinking> part.
- Available Apps: `${if (availableApps.isNotEmpty()) availableApps.toString() else "[请通过open动作打开应用]"}`.
- You should use the `open` action to open the app as possible as you can, because it is the fast way to open the app.
- You must follow the Action Space strictly, and return the correct json object within <thinking> </thinking> and <tool_call></tool_call> XML tags.
""".trimIndent()
/**
* 设置可用应用列表
*/
fun setAvailableApps(apps: List<String>) {
availableApps = apps
}
/**
* 重置对话历史
*/
fun reset() {
historyImages.clear()
historyResponses.clear()
}
/**
* 预测下一步动作
* @param instruction 用户指令
* @param screenshot 当前截图
* @return Result<MAIUIResponse>
*/
suspend fun predict(
instruction: String,
screenshot: Bitmap
): Result<MAIUIResponse> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
// 编码当前截图
val currentImageBase64 = bitmapToBase64(screenshot)
for (attempt in 1..MAX_RETRIES) {
try {
// 构建消息
val messages = buildMessages(instruction, currentImageBase64)
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messages)
put("max_tokens", 2048)
put("temperature", 0.0)
put("top_p", 1.0)
}
val request = Request.Builder()
.url("${normalizeUrl(baseUrl)}/chat/completions")
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val content = message.getString("content")
println("[MAIUIClient] Raw response: $content")
// 解析响应
val parsed = parseResponse(content)
// 保存到历史
historyImages.add(currentImageBase64)
historyResponses.add(content)
// 限制历史数量
while (historyImages.size > historyN) {
historyImages.removeAt(0)
historyResponses.removeAt(0)
}
return@withContext Result.success(parsed)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: Exception) {
println("[MAIUIClient] Error on attempt $attempt: ${e.message}")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 构建消息列表 (参考 MAI-UI 官方实现)
*/
private fun buildMessages(instruction: String, currentImageBase64: String): JSONArray {
val messages = JSONArray()
// 1. System message
messages.put(JSONObject().apply {
put("role", "system")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", getSystemPrompt())
})
})
})
// 2. User instruction
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", instruction)
})
})
})
// 3. History (image + assistant response pairs)
val startIdx = maxOf(0, historyImages.size - (historyN - 1))
for (i in startIdx until historyImages.size) {
// User message with image
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,${historyImages[i]}")
})
})
})
})
// Assistant response
messages.put(JSONObject().apply {
put("role", "assistant")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", historyResponses[i])
})
})
})
}
// 4. Current image
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,$currentImageBase64")
})
})
})
})
return messages
}
/**
* 解析 MAI-UI 响应
*/
private fun parseResponse(text: String): MAIUIResponse {
var processedText = text.trim()
// 处理 thinking model 输出格式 (</think> instead of </thinking>)
if (processedText.contains("</think>") && !processedText.contains("</thinking>")) {
processedText = processedText.replace("</think>", "</thinking>")
processedText = "<thinking>$processedText"
}
// 提取 thinking
val thinkingRegex = Regex("<thinking>(.*?)</thinking>", RegexOption.DOT_MATCHES_ALL)
val thinking = thinkingRegex.find(processedText)?.groupValues?.get(1)?.trim() ?: ""
// 提取 tool_call
val toolCallRegex = Regex("<tool_call>\\s*(.+?)\\s*</tool_call>", RegexOption.DOT_MATCHES_ALL)
val toolCallMatch = toolCallRegex.find(processedText)
var action: MAIUIAction? = null
if (toolCallMatch != null) {
try {
val toolCallJson = JSONObject(toolCallMatch.groupValues[1].trim())
val arguments = toolCallJson.optJSONObject("arguments")
if (arguments != null) {
action = parseAction(arguments)
}
} catch (e: Exception) {
println("[MAIUIClient] Failed to parse tool_call: ${e.message}")
}
}
return MAIUIResponse(
thinking = thinking,
action = action,
rawResponse = text
)
}
/**
* 解析动作
*/
private fun parseAction(arguments: JSONObject): MAIUIAction {
val actionType = arguments.optString("action", "")
// 解析坐标 (0-999 归一化)
val coordinate = arguments.optJSONArray("coordinate")
var x: Float? = null
var y: Float? = null
if (coordinate != null && coordinate.length() >= 2) {
x = coordinate.getDouble(0).toFloat() / SCALE_FACTOR
y = coordinate.getDouble(1).toFloat() / SCALE_FACTOR
}
// 解析 drag 坐标
val startCoord = arguments.optJSONArray("start_coordinate")
val endCoord = arguments.optJSONArray("end_coordinate")
var startX: Float? = null
var startY: Float? = null
var endX: Float? = null
var endY: Float? = null
if (startCoord != null && endCoord != null) {
startX = startCoord.getDouble(0).toFloat() / SCALE_FACTOR
startY = startCoord.getDouble(1).toFloat() / SCALE_FACTOR
endX = endCoord.getDouble(0).toFloat() / SCALE_FACTOR
endY = endCoord.getDouble(1).toFloat() / SCALE_FACTOR
}
return MAIUIAction(
type = actionType,
x = x,
y = y,
startX = startX,
startY = startY,
endX = endX,
endY = endY,
text = arguments.optString("text", null),
button = arguments.optString("button", null),
direction = arguments.optString("direction", null),
status = arguments.optString("status", null)
)
}
private fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[MAIUIClient] Image compressed: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
private fun normalizeUrl(url: String): String {
var normalized = url.trim().removeSuffix("/")
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "http://$normalized"
}
return normalized
}
}
/**
* MAI-UI 响应
*/
data class MAIUIResponse(
val thinking: String,
val action: MAIUIAction?,
val rawResponse: String
)
/**
* MAI-UI 动作 (坐标已归一化为 0-1)
*/
data class MAIUIAction(
val type: String,
val x: Float? = null, // 归一化坐标 0-1
val y: Float? = null,
val startX: Float? = null, // drag 起点
val startY: Float? = null,
val endX: Float? = null, // drag 终点
val endY: Float? = null,
val text: String? = null,
val button: String? = null,
val direction: String? = null, // swipe 方向: up, down, left, right
val status: String? = null // terminate 状态: success, fail
) {
/**
* 转换为屏幕像素坐标
*/
fun toScreenCoordinates(screenWidth: Int, screenHeight: Int): MAIUIAction {
return copy(
x = x?.let { it * screenWidth },
y = y?.let { it * screenHeight },
startX = startX?.let { it * screenWidth },
startY = startY?.let { it * screenHeight },
endX = endX?.let { it * screenWidth },
endY = endY?.let { it * screenHeight }
)
}
}
@@ -0,0 +1,351 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
/**
* VLM (Vision Language Model) API 客户端
* 支持 OpenAI 兼容接口 (GPT-4V, Qwen-VL, Claude, etc.)
*/
class VLMClient(
private val apiKey: String,
baseUrl: String = "https://api.openai.com/v1",
private val model: String = "gpt-4-vision-preview"
) {
// 规范化 URL:自动添加 https:// 前缀,移除末尾斜杠
private val baseUrl: String = normalizeUrl(baseUrl)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
/** 规范化 URL:自动添加 https:// 前缀,移除末尾斜杠 */
private fun normalizeUrl(url: String): String {
var normalized = url.trim().removeSuffix("/")
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "https://$normalized"
}
return normalized
}
/**
* 从 API 获取可用模型列表
* @param baseUrl API 基础地址
* @param apiKey API 密钥
* @return 模型 ID 列表
*/
suspend fun fetchModels(baseUrl: String, apiKey: String): Result<List<String>> = withContext(Dispatchers.IO) {
// 验证 baseUrl 是否为空
if (baseUrl.isBlank()) {
return@withContext Result.failure(Exception("Base URL 不能为空"))
}
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
// 清理 URL,确保正确拼接
val cleanBaseUrl = normalizeUrl(baseUrl.removeSuffix("/chat/completions"))
val request = try {
Request.Builder()
.url("$cleanBaseUrl/models")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.get()
.build()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(Exception("Base URL 格式无效: ${e.message}"))
}
try {
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val data = json.optJSONArray("data") ?: JSONArray()
val models = mutableListOf<String>()
for (i in 0 until data.length()) {
val item = data.optJSONObject(i)
if (item != null) {
val id = item.optString("id", "").trim()
if (id.isNotEmpty()) {
models.add(id)
}
}
}
Result.success(models)
} else {
Result.failure(Exception("HTTP ${response.code}: $responseBody"))
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* 调用 VLM 进行多模态推理 (带重试)
*/
suspend fun predict(
prompt: String,
images: List<Bitmap> = emptyList()
): Result<String> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
// 预先编码图片 (避免重试时重复编码)
val encodedImages = images.map { bitmapToBase64Url(it) }
for (attempt in 1..MAX_RETRIES) {
try {
val content = JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", prompt)
})
encodedImages.forEach { imageUrl ->
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", imageUrl)
})
})
}
}
val messages = JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", content)
})
}
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messages)
put("max_tokens", 4096)
put("temperature", 0.0)
put("top_p", 0.85)
put("frequency_penalty", 0.2) // 减少重复输出
}
val request = Request.Builder()
.url("$baseUrl/chat/completions")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val responseContent = message.getString("content")
return@withContext Result.success(responseContent)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: UnknownHostException) {
// DNS 解析失败,重试
println("[VLMClient] DNS 解析失败,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.net.SocketTimeoutException) {
// 超时,重试
println("[VLMClient] 请求超时,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.io.IOException) {
// IO 错误,重试
println("[VLMClient] IO 错误: ${e.message},重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: Exception) {
// 其他错误,不重试
return@withContext Result.failure(e)
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 调用 VLM 进行多模态推理 (使用完整对话历史)
* @param messagesJson OpenAI 兼容的 messages JSON 数组
*/
suspend fun predictWithContext(
messagesJson: JSONArray
): Result<String> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
for (attempt in 1..MAX_RETRIES) {
try {
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messagesJson)
put("max_tokens", 4096)
put("temperature", 0.0)
}
val request = Request.Builder()
.url("$baseUrl/chat/completions")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val responseContent = message.getString("content")
return@withContext Result.success(responseContent)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: UnknownHostException) {
println("[VLMClient] DNS 解析失败,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.net.SocketTimeoutException) {
println("[VLMClient] 请求超时,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.io.IOException) {
println("[VLMClient] IO 错误: ${e.message},重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: Exception) {
return@withContext Result.failure(e)
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* Bitmap 转 Base64 URL (只压缩质量,不压缩分辨率)
* 保持原始分辨率以确保坐标准确
*/
private fun bitmapToBase64Url(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
// 使用 JPEG 格式,质量 70%,保持原始分辨率
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[VLMClient] 图片压缩: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
return "data:image/jpeg;base64,$base64"
}
/**
* 调整图片大小
*/
private fun resizeBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
if (width <= maxWidth && height <= maxHeight) {
return bitmap
}
val ratio = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height)
val newWidth = (width * ratio).toInt()
val newHeight = (height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
}
/**
* 常用 VLM 配置
*/
object VLMConfigs {
// OpenAI GPT-4V
fun gpt4v(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://api.openai.com/v1",
model = "gpt-4-vision-preview"
)
// Qwen-VL (阿里云)
fun qwenVL(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1",
model = "qwen-vl-max"
)
// Claude (Anthropic)
fun claude(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://api.anthropic.com/v1",
model = "claude-3-5-sonnet-20241022"
)
// 自定义 (vLLM / Ollama / LocalAI)
fun custom(apiKey: String, baseUrl: String, model: String) = VLMClient(
apiKey = apiKey,
baseUrl = baseUrl,
model = model
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App Name -->
<string name="app_name">Baozi</string>
<!-- Tab Titles -->
<string name="tab_home">Baozi</string>
<string name="tab_history">History</string>
<string name="tab_settings">Settings</string>
<!-- Home Screen -->
<string name="home_ready">Ready. Tell me what you want to do</string>
<string name="home_connect_shizuku">Please connect Shizuku first</string>
<string name="home_try_these">Try these commands</string>
<string name="home_input_placeholder">Tell Baozi what you want to do…</string>
<string name="home_executing">Executing Step %1$d</string>
<string name="home_send">Send</string>
<string name="home_stop">Stop</string>
<!-- Preset Commands -->
<string name="preset_burger">Order Burger</string>
<string name="preset_burger_cmd">Order a tasty burger nearby for me</string>
<string name="preset_weibo">Post to Weibo</string>
<string name="preset_weibo_cmd">Post my latest photo to Weibo</string>
<string name="preset_bilibili">Watch Bilibili</string>
<string name="preset_bilibili_cmd">I want to watch trending videos on Bilibili</string>
<string name="preset_takeout">Order Takeout</string>
<string name="preset_takeout_cmd">Order a pork trotter rice on Meituan</string>
<string name="preset_music">Listen to Music</string>
<string name="preset_music_cmd">Open NetEase Cloud Music and play daily recommendations</string>
<string name="preset_message">Send Message</string>
<string name="preset_message_cmd">Send a message to my recent contact saying I\'m busy</string>
<!-- History Screen -->
<string name="history_title">Execution History</string>
<string name="history_count">%1$d records</string>
<string name="history_empty">No execution records</string>
<string name="history_empty_hint">Records will appear here after executing tasks</string>
<string name="history_steps">%1$d steps</string>
<string name="history_delete">Delete</string>
<string name="history_delete_title">Delete Record</string>
<string name="history_delete_confirm">Are you sure you want to delete this record?</string>
<string name="history_timeline">Execution Timeline</string>
<string name="history_instruction">Task Instruction</string>
<!-- Status -->
<string name="status_completed">Completed</string>
<string name="status_failed">Failed</string>
<string name="status_stopped">Stopped</string>
<string name="status_running">Running</string>
<string name="status_label">Status</string>
<string name="steps_label">Steps</string>
<string name="duration_label">Duration</string>
<!-- Settings Screen -->
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Configure API and app options</string>
<!-- Settings - Appearance -->
<string name="settings_appearance">Appearance</string>
<string name="settings_theme">Theme Mode</string>
<string name="theme_light">Light Mode</string>
<string name="theme_dark">Dark Mode</string>
<string name="theme_system">Follow System</string>
<string name="theme_select">Select Theme</string>
<!-- Settings - API -->
<string name="settings_api">API Configuration</string>
<string name="settings_provider">API Provider</string>
<string name="settings_provider_custom">Custom</string>
<string name="settings_model">Model</string>
<string name="settings_api_key">API Key</string>
<string name="settings_api_key_set">Set (%1$s)</string>
<string name="settings_api_key_unset">Not set</string>
<string name="settings_api_key_hint">Enter your API Key</string>
<string name="settings_show">Show</string>
<string name="settings_hide">Hide</string>
<!-- Settings - Custom Models -->
<string name="settings_custom_models">Custom Models</string>
<string name="settings_add_model">Add Custom Model</string>
<string name="settings_add_model_hint">Add other supported models</string>
<string name="settings_model_name_hint">Enter model name or ID</string>
<string name="settings_model_name_example">e.g. gpt-4-vision</string>
<string name="settings_select_model">Select Model</string>
<!-- Settings - Help -->
<string name="settings_help">Help</string>
<string name="settings_shizuku_guide">Shizuku Guide</string>
<string name="settings_shizuku_guide_desc">Learn how to install and configure Shizuku</string>
<string name="settings_overlay_help">Overlay Permission</string>
<string name="settings_overlay_help_desc">Learn why overlay permission is needed</string>
<!-- Settings - About -->
<string name="settings_about">About</string>
<string name="settings_version">Version</string>
<string name="settings_app_desc">VLM-based Android automation tool</string>
<!-- Shizuku Status -->
<string name="shizuku_connected">Shizuku Connected</string>
<string name="shizuku_not_connected">Shizuku Not Connected</string>
<string name="shizuku_available">Device control available</string>
<string name="shizuku_unavailable">Please start Shizuku and grant permission</string>
<!-- Shizuku Help -->
<string name="shizuku_help_step1_title">Download Shizuku</string>
<string name="shizuku_help_step1_desc">Download Shizuku from Google Play or GitHub\n\n• Google Play: Search \"Shizuku\"\n• GitHub: github.com/RikkaApps/Shizuku</string>
<string name="shizuku_help_step2_title">Start Shizuku</string>
<string name="shizuku_help_step2_desc">Open Shizuku and choose a startup method:\n\n• Wireless debugging (recommended): Requires Android 11+, enable wireless debugging in developer options\n• Connect to computer: Start via ADB command</string>
<string name="shizuku_help_step3_title">Authorize Baozi</string>
<string name="shizuku_help_step3_desc">Find Baozi Autopilot in Shizuku\'s \"App Management\" and click authorize</string>
<string name="shizuku_help_step4_title">Start Using</string>
<string name="shizuku_help_step4_desc">After authorization, return to Baozi. The status card will show \"Shizuku Connected\"</string>
<!-- Overlay Help -->
<string name="overlay_why">Why is overlay permission needed?</string>
<string name="overlay_reason">Baozi needs to display an overlay during task execution to:</string>
<string name="overlay_feature1">Show current execution progress</string>
<string name="overlay_feature2">Provide a stop button to interrupt tasks anytime</string>
<string name="overlay_feature3">Display status information above other apps</string>
<string name="overlay_how">How to enable?</string>
<string name="overlay_how_desc">1. You\'ll be prompted when executing a task\n2. Or go to: Settings > Apps > Baozi > Overlay permission\n3. Enable \"Allow display over other apps\"</string>
<string name="overlay_privacy">Privacy &amp; Security</string>
<string name="overlay_privacy_desc">The overlay is only shown during task execution and does not collect any personal information. It automatically disappears after the task is complete.</string>
<!-- Onboarding -->
<string name="onboarding_welcome">Welcome to Baozi</string>
<string name="onboarding_welcome_desc">Baozi is an intelligent automation assistant\nthat helps you operate your phone to complete various tasks</string>
<string name="onboarding_ai">AI Powered</string>
<string name="onboarding_ai_desc">Based on advanced vision-language models,\nBaozi can understand screen content and make intelligent decisions</string>
<string name="onboarding_easy">Easy to Use</string>
<string name="onboarding_easy_desc">Just describe what you want to do in natural language,\nand Baozi will automatically complete it for you</string>
<string name="onboarding_safe">Safe &amp; Secure</string>
<string name="onboarding_safe_desc">Automatically stops when encountering sensitive pages\n(like payments, passwords) to protect your account</string>
<string name="onboarding_skip">Skip</string>
<string name="onboarding_next">Next</string>
<string name="onboarding_start">Get Started</string>
<!-- Common -->
<string name="btn_confirm">Confirm</string>
<string name="btn_cancel">Cancel</string>
<string name="btn_close">Close</string>
<string name="btn_add">Add</string>
<string name="btn_delete">Delete</string>
<string name="btn_back">Back</string>
<string name="btn_got_it">Got it</string>
<!-- Toast Messages -->
<string name="toast_enter_instruction">Please enter an instruction</string>
<string name="toast_enter_api_key">Please enter API Key</string>
<string name="toast_grant_overlay">Please grant overlay permission</string>
<string name="toast_shizuku_granted">Shizuku permission granted</string>
<string name="toast_start_shizuku">Please start Shizuku App first</string>
<string name="toast_shizuku_old">Shizuku version too old</string>
<string name="toast_task_complete">Task complete</string>
<string name="toast_user_stopped">User stopped</string>
<string name="toast_sensitive_page">Detected sensitive page (payment/password), safely stopped</string>
<string name="toast_max_steps">Reached maximum steps limit</string>
<string name="toast_error">Error: %1$s</string>
</resources>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 图标背景 -->
<color name="ic_launcher_background">#EFB471</color>
<color name="splash_background">#EFB471</color>
<!-- 主题色 -->
<color name="primary">#D67744</color>
<color name="primary_dark">#B85E2E</color>
<color name="primary_light">#E89060</color>
<color name="secondary">#EFB773</color>
<color name="secondary_dark">#D69B52</color>
<color name="secondary_light">#F5CB94</color>
<!-- 背景色 -->
<color name="background_dark">#1A1A1A</color>
<color name="background_card">#252525</color>
<color name="background_input">#2A2A2A</color>
<!-- 文字颜色 -->
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#B0B0B0</color>
<color name="text_hint">#666666</color>
<!-- 状态颜色 -->
<color name="success">#4CAF50</color>
<color name="error">#F44336</color>
<color name="warning">#FF9800</color>
</resources>
@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App Name -->
<string name="app_name">肉包</string>
<!-- Tab Titles -->
<string name="tab_home">肉包</string>
<string name="tab_history">记录</string>
<string name="tab_settings">设置</string>
<!-- Home Screen -->
<string name="home_ready">准备就绪,告诉我你想做什么</string>
<string name="home_connect_shizuku">请先连接 Shizuku</string>
<string name="home_try_these">试试这些指令</string>
<string name="home_input_placeholder">告诉肉包你想做什么…</string>
<string name="home_executing">正在执行 Step %1$d</string>
<string name="home_send">发送</string>
<string name="home_stop">停止</string>
<!-- Preset Commands -->
<string name="preset_burger">点汉堡</string>
<string name="preset_burger_cmd">帮我点个附近好吃的汉堡</string>
<string name="preset_weibo">发微博</string>
<string name="preset_weibo_cmd">帮我把最后一张照片发送到微博</string>
<string name="preset_bilibili">看B站</string>
<string name="preset_bilibili_cmd">我要看B站热门的视频</string>
<string name="preset_takeout">点外卖</string>
<string name="preset_takeout_cmd">帮我在美团点一份猪脚饭</string>
<string name="preset_music">听音乐</string>
<string name="preset_music_cmd">打开网易云音乐播放每日推荐</string>
<string name="preset_message">发消息</string>
<string name="preset_message_cmd">帮我给最近联系人发一条消息说在忙</string>
<!-- History Screen -->
<string name="history_title">执行记录</string>
<string name="history_count">共 %1$d 条记录</string>
<string name="history_empty">暂无执行记录</string>
<string name="history_empty_hint">执行任务后记录会显示在这里</string>
<string name="history_steps">%1$d 步</string>
<string name="history_delete">删除</string>
<string name="history_delete_title">删除记录</string>
<string name="history_delete_confirm">确定要删除这条执行记录吗?</string>
<string name="history_timeline">执行时间线</string>
<string name="history_instruction">任务指令</string>
<!-- Status -->
<string name="status_completed">已完成</string>
<string name="status_failed">失败</string>
<string name="status_stopped">已停止</string>
<string name="status_running">执行中</string>
<string name="status_label">状态</string>
<string name="steps_label">步骤数</string>
<string name="duration_label">耗时</string>
<!-- Settings Screen -->
<string name="settings_title">设置</string>
<string name="settings_subtitle">配置 API 和应用选项</string>
<!-- Settings - Appearance -->
<string name="settings_appearance">外观</string>
<string name="settings_theme">主题模式</string>
<string name="theme_light">浅色模式</string>
<string name="theme_dark">深色模式</string>
<string name="theme_system">跟随系统</string>
<string name="theme_select">选择主题</string>
<!-- Settings - API -->
<string name="settings_api">API 配置</string>
<string name="settings_provider">API 服务商</string>
<string name="settings_provider_custom">自定义</string>
<string name="settings_model">模型</string>
<string name="settings_api_key">API Key</string>
<string name="settings_api_key_set">已设置 (%1$s)</string>
<string name="settings_api_key_unset">未设置</string>
<string name="settings_api_key_hint">请输入您的 API Key</string>
<string name="settings_show">显示</string>
<string name="settings_hide">隐藏</string>
<!-- Settings - Custom Models -->
<string name="settings_custom_models">自定义模型</string>
<string name="settings_add_model">添加自定义模型</string>
<string name="settings_add_model_hint">添加其他支持的模型</string>
<string name="settings_model_name_hint">输入模型名称或 ID</string>
<string name="settings_model_name_example">例如: gpt-4-vision</string>
<string name="settings_select_model">选择模型</string>
<!-- Settings - Help -->
<string name="settings_help">帮助</string>
<string name="settings_shizuku_guide">Shizuku 使用指南</string>
<string name="settings_shizuku_guide_desc">了解如何安装和配置 Shizuku</string>
<string name="settings_overlay_help">悬浮窗权限说明</string>
<string name="settings_overlay_help_desc">了解为什么需要悬浮窗权限</string>
<!-- Settings - About -->
<string name="settings_about">关于</string>
<string name="settings_version">版本</string>
<string name="settings_app_desc">基于视觉语言模型的 Android 自动化工具</string>
<!-- Shizuku Status -->
<string name="shizuku_connected">Shizuku 已连接</string>
<string name="shizuku_not_connected">Shizuku 未连接</string>
<string name="shizuku_available">设备控制功能可用</string>
<string name="shizuku_unavailable">请启动 Shizuku 并授权</string>
<!-- Shizuku Help -->
<string name="shizuku_help_step1_title">下载 Shizuku</string>
<string name="shizuku_help_step1_desc">从 Google Play 或 GitHub 下载 Shizuku 应用\n\n• Google Play: 搜索 \"Shizuku\"\n• GitHub: github.com/RikkaApps/Shizuku</string>
<string name="shizuku_help_step2_title">启动 Shizuku</string>
<string name="shizuku_help_step2_desc">打开 Shizuku 应用,根据您的设备选择启动方式:\n\n• 无线调试(推荐):需要 Android 11+,在开发者选项中开启无线调试\n• 连接电脑:通过 ADB 命令启动</string>
<string name="shizuku_help_step3_title">授权肉包</string>
<string name="shizuku_help_step3_desc">在 Shizuku 的「应用管理」中找到肉包 Autopilot,点击授权按钮</string>
<string name="shizuku_help_step4_title">开始使用</string>
<string name="shizuku_help_step4_desc">授权完成后,返回肉包应用,状态卡片会显示「Shizuku 已连接」</string>
<!-- Overlay Help -->
<string name="overlay_why">为什么需要悬浮窗权限?</string>
<string name="overlay_reason">肉包在执行任务时需要显示悬浮窗来:</string>
<string name="overlay_feature1">显示当前执行进度</string>
<string name="overlay_feature2">提供停止按钮,随时中断任务</string>
<string name="overlay_feature3">在其他应用上方显示状态信息</string>
<string name="overlay_how">如何开启?</string>
<string name="overlay_how_desc">1. 点击执行任务时会自动提示\n2. 或前往:设置 > 应用 > 肉包 > 悬浮窗权限\n3. 开启「允许显示在其他应用上层」</string>
<string name="overlay_privacy">隐私安全</string>
<string name="overlay_privacy_desc">悬浮窗仅在任务执行期间显示,不会收集任何个人信息。任务完成后悬浮窗会自动消失。</string>
<!-- Onboarding -->
<string name="onboarding_welcome">欢迎使用肉包</string>
<string name="onboarding_welcome_desc">肉包是一个智能自动化助手,\n可以帮你操作手机完成各种任务</string>
<string name="onboarding_ai">AI 驱动</string>
<string name="onboarding_ai_desc">基于先进的视觉语言模型,\n肉包能够理解屏幕内容并做出智能决策</string>
<string name="onboarding_easy">简单易用</string>
<string name="onboarding_easy_desc">只需用自然语言描述你想做的事,\n肉包会自动帮你完成</string>
<string name="onboarding_safe">安全可靠</string>
<string name="onboarding_safe_desc">遇到敏感页面(如支付、密码)会自动停止,\n保护你的账户安全</string>
<string name="onboarding_skip">跳过</string>
<string name="onboarding_next">下一步</string>
<string name="onboarding_start">开始使用</string>
<!-- Common -->
<string name="btn_confirm">确定</string>
<string name="btn_cancel">取消</string>
<string name="btn_close">关闭</string>
<string name="btn_add">添加</string>
<string name="btn_delete">删除</string>
<string name="btn_back">返回</string>
<string name="btn_got_it">知道了</string>
<!-- Toast Messages -->
<string name="toast_enter_instruction">请输入指令</string>
<string name="toast_enter_api_key">请输入 API Key</string>
<string name="toast_grant_overlay">请授予悬浮窗权限</string>
<string name="toast_shizuku_granted">Shizuku 权限已获取</string>
<string name="toast_start_shizuku">请先启动 Shizuku App</string>
<string name="toast_shizuku_old">Shizuku 版本过低</string>
<string name="toast_task_complete">任务完成</string>
<string name="toast_user_stopped">用户停止</string>
<string name="toast_sensitive_page">检测到敏感页面(支付/密码),已安全停止</string>
<string name="toast_max_steps">达到最大步数限制</string>
<string name="toast_error">错误: %1$s</string>
</resources>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 主应用主题 - 深色主题 -->
<style name="Theme.Baozi" parent="android:Theme.Material.NoActionBar">
<!-- 状态栏颜色 -->
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:windowLightStatusBar">false</item>
<!-- 导航栏颜色 -->
<item name="android:navigationBarColor">@color/background_card</item>
<item name="android:windowLightNavigationBar">false</item>
<!-- 窗口背景 -->
<item name="android:windowBackground">@color/background_dark</item>
<!-- 主题色 -->
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/secondary</item>
</style>
<!-- 启动画面主题 (Android 12+) -->
<style name="Theme.Baozi.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="windowSplashScreenIconBackgroundColor">@color/splash_background</item>
<item name="postSplashScreenTheme">@style/Theme.Baozi</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="crash_logs" path="crash_logs/" />
<cache-path name="cache" path="/" />
</paths>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- 允许明文 HTTP 连接(用于本地部署的模型如 Ollama, vLLM 等) -->
<!-- 注意:仅在用户手动配置本地 API 地址时使用 -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=120000
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+19
View File
@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "AutoPilot"
include(":app")
+17 -5
View File
@@ -44,14 +44,14 @@
2. 读取 `agent-context.json`、`current-state.md` 和当前任务文件。
3. 仓库已启用 Git 时查看最近五条提交;当前未启用时,以 `current-state.md` 为准。
4. 运行 `./init.ps1`(Windows)或 `./init.sh`(WSL/Linux)。
5. 如果脚本因 `T-001` 尚未完成而主动失败,只能领取 `T-001`;不要宣称基线可运行。
5. 如果脚本失败,先记录并修复基线;不要在失败状态领取依赖它的后续任务。
6. 有真实基线后先跑 smoke;基线失败时先修基线。
7. 领取编号最小、依赖已完成的一个 `TODO` 任务并改成 `DOING`。
8. 完成实现、验证、任务执行记录和当前状态同步后再结束。
## 当前阶段与优先路径
当前处于“文档基线完成、最高风险验证尚未开始”阶段。
当前处于“Android 可运行基线完成、设备和 workflow 验证准备”阶段。
严格按以下顺序推进:
@@ -88,6 +88,18 @@ Roubao、拼多多页面、Android 包名、上游分支和依赖版本必须从
## 当前验证命令
当前没有生产代码,`init.ps1` 和 `init.sh` 会主动报错。`T-001` 完成后必须用真实
命令替换本文,例如 Android 至少应有可复现的 Gradle 构建命令;后端建立后再追加
后端测试和启动命令。
Windows 默认验证:
```powershell
.\init.ps1
```
连接并授权真机后执行安装和冷启动:
```powershell
$env:RUN_START_COMMAND = "1"
.\init.ps1
```
脚本执行 `test assembleDebug`;上游当前没有单元测试源,因此 `test` 的测试任务为
`NO-SOURCE`,但编译与 APK 打包必须成功。后端建立后再追加后端测试和启动命令。
+20 -21
View File
@@ -6,10 +6,10 @@
| 维度 | 选型 | 状态 | 说明 |
| --- | --- | --- | --- |
| Android 基础 | Roubao `main` 的 Kotlin 原生 Android 代码,应用版本 1.4.2 | 上游已核实,待接入 | 上游为 `Turbo1123/roubao`,MIT;接入时固定具体 commit。 |
| Android IDE/JDK | Android Studio Hedgehog 2023.1.1 或更高;JDK 17 | 上游已核实 | 首次导入不接受 IDE 自动升级 AGP、Gradle 或 Kotlin。 |
| Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 上游已核实 | 使用仓库内 Gradle Wrapper,不依赖全局 Gradle。 |
| Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 上游已核实 | 支持 Android 8.0+;真机自动化优先 Android 11+。 |
| Android 基础 | Roubao `main` 的 Kotlin 原生 Android 代码,应用版本 1.4.2 | 已接入并构建 | 固定 `main@c8a6d7f...`,来源与调整见 `android-buyer/UPSTREAM.md`。 |
| Android IDE/JDK | Android Studio Hedgehog 2023.1.1 或更高;JDK 17 | JDK/CLI 已验证,IDE 可选安装 | 命令行构建不依赖 Android Studio;首次用 IDE 导入时不接受自动升级。 |
| Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 已验证 | 已补齐上游缺失的 `gradlew.bat`,不依赖全局 Gradle。 |
| Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 |
| Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 |
| Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 目标已定,待实现 | 上游 `main` 使用 Shizuku 13.1.5;无障碍实现位于独立开发分支,不能把它误认为主分支现状。 |
| 第一层任务源 | UTF-8 四行蝦皮订单文本 + 同订单号 JPEG | 首份样例已核实,待实现 | 使用 Debug/测试专用 `TaskSource`;原始数据和生成物不得提交。 |
@@ -27,7 +27,7 @@
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 |
| Android 测试 | JUnit + 现有上游测试工具;真实设备 smoke | 待源码核实 | UI 自动化核心必须在目标设备验证。 |
| Android 测试 | Gradle `test` + 真实设备 smoke | 基线已验证 | 上游没有 `test/androidTest` 源;T-003 建立 workflow 测试。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
## Roubao 上游版本基线
@@ -51,15 +51,13 @@
| 自动化依赖 | Shizuku API/Provider 13.1.5 |
| 无障碍开发分支 | `roubao2.0+AccessibilityService`,核实时 commit `5b114c0a9476c359b27cfe994743fc7beb0a3554` |
上述值来自上游 README、`build.gradle.kts`、`app/build.gradle.kts` 和
`gradle/wrapper/gradle-wrapper.properties`。它们是接入前的远端核实结果,不代表
本仓库已经构建通过。`T-001` 接入时必须再次确认远端 commit,并把最终采用的 commit
作为可复现基线。
上述版本已在 2026-07-25 使用 JDK 17.0.13、SDK 34 和 Build Tools 34.0.0 完成
Windows Debug 构建。上游无障碍分支落后于 `main` 的修复和 1.4.2 能力,因此不整体
切换分支;后续只选择性移植无障碍服务。
上游启用了 Google Services 4.4.2、Firebase Crashlytics Gradle Plugin 3.0.2 和
Firebase BOM 33.7.0,但 `google-services.json` 被 `.gitignore` 排除。干净构建必须
提供本项目自己的 Firebase 配置,或者在不需要遥测时移除相关插件和依赖;不得提交
真实 Firebase 配置。
上游 Firebase 配置依赖未提交的 `google-services.json`,且第一层不需要遥测。
接入基线已移除 Google Services、Firebase Analytics/Crashlytics 及云端崩溃上报
设置,保留本地 `CrashHandler`,从而无需伪造或提交 Firebase 凭证。
## 关键决策
@@ -113,7 +111,7 @@ RabbitMQ、NATS 和 gRPC 为主,不符合最小 Gin + SQLite 边界。
## 计划目录
```text
android-buyer/ # Kotlin Android App
android-buyer/ # 已接入的 Roubao Kotlin Android App
backend-api/
cmd/api/ # 进程入口,只负责装配和生命周期
internal/domain/ # 实体、状态机和确定性规则
@@ -132,20 +130,21 @@ docs/
## 构建与运行命令
当前没有源码,以下是计划形状而非可运行证据:
| 用途 | 计划命令 | 当前状态 |
| 用途 | 命令 | 当前状态 |
| --- | --- | --- |
| Android 构建 | `android-buyer\gradlew.bat assembleDebug` | 待 `T-001` |
| Android 单元测试 | `android-buyer\gradlew.bat test` | 待 `T-001` |
| Android 标准验证 | `.\init.ps1` | 已验证 |
| Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 |
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;无测试源 |
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` |
| 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` |
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` | 待 `T-201` |
| 后端启动 | `go run ./cmd/api` | 待 `T-201` |
| 数据迁移 | `goose -dir migrations sqlite3 ./var/cmroubao.db up` | 待 `T-201` |
任何命令只有实际执行成功后才能写入 `init.ps1`、`init.sh` 和
`current-state.md` 的“当前可运行内容”。
`gradlew installDebug` 在当前 Android 16 设备上由旧版 AGP/ddmlib 返回 `-99`,
但 SDK Platform Tools 37.0.0 的 `adb install -r -t` 成功。标准脚本因此使用 SDK
内新版 ADB 安装,避免 PATH 中旧版 ADB 1.0.32。
## 依赖纪律
+3 -2
View File
@@ -94,5 +94,6 @@
- [ ] 当前任务文件记录命令、结果、环境和未验证项。
- [ ] `current-state.md` 与真实目录、命令和 blocker 一致。
当前尚无真实命令。`T-001` 后必须在这里列出 Android 构建和测试命令;后端建立后
再列出后端命令。不能把 `docs/03-tech-stack.md` 中的计划命令当作已通过证据。
当前 Android 标准验证命令是根目录 `.\init.ps1`;它执行 Gradle `test` 和
`assembleDebug`。设置 `RUN_START_COMMAND=1` 时使用 SDK 内新版 ADB 安装并启动。
后端建立后再在这里列出后端命令。
+24 -28
View File
@@ -5,23 +5,23 @@
## 当前快照
- 日期:2026-07-25
- 阶段:Harness 文档基线完成,T-001 已开始,Android 源码尚未接入
- 阶段:T-001 Android 可运行基线完成,准备执行 T-002 设备就绪基线
- Git:已初始化,当前分支为 `main`,文档和初始化脚本纳入首个基线提交
- 生产代码:无
- Android:Roubao 源码尚未接入;上游仓库、MIT 许可证、分支和构建版本已远端核实
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入
- 本机 Android 工具:JDK 17.0.13 和 ADB 已有;Android Studio、Android SDK 34
未检测到,`ANDROID_HOME`/`ANDROID_SDK_ROOT` 未设置
- 测试:无
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:Gradle `test` 成功;上游没有 `test/androidTest` 测试源
- 版本控制内数据:只有 `deepseek总结.txt` 背景摘要和本套项目文档
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git
- 标准启动路径:未配置;`init.ps1`/`init.sh` 会主动失败
- 标准验证路径:文档结构和链接检查;无代码构建
- 当前 blocker:Roubao 尚未导入,`main` 与无障碍开发分支尚未完成代码差异评估;
Android Studio/SDK 34 未安装或未被检测到,测试设备状态未知;拼多多测试版本/账号
未建立基线;蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM
供应商和测试凭证未确认
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1`
- 当前 blocker:T-002 尚未完成 Shizuku 服务/授权、目标无障碍服务和拼多多登录状态
检查;蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和
测试凭证未确认
## 当前目录
@@ -29,33 +29,29 @@
| --- | --- | --- |
| `AGENTS.md` | 已有 | agent 权威入口 |
| `docs/` | 已有 | Harness Coding 文档 |
| `docs/tasks/T-001.md` | TODO | 当前第一个可领取任务 |
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 待建 | Roubao Android 代码目标目录 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 |
| `init.ps1` / `init.sh` | 占位 | T-001 后写入真实命令 |
| `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 |
## 任务摘要
- 已完成:Harness 文档初始化(本次文档工作,不作为产品开发任务编号)。
- 正在进行:`T-001 初始化 Git 并接入 Roubao Android 基线`;已完成 Git 初始化和
上游远端核实,源码导入及构建尚未完成。
- 下一个可领取任务:无;完成 T-001 后才能领取依赖它的任务。
- 已完成:T-001 初始化 Git 并接入 Roubao Android 基线。
- 正在进行:无。
- 下一个可领取任务:T-002 建立测试设备基线和就绪检查。
## 当前可运行内容
没有生产代码或已验证构建命令。以下检查仅验证文档文件:
```powershell
Get-ChildItem -Recurse -File
Get-Content docs/agent-context.json -Raw | ConvertFrom-Json | Out-Null
.\init.ps1
$env:RUN_START_COMMAND = "1"
.\init.ps1
```
`docs/03-tech-stack.md` 中的 Gradle、Go 构建和启动命令是计划形状,尚不能作为
当前可运行内容。
已核实的 Roubao 远端版本事实记录在 `docs/03-tech-stack.md`;“已核实”只表示查看了
上游配置,不表示本机已经具备 Android SDK 或完成过 APK 构建。
2026-07-25 已在 PKG110、Android 16/API 36 上用第二条命令完成 Debug APK 安装和
冷启动;画面停在预期的 Shizuku 使用指南,进程存活且 logcat 无崩溃。
## 维护规则
+43 -14
View File
@@ -3,17 +3,19 @@ id: T-001
title: 初始化 Git 并接入 Roubao Android 基线
phase: 0
deps: []
status: DOING
status: DONE
created: 2026-07-25
context_ref: null
context_ref: 285606639bc13b6db4bb8969eb607612fa56d87a
work_branch: main
write_paths:
- AGENTS.md
- docs/tasks/T-001.md
- android-buyer/**
- .gitignore
- README.md
- init.ps1
- init.sh
- .gitattributes
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md
@@ -27,9 +29,9 @@ write_paths:
## 问题 / 背景
当前仓库只有 Harness 文档,不是 Git 仓库,也没有 Roubao 源码、Gradle Wrapper 或
任务启动时仓库只有 Harness 文档,没有 Roubao 源码、Windows Gradle Wrapper 或
可验证的 Android 命令。所有自动化、VLM 和后台任务都依赖一个可复现的 Android
基线;在基线完成前不能开始拼多多业务修改。
基线。
预期上游已经从真实仓库完成远端核实:
@@ -68,17 +70,17 @@ AccessibilityService 位于独立开发分支;接入前要先比较分支,
## 验收要点
- [x] 当前目录已成为可正常查看状态和提交的 Git 仓库。
- [ ] `android-buyer/` 包含可追溯到明确 URL 和 commit 的 Roubao 源码。
- [ ] 上游许可证文件已保留,并确认允许当前预期的二次开发方式。
- [ ] Windows 上使用仓库内 Gradle Wrapper 成功构建 Debug APK。
- [ ] 上游已有 Android 单元测试已执行;没有测试时明确记录。
- [ ] 有测试设备时完成安装和启动 smoke,并记录设备/Android 版本;无设备时任务
- [x] `android-buyer/` 包含可追溯到明确 URL 和 commit 的 Roubao 源码。
- [x] 上游许可证文件已保留,并确认允许当前预期的二次开发方式。
- [x] Windows 上使用仓库内 Gradle Wrapper 成功构建 Debug APK。
- [x] 上游已有 Android 单元测试已执行;没有测试时明确记录。
- [x] 有测试设备时完成安装和启动 smoke,并记录设备/Android 版本;无设备时任务
不能把设备 smoke 写成已通过。
- [ ] 未加入真实 API key、签名密码、设备 token 或本地 SDK 绝对路径。
- [ ] 已处理缺失的 `google-services.json`:提供本地忽略配置,或明确移除不需要的
- [x] 未加入真实 API key、签名密码、设备 token 或本地 SDK 绝对路径。
- [x] 已处理缺失的 `google-services.json`:提供本地忽略配置,或明确移除不需要的
Firebase 插件和依赖。
- [ ] `03-tech-stack.md` 和 `current-state.md` 不再把已核实信息标为待定。
- [ ] `init.ps1`/`init.sh` 与文档中的当前命令一致,或因明确 blocker 保持主动失败。
- [x] `03-tech-stack.md` 和 `current-state.md` 不再把已核实信息标为待定。
- [x] `init.ps1`/`init.sh` 与文档中的当前命令一致,或因明确 blocker 保持主动失败。
建议验证命令形状,最终以接入工程为准:
@@ -108,4 +110,31 @@ Set-Location android-buyer
- 用户确认第一层使用本机蝦皮订单文本和同订单号参考图作为真实样本;已将输入契约
和后续 T-004 同步到需求、架构、编码规则及路线图。
- 本次只建立文档与脚本提交;Gradle 构建、测试、APK 安装和设备 smoke 均未运行。
- T-001 保持 `DOING`,剩余 blocker 见 `docs/current-state.md`。
- 本阶段结束时 T-001 仍保持 `DOING`,当时的 blocker 见 `docs/current-state.md` 历史。
### 2026-07-25:Android 可运行基线
- 选择 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e` 作为导入基线。无障碍分支
落后 `main` 的修复和 1.4.2 能力,后续只选择性移植,不整体切换。
- `android-buyer/UPSTREAM.md` 记录来源和调整;上游 MIT `LICENSE` 已保留。
- 安装 JDK 17 对应的 Android SDK 34、Build Tools 34.0.0、Command-line Tools
22.0 和 Platform Tools 37.0.0;没有把本机 SDK 绝对路径提交到仓库。
- 新版 Command-line Tools 与 AGP 8.2 会输出 SDK XML v4/v3 兼容警告,但任务和 APK
构建均成功;这是非阻塞工具版本提示,不作为通过证据本身。
- 上游缺少 `gradlew.bat`,已用 Gradle 8.2 Wrapper 任务生成;Wrapper 固定官方
SHA-256、120 秒网络超时并关闭生成阶段 URL 探测。
- 移除需要私有 `google-services.json` 的 Firebase Analytics/Crashlytics 和云端
上报设置;保留本地 `CrashHandler`。
- `.\gradlew.bat tasks --no-daemon`:成功。
- `.\gradlew.bat test --no-daemon`:成功;Debug/Release 单元测试均为 `NO-SOURCE`,
上游没有 `src/test` 或 `src/androidTest`。
- `.\gradlew.bat assembleDebug --no-daemon`:成功;生成 `app-debug.apk`,当次
SHA-256 为 `745C8E23B5C1575E9C71F6FF583BEEA3800759E9C611AD0C45D9062A856F68C4`。
- `gradlew installDebug` 在 Android 16 上由 AGP 8.2/ddmlib 返回 `-99`;SDK ADB
37.0.0 执行 `adb install -r -t` 成功,标准脚本采用该可靠路径。
- `$env:RUN_START_COMMAND="1"; .\init.ps1`:成功完成测试任务、APK 构建、安装和
冷启动。
- 真机 smoke:OnePlus PKG110,Android 16/API 36;MainActivity 冷启动约 0.9 秒,
进程存活、前台窗口正确、logcat 无崩溃,画面正常停在 Shizuku 使用指南。
- Android Studio `winget` 静默安装卡住并已终止;命令行构建不依赖 IDE,因此不作为
T-001 blocker。
+3 -3
View File
@@ -66,6 +66,6 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固
## 执行记录
尚未开始,等待 T-001 完成。前置检查已确认一组同名文本/JPEG 存在,文本字段结构和
图片解码有效;未记录或提交实际订单号、店铺名、商品内容及图片。实现时仍需确定正式
私有目录和一单多 SKU 规则。
尚未开始,依赖 T-001 已满足。前置检查已确认一组同名文本/JPEG 存在,文本字段结构
和图片解码有效;未记录或提交实际订单号、店铺名、商品内容及图片。实现时仍需确定
正式私有目录和一单多 SKU 规则。
+60 -23
View File
@@ -3,31 +3,68 @@
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
# T-001 接入真实工程后替换。未配置时主动失败,防止把占位命令当成有效基线。
$InstallCmd = "__REPLACE_AFTER_T001__"
$VerifyCmd = "__REPLACE_AFTER_T001__"
$StartCmd = "__REPLACE_AFTER_T001__"
foreach ($entry in @{
InstallCmd = $InstallCmd
VerifyCmd = $VerifyCmd
StartCmd = $StartCmd
}.GetEnumerator()) {
if ($entry.Value -like "__REPLACE_*") {
[Console]::Error.WriteLine(
"项目尚未完成 T-001;请配置 $($entry.Key),并同步 docs/03-tech-stack.md 和 docs/current-state.md。"
)
exit 2
}
$sdkRoot = if ($env:ANDROID_SDK_ROOT) {
$env:ANDROID_SDK_ROOT
} elseif ($env:ANDROID_HOME) {
$env:ANDROID_HOME
} else {
Join-Path $env:LOCALAPPDATA "Android\Sdk"
}
Write-Host "==> 当前目录: $($PWD.Path)"
Write-Host "==> 同步依赖"
Invoke-Expression $InstallCmd
Write-Host "==> 运行基础验证"
Invoke-Expression $VerifyCmd
Write-Host "==> 启动命令: $StartCmd"
$gradle = Join-Path $PSScriptRoot "android-buyer\gradlew.bat"
$adb = Join-Path $sdkRoot "platform-tools\adb.exe"
$apk = Join-Path $PSScriptRoot "android-buyer\app\build\outputs\apk\debug\app-debug.apk"
if (-not (Test-Path -LiteralPath $gradle)) {
throw "缺少 Android Gradle Wrapper: $gradle"
}
if (-not (Test-Path -LiteralPath (Join-Path $sdkRoot "platforms\android-34"))) {
throw "缺少 Android SDK 34,请设置 ANDROID_SDK_ROOT 或 ANDROID_HOME。"
}
if (-not (Test-Path -LiteralPath (Join-Path $sdkRoot "build-tools\34.0.0"))) {
throw "缺少 Android SDK Build Tools 34.0.0。"
}
if (-not (Get-Command java -ErrorAction SilentlyContinue)) {
throw "缺少 JDK 17,java 不在 PATH 中。"
}
$javaVersion = (& java -version 2>&1) -join "`n"
if ($javaVersion -notmatch 'version "17(?:\.|")') {
throw "需要 JDK 17,当前 java -version 输出: $javaVersion"
}
$env:ANDROID_HOME = $sdkRoot
$env:ANDROID_SDK_ROOT = $sdkRoot
Write-Host "==> 验证 Android 工程"
Push-Location (Join-Path $PSScriptRoot "android-buyer")
try {
& $gradle test assembleDebug --no-daemon
if ($LASTEXITCODE -ne 0) {
throw "Android 构建失败,退出码: $LASTEXITCODE"
}
} finally {
Pop-Location
}
Write-Host "==> Debug APK: $apk"
if ($env:RUN_START_COMMAND -eq "1") {
Invoke-Expression $StartCmd
if (-not (Test-Path -LiteralPath $adb)) {
throw "缺少 SDK Platform Tools: $adb"
}
$devices = & $adb devices
if (($devices -join "`n") -notmatch "\sdevice\s*$") {
throw "没有已授权的 Android 设备。"
}
Write-Host "==> 安装并启动 Debug APK"
& $adb install -r -t $apk
if ($LASTEXITCODE -ne 0) {
throw "APK 安装失败,退出码: $LASTEXITCODE"
}
& $adb shell am start -W -n "com.roubao.autopilot/.MainActivity"
if ($LASTEXITCODE -ne 0) {
throw "App 启动失败,退出码: $LASTEXITCODE"
}
}
Regular → Executable
+47 -17
View File
@@ -3,26 +3,56 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
ANDROID_DIR="$ROOT_DIR/android-buyer"
SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
GRADLE="$ANDROID_DIR/gradlew"
ADB="$SDK_ROOT/platform-tools/adb"
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
# T-001 接入真实工程后替换。
INSTALL_CMD=(__REPLACE_AFTER_T001__)
VERIFY_CMD=(__REPLACE_AFTER_T001__)
START_CMD=(__REPLACE_AFTER_T001__)
if [[ ! -x "$GRADLE" ]]; then
echo "ERROR: Android Gradle Wrapper is missing or not executable: $GRADLE" >&2
exit 2
fi
if [[ ! -d "$SDK_ROOT/platforms/android-34" ]]; then
echo "ERROR: Android SDK 34 is missing; set ANDROID_SDK_ROOT or ANDROID_HOME." >&2
exit 2
fi
if [[ ! -d "$SDK_ROOT/build-tools/34.0.0" ]]; then
echo "ERROR: Android SDK Build Tools 34.0.0 is missing." >&2
exit 2
fi
if ! command -v java >/dev/null 2>&1; then
echo "ERROR: JDK 17 is missing from PATH." >&2
exit 2
fi
JAVA_VERSION="$(java -version 2>&1 | head -n 1)"
if [[ "$JAVA_VERSION" != *'version "17.'* ]]; then
echo "ERROR: JDK 17 is required; found: $JAVA_VERSION" >&2
exit 2
fi
for value in "${INSTALL_CMD[0]}" "${VERIFY_CMD[0]}" "${START_CMD[0]}"; do
if [[ "$value" == __REPLACE_* ]]; then
echo "ERROR: 项目尚未完成 T-001,请配置 init.sh 并同步技术栈和当前状态文档。"
exit 2
fi
done
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
"${INSTALL_CMD[@]}"
"${VERIFY_CMD[@]}"
printf '启动命令:'
printf ' %q' "${START_CMD[@]}"
printf '\n'
echo "==> Verifying Android project"
(
cd "$ANDROID_DIR"
"$GRADLE" test assembleDebug --no-daemon
)
echo "==> Debug APK: $APK"
if [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then
exec "${START_CMD[@]}"
if [[ ! -x "$ADB" ]]; then
echo "ERROR: Android Platform Tools are missing: $ADB" >&2
exit 2
fi
if ! "$ADB" devices | grep -qE $'\tdevice$'; then
echo "ERROR: No authorized Android device is connected." >&2
exit 2
fi
echo "==> Installing and starting Debug APK"
"$ADB" install -r -t "$APK"
"$ADB" shell am start -W -n "com.roubao.autopilot/.MainActivity"
fi
+7
View File
@@ -38,3 +38,10 @@
和参考图,规范化为与未来 HTTP 接口一致的探针任务。
- 影响:新增 T-004;私有订单资料和生成物不得进入 Git。首份样例已确认 UTF-8 四行
标签和同名 JPEG,Android workflow 只依赖统一 `TaskSource`。
## 2026-07-25 Android 可运行基线
- 类型:阶段完成
- 内容:完成 T-001;导入 Roubao `main@c8a6d7f`,移除不可复现的 Firebase 私有
配置依赖,补齐 Windows Gradle Wrapper,并在 Android 16 真机完成构建、安装和启动。
- 影响:Phase 0 的设备、workflow 和蝦皮样本任务可以开始;无障碍分支只选择性移植。