From 04fa4a099465a74d68a6aae6c23df0c556d9e7cf Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Sat, 25 Jul 2026 18:20:37 +0800
Subject: [PATCH] feat(android): establish Roubao build baseline
---
.gitattributes | 4 +
AGENTS.md | 10 +-
README.md | 10 +-
android-buyer/.gitignore | 114 ++
android-buyer/LICENSE | 21 +
android-buyer/README.md | 540 +++++
android-buyer/README_EN.md | 540 +++++
android-buyer/UPSTREAM.md | 21 +
android-buyer/app/build.gradle.kts | 91 +
android-buyer/app/proguard-rules.pro | 3 +
.../app/src/main/AndroidManifest.xml | 112 +
.../com/roubao/autopilot/IShellService.aidl | 6 +
android-buyer/app/src/main/assets/skills.json | 709 +++++++
.../src/main/java/com/roubao/autopilot/App.kt | 77 +
.../java/com/roubao/autopilot/MainActivity.kt | 625 ++++++
.../roubao/autopilot/agent/ActionReflector.kt | 78 +
.../autopilot/agent/ConversationMemory.kt | 163 ++
.../com/roubao/autopilot/agent/Executor.kt | 194 ++
.../com/roubao/autopilot/agent/InfoPool.kt | 226 ++
.../com/roubao/autopilot/agent/Manager.kt | 178 ++
.../com/roubao/autopilot/agent/MobileAgent.kt | 1167 +++++++++++
.../com/roubao/autopilot/agent/Notetaker.kt | 52 +
.../roubao/autopilot/controller/AppScanner.kt | 435 ++++
.../autopilot/controller/DeviceController.kt | 552 +++++
.../roubao/autopilot/data/ExecutionHistory.kt | 209 ++
.../roubao/autopilot/data/SettingsManager.kt | 352 ++++
.../roubao/autopilot/service/ShellService.kt | 35 +
.../java/com/roubao/autopilot/skills/Skill.kt | 299 +++
.../roubao/autopilot/skills/SkillManager.kt | 503 +++++
.../roubao/autopilot/skills/SkillRegistry.kt | 341 ++++
.../roubao/autopilot/tools/ClipboardTool.kt | 152 ++
.../roubao/autopilot/tools/DeepLinkTool.kt | 108 +
.../com/roubao/autopilot/tools/HttpTool.kt | 164 ++
.../com/roubao/autopilot/tools/OpenAppTool.kt | 56 +
.../roubao/autopilot/tools/SearchAppsTool.kt | 83 +
.../com/roubao/autopilot/tools/ShellTool.kt | 150 ++
.../java/com/roubao/autopilot/tools/Tool.kt | 144 ++
.../com/roubao/autopilot/tools/ToolManager.kt | 109 +
.../com/roubao/autopilot/ui/OverlayService.kt | 501 +++++
.../ui/screens/CapabilitiesScreen.kt | 452 ++++
.../autopilot/ui/screens/HistoryScreen.kt | 595 ++++++
.../roubao/autopilot/ui/screens/HomeScreen.kt | 557 +++++
.../autopilot/ui/screens/OnboardingScreen.kt | 247 +++
.../autopilot/ui/screens/SettingsScreen.kt | 1812 +++++++++++++++++
.../com/roubao/autopilot/ui/theme/Theme.kt | 180 ++
.../roubao/autopilot/utils/CrashHandler.kt | 245 +++
.../com/roubao/autopilot/vlm/GUIOwlClient.kt | 295 +++
.../com/roubao/autopilot/vlm/MAIUIClient.kt | 390 ++++
.../com/roubao/autopilot/vlm/VLMClient.kt | 351 ++++
.../main/res/drawable-xxxhdpi/splash_icon.png | Bin 0 -> 87836 bytes
.../app/src/main/res/drawable/splash_icon.png | Bin 0 -> 39461 bytes
.../res/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
.../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 6942 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 5326 bytes
.../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 8026 bytes
.../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3657 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 3380 bytes
.../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 4261 bytes
.../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 11258 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 10304 bytes
.../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 12837 bytes
.../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 22916 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 17004 bytes
.../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 26191 bytes
.../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 39461 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 35995 bytes
.../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 44855 bytes
.../app/src/main/res/values-en/strings.xml | 160 ++
.../app/src/main/res/values/colors.xml | 29 +
.../app/src/main/res/values/strings.xml | 160 ++
.../app/src/main/res/values/themes.xml | 29 +
.../app/src/main/res/xml/file_paths.xml | 5 +
.../main/res/xml/network_security_config.xml | 10 +
android-buyer/build.gradle.kts | 5 +
android-buyer/docs/demo/demo.gif | Bin 0 -> 11145656 bytes
android-buyer/docs/images/logo.png | Bin 0 -> 1400666 bytes
android-buyer/docs/screenshots/1.jpg | Bin 0 -> 191384 bytes
android-buyer/docs/screenshots/2.jpg | Bin 0 -> 339179 bytes
android-buyer/docs/screenshots/3.jpg | Bin 0 -> 314934 bytes
android-buyer/docs/screenshots/4.jpg | Bin 0 -> 232030 bytes
android-buyer/gradle.properties | 4 +
.../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes
.../gradle/wrapper/gradle-wrapper.properties | 8 +
android-buyer/gradlew | 248 +++
android-buyer/gradlew.bat | 92 +
android-buyer/settings.gradle.kts | 19 +
docs/00-ai-start-here.md | 22 +-
docs/03-tech-stack.md | 41 +-
docs/05-coding-rules.md | 5 +-
docs/current-state.md | 52 +-
docs/tasks/T-001.md | 57 +-
docs/tasks/T-004.md | 6 +-
init.ps1 | 83 +-
init.sh | 64 +-
progress.md | 7 +
96 files changed, 15249 insertions(+), 125 deletions(-)
create mode 100644 android-buyer/.gitignore
create mode 100644 android-buyer/LICENSE
create mode 100644 android-buyer/README.md
create mode 100644 android-buyer/README_EN.md
create mode 100644 android-buyer/UPSTREAM.md
create mode 100644 android-buyer/app/build.gradle.kts
create mode 100644 android-buyer/app/proguard-rules.pro
create mode 100644 android-buyer/app/src/main/AndroidManifest.xml
create mode 100644 android-buyer/app/src/main/aidl/com/roubao/autopilot/IShellService.aidl
create mode 100644 android-buyer/app/src/main/assets/skills.json
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/App.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/ActionReflector.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/ConversationMemory.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/Executor.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/InfoPool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/Manager.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/MobileAgent.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/agent/Notetaker.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/controller/AppScanner.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/controller/DeviceController.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/data/ExecutionHistory.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/data/SettingsManager.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/service/ShellService.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/skills/Skill.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillManager.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillRegistry.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/ClipboardTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/DeepLinkTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/HttpTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/OpenAppTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/SearchAppsTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/ShellTool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/Tool.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/tools/ToolManager.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/OverlayService.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/CapabilitiesScreen.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/HistoryScreen.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/HomeScreen.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/OnboardingScreen.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SettingsScreen.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/theme/Theme.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/vlm/GUIOwlClient.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/vlm/MAIUIClient.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/vlm/VLMClient.kt
create mode 100644 android-buyer/app/src/main/res/drawable-xxxhdpi/splash_icon.png
create mode 100644 android-buyer/app/src/main/res/drawable/splash_icon.png
create mode 100644 android-buyer/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 android-buyer/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 android-buyer/app/src/main/res/mipmap-hdpi/ic_launcher.png
create mode 100644 android-buyer/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
create mode 100644 android-buyer/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
create mode 100644 android-buyer/app/src/main/res/mipmap-mdpi/ic_launcher.png
create mode 100644 android-buyer/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
create mode 100644 android-buyer/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xhdpi/ic_launcher.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
create mode 100644 android-buyer/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
create mode 100644 android-buyer/app/src/main/res/values-en/strings.xml
create mode 100644 android-buyer/app/src/main/res/values/colors.xml
create mode 100644 android-buyer/app/src/main/res/values/strings.xml
create mode 100644 android-buyer/app/src/main/res/values/themes.xml
create mode 100644 android-buyer/app/src/main/res/xml/file_paths.xml
create mode 100644 android-buyer/app/src/main/res/xml/network_security_config.xml
create mode 100644 android-buyer/build.gradle.kts
create mode 100644 android-buyer/docs/demo/demo.gif
create mode 100644 android-buyer/docs/images/logo.png
create mode 100644 android-buyer/docs/screenshots/1.jpg
create mode 100644 android-buyer/docs/screenshots/2.jpg
create mode 100644 android-buyer/docs/screenshots/3.jpg
create mode 100644 android-buyer/docs/screenshots/4.jpg
create mode 100644 android-buyer/gradle.properties
create mode 100644 android-buyer/gradle/wrapper/gradle-wrapper.jar
create mode 100644 android-buyer/gradle/wrapper/gradle-wrapper.properties
create mode 100755 android-buyer/gradlew
create mode 100644 android-buyer/gradlew.bat
create mode 100644 android-buyer/settings.gradle.kts
mode change 100644 => 100755 init.sh
diff --git a/.gitattributes b/.gitattributes
index f8e512d..09d0ea4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index bb461e6..5b14ba7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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` 收尾。
diff --git a/README.md b/README.md
index 8c9645c..91fe31b 100644
--- a/README.md
+++ b/README.md
@@ -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) 为准。
diff --git a/android-buyer/.gitignore b/android-buyer/.gitignore
new file mode 100644
index 0000000..0609db4
--- /dev/null
+++ b/android-buyer/.gitignore
@@ -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
diff --git a/android-buyer/LICENSE b/android-buyer/LICENSE
new file mode 100644
index 0000000..1d48eaf
--- /dev/null
+++ b/android-buyer/LICENSE
@@ -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.
diff --git a/android-buyer/README.md b/android-buyer/README.md
new file mode 100644
index 0000000..bd904dc
--- /dev/null
+++ b/android-buyer/README.md
@@ -0,0 +1,540 @@
+
+
+
+
+肉包 Roubao
+
+
+ 首款无需电脑的开源 AI 手机自动化助手 | AI Phone Automation Assistant
+
+
+
+ 基于视觉语言模型 (VLM) · 原生 Android Kotlin · 多 Agent 协作架构
+
+
+
+ English | 简体中文
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## 项目背景
+
+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 权限管理框架
+
+---
+
+
+ Made with ❤️ by Roubao Team
+
diff --git a/android-buyer/README_EN.md b/android-buyer/README_EN.md
new file mode 100644
index 0000000..6198105
--- /dev/null
+++ b/android-buyer/README_EN.md
@@ -0,0 +1,540 @@
+
+
+
+
+Roubao (肉包)
+
+
+ The First Open-Source AI Phone Automation Assistant Without PC
+
+
+
+ Vision-Language Model (VLM) · Native Android Kotlin · Multi-Agent Architecture
+
+
+
+ English | 简体中文
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## 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
+
+---
+
+
+ Made with ❤️ by Roubao Team
+
diff --git a/android-buyer/UPSTREAM.md b/android-buyer/UPSTREAM.md
new file mode 100644
index 0000000..5cdc158
--- /dev/null
+++ b/android-buyer/UPSTREAM.md
@@ -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.
diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts
new file mode 100644
index 0000000..5bb7554
--- /dev/null
+++ b/android-buyer/app/build.gradle.kts
@@ -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")
+}
diff --git a/android-buyer/app/proguard-rules.pro b/android-buyer/app/proguard-rules.pro
new file mode 100644
index 0000000..9e13270
--- /dev/null
+++ b/android-buyer/app/proguard-rules.pro
@@ -0,0 +1,3 @@
+# Add project specific ProGuard rules here.
+-keep class com.example.autopilot.** { *; }
+-keep class rikka.shizuku.** { *; }
diff --git a/android-buyer/app/src/main/AndroidManifest.xml b/android-buyer/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0031f86
--- /dev/null
+++ b/android-buyer/app/src/main/AndroidManifest.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-buyer/app/src/main/aidl/com/roubao/autopilot/IShellService.aidl b/android-buyer/app/src/main/aidl/com/roubao/autopilot/IShellService.aidl
new file mode 100644
index 0000000..e42e675
--- /dev/null
+++ b/android-buyer/app/src/main/aidl/com/roubao/autopilot/IShellService.aidl
@@ -0,0 +1,6 @@
+package com.roubao.autopilot;
+
+interface IShellService {
+ void destroy() = 16777114;
+ String exec(String command) = 1;
+}
diff --git a/android-buyer/app/src/main/assets/skills.json b/android-buyer/app/src/main/assets/skills.json
new file mode 100644
index 0000000..b942473
--- /dev/null
+++ b/android-buyer/app/src/main/assets/skills.json
@@ -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": ["打开番茄小说", "搜索或打开书架", "阅读"]
+ }
+ ]
+ }
+]
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/App.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/App.kt
new file mode 100644
index 0000000..6711d2e
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/App.kt
@@ -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")
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
new file mode 100644
index 0000000..c0466f7
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
@@ -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(null)
+ private var shizukuAvailable = mutableStateOf(false)
+
+ // 当前执行的协程 Job(用于停止任务)
+ private var currentExecutionJob: kotlinx.coroutines.Job? = null
+
+ // 执行记录列表
+ private val executionRecords = mutableStateOf>(emptyList())
+
+ // 是否正在执行(点击发送后立即为 true)
+ private val isExecuting = mutableStateOf(false)
+
+ // 当前执行的记录 ID(用于停止后跳转)
+ private val currentRecordId = mutableStateOf(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.Home) }
+ var selectedRecord by remember { mutableStateOf(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()) }
+ 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ActionReflector.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ActionReflector.kt
new file mode 100644
index 0000000..6750e49
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ActionReflector.kt
@@ -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
+)
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ConversationMemory.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ConversationMemory.kt
new file mode 100644
index 0000000..6eae8df
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/ConversationMemory.kt
@@ -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()
+
+ /**
+ * 消息类型
+ */
+ 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 {
+ 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)
+ }
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Executor.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Executor.kt
new file mode 100644
index 0000000..2cc08fd
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Executor.kt
@@ -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 格式 (......)
+ */
+ fun parseResponse(response: String): ExecutorResult {
+ // 检测是否为 MAI-UI 格式
+ if (response.contains("") || response.contains("")) {
+ 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 格式响应
+ * 格式: ...{"name": "mobile_use", "arguments": {...}}
+ */
+ 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
+)
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/InfoPool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/InfoPool.kt
new file mode 100644
index 0000000..1a5bb2d
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/InfoPool.kt
@@ -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 = mutableListOf(),
+ val summaryHistory: MutableList = mutableListOf(),
+ val actionOutcomes: MutableList = mutableListOf(), // A=成功, B=错误页面, C=无变化
+ val errorDescriptions: MutableList = 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 格式: {"name": "mobile_use", "arguments": {...}}
+ */
+ fun fromJson(json: String): Action? {
+ val cleanJson = json.trim()
+ .replace("```json", "")
+ .replace("```", "")
+ .trim()
+
+ // 检查是否是 MAI-UI 的 格式
+ if (cleanJson.contains("")) {
+ 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 的 格式
+ * 格式: {"name": "mobile_use", "arguments": {"action": "click", "coordinate": [x, y]}}
+ */
+ fun fromMAIUIFormat(response: String): Action? {
+ return try {
+ // 提取 内容
+ val toolCallRegex = Regex("\\s*(.+?)\\s*", 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("\\s*(.+?)\\s*", 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()
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Manager.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Manager.kt
new file mode 100644
index 0000000..f58006b
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Manager.kt
@@ -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
+)
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/MobileAgent.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/MobileAgent.kt
new file mode 100644
index 0000000..75bc395
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/MobileAgent.kt
@@ -0,0 +1,1167 @@
+package com.roubao.autopilot.agent
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import com.roubao.autopilot.App
+import com.roubao.autopilot.controller.AppScanner
+import com.roubao.autopilot.controller.DeviceController
+import com.roubao.autopilot.data.ExecutionStep
+import com.roubao.autopilot.skills.SkillManager
+import com.roubao.autopilot.ui.OverlayService
+import com.roubao.autopilot.vlm.GUIOwlClient
+import com.roubao.autopilot.vlm.MAIUIClient
+import com.roubao.autopilot.vlm.VLMClient
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.coroutineContext
+import kotlin.coroutines.resume
+
+/**
+ * Mobile Agent 主循环 - 移植自 MobileAgent-v3
+ *
+ * 新增 Skill 层支持:
+ * - 快速路径:高置信度 delegation Skill 直接执行
+ * - 增强模式:GUI 自动化 Skill 提供上下文指导
+ *
+ * 支持三种模式:
+ * - OpenAI 兼容模式:使用 VLMClient (Manager → Executor → Reflector)
+ * - GUI-Owl 模式:使用 GUIOwlClient (直接返回操作指令)
+ * - MAI-UI 模式:使用 MAIUIClient (专用 prompt 和对话历史)
+ */
+class MobileAgent(
+ private val vlmClient: VLMClient?,
+ private val controller: DeviceController,
+ private val context: Context,
+ private val guiOwlClient: GUIOwlClient? = null, // GUI-Owl 专用客户端
+ private val maiuiClient: MAIUIClient? = null // MAI-UI 专用客户端
+) {
+ // 是否使用 GUI-Owl 模式
+ private val useGUIOwlMode: Boolean = guiOwlClient != null
+ // 是否使用 MAI-UI 模式
+ private val useMAIUIMode: Boolean = maiuiClient != null
+ // App 扫描器 (使用 App 单例中的实例)
+ private val appScanner: AppScanner = App.getInstance().appScanner
+ private val manager = Manager()
+ private val executor = Executor()
+ private val reflector = ActionReflector()
+ private val notetaker = Notetaker()
+
+ // Skill 管理器
+ private val skillManager: SkillManager? = try {
+ SkillManager.getInstance().also {
+ println("[肉包] SkillManager 已加载,共 ${it.getAllSkills().size} 个 Skills")
+ // 设置 VLM 客户端用于意图匹配(仅在 OpenAI 兼容模式下)
+ vlmClient?.let { client -> it.setVLMClient(client) }
+ }
+ } catch (e: Exception) {
+ println("[肉包] SkillManager 加载失败: ${e.message}")
+ null
+ }
+
+ // 状态流
+ private val _state = MutableStateFlow(AgentState())
+ val state: StateFlow = _state
+
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs: StateFlow> = _logs
+
+ /**
+ * 执行指令
+ */
+ suspend fun runInstruction(
+ instruction: String,
+ maxSteps: Int = 25,
+ useNotetaker: Boolean = false
+ ): AgentResult {
+ log("开始执行: $instruction")
+
+ // 根据模式选择执行路径
+ if (useGUIOwlMode && guiOwlClient != null) {
+ log("使用 GUI-Owl 模式")
+ return runInstructionWithGUIOwl(instruction, maxSteps)
+ }
+
+ if (useMAIUIMode && maiuiClient != null) {
+ log("使用 MAI-UI 模式")
+ return runInstructionWithMAIUI(instruction, maxSteps)
+ }
+
+ // OpenAI 兼容模式需要 VLMClient
+ if (vlmClient == null) {
+ log("错误: VLMClient 未初始化")
+ return AgentResult(success = false, message = "VLMClient 未初始化")
+ }
+
+ log("使用 OpenAI 兼容模式")
+
+ // 使用 LLM 匹配 Skill,生成上下文信息给 Agent(不执行任何操作)
+ log("正在分析意图...")
+ val skillContext = skillManager?.generateAgentContextWithLLM(instruction)
+
+ val infoPool = InfoPool(instruction = instruction)
+
+ // 初始化 Executor 的对话记忆
+ val executorSystemPrompt = 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: $instruction\n")
+ }
+ infoPool.executorMemory = ConversationMemory.withSystemPrompt(executorSystemPrompt)
+ log("已初始化对话记忆")
+
+ // 如果有 Skill 上下文,添加到 InfoPool,让 Manager 知道可用的工具
+ if (!skillContext.isNullOrEmpty() && skillContext != "未找到相关技能或可用应用,请使用通用 GUI 自动化完成任务。") {
+ infoPool.skillContext = skillContext
+ log("已匹配到可用技能:\n$skillContext")
+ } else {
+ log("未匹配到特定技能,使用通用 GUI 自动化")
+ }
+
+ // 获取屏幕尺寸
+ val (width, height) = controller.getScreenSize()
+ infoPool.screenWidth = width
+ infoPool.screenHeight = height
+ log("屏幕尺寸: ${width}x${height}")
+
+ // 获取已安装应用列表(只取非系统应用,限制数量避免 prompt 过长)
+ val apps = appScanner.getApps()
+ .filter { !it.isSystem }
+ .take(50)
+ .map { it.appName }
+ infoPool.installedApps = apps.joinToString(", ")
+ log("已加载 ${apps.size} 个应用")
+
+ // 显示悬浮窗 (带停止按钮)
+ OverlayService.show(context, "开始执行...") {
+ // 停止回调 - 设置状态为停止
+ // 注意:协程取消需要在 MainActivity 中处理
+ updateState { copy(isRunning = false) }
+ // 调用 stop() 方法确保清理
+ stop()
+ }
+
+ updateState { copy(isRunning = true, currentStep = 0, instruction = instruction) }
+
+ try {
+ for (step in 0 until maxSteps) {
+ // 检查协程是否被取消
+ coroutineContext.ensureActive()
+
+ // 检查是否被用户停止
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ updateState { copy(currentStep = step + 1) }
+ log("\n========== Step ${step + 1} ==========")
+ OverlayService.update("Step ${step + 1}/$maxSteps")
+
+ // 1. 截图 (先隐藏悬浮窗避免被识别)
+ log("截图中...")
+ OverlayService.setVisible(false)
+ delay(100) // 等待悬浮窗隐藏
+ val screenshotResult = controller.screenshotWithFallback()
+ OverlayService.setVisible(true)
+ val screenshot = screenshotResult.bitmap
+
+ // 处理敏感页面(截图被系统阻止)
+ if (screenshotResult.isSensitive) {
+ log("⚠️ 检测到敏感页面(截图被阻止),请求人工接管")
+ val confirmed = withContext(Dispatchers.Main) {
+ waitForUserConfirm("检测到敏感页面,是否继续执行?")
+ }
+ if (!confirmed) {
+ log("用户取消,任务终止")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "敏感页面,用户取消")
+ }
+ log("用户确认继续(使用黑屏占位图)")
+ } else if (screenshotResult.isFallback) {
+ log("⚠️ 截图失败,使用黑屏占位图继续")
+ }
+
+ // 再次检查停止状态(截图后)
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ // 2. 检查错误升级
+ checkErrorEscalation(infoPool)
+
+ // 3. 跳过 Manager 的情况
+ val skipManager = !infoPool.errorFlagPlan &&
+ infoPool.actionHistory.isNotEmpty() &&
+ infoPool.actionHistory.last().type == "invalid"
+
+ // 4. Manager 规划
+ if (!skipManager) {
+ log("Manager 规划中...")
+
+ // 检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ val planPrompt = manager.getPrompt(infoPool)
+ val planResponse = vlmClient.predict(planPrompt, listOf(screenshot))
+
+ // VLM 调用后检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ if (planResponse.isFailure) {
+ log("Manager 调用失败: ${planResponse.exceptionOrNull()?.message}")
+ continue
+ }
+
+ val planResult = manager.parseResponse(planResponse.getOrThrow())
+ infoPool.completedPlan = planResult.completedSubgoal
+ infoPool.plan = planResult.plan
+
+ log("计划: ${planResult.plan.take(100)}...")
+
+ // 检查是否遇到敏感页面
+ if (planResult.plan.contains("STOP_SENSITIVE")) {
+ log("检测到敏感页面(支付/密码等),已停止执行")
+ OverlayService.update("敏感页面,已停止")
+ delay(2000)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = false) }
+ bringAppToFront()
+ return AgentResult(success = false, message = "检测到敏感页面(支付/密码),已安全停止")
+ }
+
+ // 检查是否完成
+ if (planResult.plan.contains("Finished") && planResult.plan.length < 20) {
+ log("任务完成!")
+ OverlayService.update("完成!")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = true) }
+ bringAppToFront()
+ return AgentResult(success = true, message = "任务完成")
+ }
+ }
+
+ // 5. Executor 决定动作 (使用上下文记忆)
+ log("Executor 决策中...")
+
+ // 检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ val actionPrompt = executor.getPrompt(infoPool)
+
+ // 使用上下文记忆调用 VLM
+ val memory = infoPool.executorMemory
+ val actionResponse = if (memory != null) {
+ // 添加用户消息(带截图)
+ memory.addUserMessage(actionPrompt, screenshot)
+ log("记忆消息数: ${memory.size()}, 估算 token: ${memory.estimateTokens()}")
+
+ // 调用 VLM
+ val response = vlmClient.predictWithContext(memory.toMessagesJson())
+
+ // 删除图片节省 token
+ memory.stripLastUserImage()
+
+ response
+ } else {
+ // 降级:使用普通方式
+ vlmClient.predict(actionPrompt, listOf(screenshot))
+ }
+
+ // VLM 调用后检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ if (actionResponse.isFailure) {
+ log("Executor 调用失败: ${actionResponse.exceptionOrNull()?.message}")
+ continue
+ }
+
+ val responseText = actionResponse.getOrThrow()
+ val executorResult = executor.parseResponse(responseText)
+
+ // 将助手响应添加到记忆
+ memory?.addAssistantMessage(responseText)
+ val action = executorResult.action
+
+ log("思考: ${executorResult.thought.take(80)}...")
+ log("动作: ${executorResult.actionStr}")
+ log("描述: ${executorResult.description}")
+
+ infoPool.lastActionThought = executorResult.thought
+ infoPool.lastSummary = executorResult.description
+
+ if (action == null) {
+ log("动作解析失败")
+ infoPool.actionHistory.add(Action(type = "invalid"))
+ infoPool.summaryHistory.add(executorResult.description)
+ infoPool.actionOutcomes.add("C")
+ infoPool.errorDescriptions.add("Invalid action format")
+ continue
+ }
+
+ // 特殊处理: answer 动作
+ if (action.type == "answer") {
+ log("回答: ${action.text}")
+ OverlayService.update("${action.text?.take(20)}...")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = true, answer = action.text) }
+ bringAppToFront()
+ return AgentResult(success = true, message = "回答: ${action.text}")
+ }
+
+ // 特殊处理: terminate 动作 (MAI-UI)
+ if (action.type == "terminate") {
+ val success = action.status == "success"
+ log("任务${if (success) "完成" else "失败"}")
+ OverlayService.update(if (success) "完成!" else "失败")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = success) }
+ bringAppToFront()
+ return AgentResult(success = success, message = if (success) "任务完成" else "任务失败")
+ }
+
+ // 6. 敏感操作确认
+ if (action.needConfirm || action.message != null && action.type in listOf("click", "double_tap", "long_press")) {
+ val confirmMessage = action.message ?: "确认执行此操作?"
+ log("⚠️ 敏感操作: $confirmMessage")
+
+ val confirmed = withContext(Dispatchers.Main) {
+ waitForUserConfirm(confirmMessage)
+ }
+
+ if (!confirmed) {
+ log("❌ 用户取消操作")
+ infoPool.actionHistory.add(action)
+ infoPool.summaryHistory.add("用户取消: ${executorResult.description}")
+ infoPool.actionOutcomes.add("C")
+ infoPool.errorDescriptions.add("User cancelled")
+ continue
+ }
+ log("✅ 用户确认,继续执行")
+ }
+
+ // 7. 执行动作
+ log("执行动作: ${action.type}")
+ OverlayService.update("${action.type}: ${executorResult.description.take(15)}...")
+ executeAction(action, infoPool)
+ infoPool.lastAction = action
+
+ // 立即记录执行步骤(outcome 暂时为 "?" 表示进行中)
+ val currentStepIndex = _state.value.executionSteps.size
+ val executionStep = ExecutionStep(
+ stepNumber = step + 1,
+ timestamp = System.currentTimeMillis(),
+ action = action.type,
+ description = executorResult.description,
+ thought = executorResult.thought,
+ outcome = "?" // 进行中
+ )
+ updateState { copy(executionSteps = executionSteps + executionStep) }
+
+ // 等待动作生效
+ delay(if (step == 0) 5000 else 2000)
+
+ // 检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ // 8. 截图 (动作后,隐藏悬浮窗)
+ OverlayService.setVisible(false)
+ delay(100)
+ val afterScreenshotResult = controller.screenshotWithFallback()
+ OverlayService.setVisible(true)
+ val afterScreenshot = afterScreenshotResult.bitmap
+ if (afterScreenshotResult.isFallback) {
+ log("动作后截图失败,使用黑屏占位图")
+ }
+
+ // 9. Reflector 反思
+ log("Reflector 反思中...")
+
+ // 检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ val reflectPrompt = reflector.getPrompt(infoPool)
+ val reflectResponse = vlmClient.predict(reflectPrompt, listOf(screenshot, afterScreenshot))
+
+ val reflectResult = if (reflectResponse.isSuccess) {
+ reflector.parseResponse(reflectResponse.getOrThrow())
+ } else {
+ ReflectorResult("C", "Failed to call reflector")
+ }
+
+ log("结果: ${reflectResult.outcome} - ${reflectResult.errorDescription.take(50)}")
+
+ // 更新历史
+ infoPool.actionHistory.add(action)
+ infoPool.summaryHistory.add(executorResult.description)
+ infoPool.actionOutcomes.add(reflectResult.outcome)
+ infoPool.errorDescriptions.add(reflectResult.errorDescription)
+ infoPool.progressStatus = infoPool.completedPlan
+
+ // 更新执行步骤的 outcome(之前添加的步骤 outcome 是 "?")
+ updateState {
+ val updatedSteps = executionSteps.toMutableList()
+ if (currentStepIndex < updatedSteps.size) {
+ updatedSteps[currentStepIndex] = updatedSteps[currentStepIndex].copy(
+ outcome = reflectResult.outcome
+ )
+ }
+ copy(executionSteps = updatedSteps)
+ }
+
+ // 10. Notetaker (可选)
+ if (useNotetaker && reflectResult.outcome == "A" && action.type != "answer") {
+ log("Notetaker 记录中...")
+
+ // 检查停止状态
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ val notePrompt = notetaker.getPrompt(infoPool)
+ val noteResponse = vlmClient.predict(notePrompt, listOf(afterScreenshot))
+ if (noteResponse.isSuccess) {
+ infoPool.importantNotes = notetaker.parseResponse(noteResponse.getOrThrow())
+ }
+ }
+ }
+ } catch (e: CancellationException) {
+ log("任务被取消")
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false) }
+ bringAppToFront()
+ throw e
+ }
+
+ log("达到最大步数限制")
+ OverlayService.update("达到最大步数")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = false) }
+ bringAppToFront()
+ return AgentResult(success = false, message = "达到最大步数限制")
+ }
+
+ /**
+ * GUI-Owl 模式执行指令
+ * 简化流程:截图 → GUI-Owl API → 解析操作 → 执行
+ */
+ private suspend fun runInstructionWithGUIOwl(
+ instruction: String,
+ maxSteps: Int
+ ): AgentResult {
+ val client = guiOwlClient ?: return AgentResult(false, "GUIOwlClient 未初始化")
+
+ // 重置会话
+ client.resetSession()
+ log("GUI-Owl 会话已重置")
+
+ // 获取屏幕尺寸
+ val (screenWidth, screenHeight) = controller.getScreenSize()
+ log("屏幕尺寸: ${screenWidth}x${screenHeight}")
+
+ // 显示悬浮窗
+ OverlayService.show(context, "GUI-Owl 模式") {
+ updateState { copy(isRunning = false) }
+ stop()
+ }
+
+ updateState { copy(isRunning = true, currentStep = 0, instruction = instruction) }
+
+ try {
+ for (step in 0 until maxSteps) {
+ coroutineContext.ensureActive()
+
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ updateState { copy(currentStep = step + 1) }
+ log("\n========== Step ${step + 1} (GUI-Owl) ==========")
+ OverlayService.update("Step ${step + 1}/$maxSteps")
+
+ // 1. 截图
+ log("截图中...")
+ OverlayService.setVisible(false)
+ delay(100)
+ val screenshotResult = controller.screenshotWithFallback()
+ OverlayService.setVisible(true)
+ val screenshot = screenshotResult.bitmap
+
+ if (screenshotResult.isSensitive) {
+ log("⚠️ 检测到敏感页面")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "敏感页面,已停止")
+ }
+
+ // 2. 调用 GUI-Owl API
+ log("调用 GUI-Owl API...")
+ val response = client.predict(instruction, screenshot)
+
+ if (response.isFailure) {
+ log("GUI-Owl 调用失败: ${response.exceptionOrNull()?.message}")
+ continue
+ }
+
+ val result = response.getOrThrow()
+ log("思考: ${result.thought.take(100)}...")
+ log("操作: ${result.operation}")
+ log("说明: ${result.explanation}")
+
+ // 3. 解析操作指令
+ val parsedAction = client.parseOperation(result.operation)
+ if (parsedAction == null) {
+ log("无法解析操作: ${result.operation}")
+ continue
+ }
+
+ // 记录执行步骤
+ val executionStep = ExecutionStep(
+ stepNumber = step + 1,
+ timestamp = System.currentTimeMillis(),
+ action = parsedAction.type,
+ description = result.explanation,
+ thought = result.thought,
+ outcome = "?"
+ )
+ updateState { copy(executionSteps = executionSteps + executionStep) }
+
+ // 检查是否完成
+ if (parsedAction.type == "finish") {
+ log("任务完成!")
+ OverlayService.update("完成!")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = true) }
+ bringAppToFront()
+ return AgentResult(success = true, message = "任务完成")
+ }
+
+ // 4. 执行动作
+ log("执行动作: ${parsedAction.type}")
+ OverlayService.update("${parsedAction.type}: ${result.explanation.take(15)}...")
+ executeGUIOwlAction(parsedAction, screenWidth, screenHeight)
+
+ // 更新步骤状态
+ updateState {
+ val updatedSteps = executionSteps.toMutableList()
+ if (step < updatedSteps.size) {
+ updatedSteps[step] = updatedSteps[step].copy(outcome = "A")
+ }
+ copy(executionSteps = updatedSteps)
+ }
+
+ // 等待动作生效
+ delay(if (step == 0) 3000 else 1500)
+ }
+ } catch (e: CancellationException) {
+ log("任务被取消")
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false) }
+ bringAppToFront()
+ throw e
+ }
+
+ log("达到最大步数限制")
+ OverlayService.update("达到最大步数")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = false) }
+ bringAppToFront()
+ return AgentResult(success = false, message = "达到最大步数限制")
+ }
+
+ /**
+ * 执行 GUI-Owl 解析的动作
+ */
+ private suspend fun executeGUIOwlAction(
+ action: GUIOwlClient.ParsedAction,
+ screenWidth: Int,
+ screenHeight: Int
+ ) = withContext(Dispatchers.IO) {
+ when (action.type) {
+ "click" -> {
+ val x = action.x ?: return@withContext
+ val y = action.y ?: return@withContext
+ log("点击: ($x, $y)")
+ controller.tap(x, y)
+ }
+ "swipe" -> {
+ val x1 = action.x ?: return@withContext
+ val y1 = action.y ?: return@withContext
+ val x2 = action.x2 ?: return@withContext
+ val y2 = action.y2 ?: return@withContext
+ log("滑动: ($x1, $y1) -> ($x2, $y2)")
+ controller.swipe(x1, y1, x2, y2)
+ }
+ "long_press" -> {
+ val x = action.x ?: return@withContext
+ val y = action.y ?: return@withContext
+ log("长按: ($x, $y)")
+ controller.longPress(x, y)
+ }
+ "type" -> {
+ val text = action.text ?: return@withContext
+ log("输入: $text")
+ controller.type(text)
+ }
+ "scroll" -> {
+ val direction = action.text ?: "down"
+ val centerX = screenWidth / 2
+ val centerY = screenHeight / 2
+ log("滚动: $direction")
+ when (direction) {
+ "up" -> controller.swipe(centerX, centerY + 300, centerX, centerY - 300)
+ "down" -> controller.swipe(centerX, centerY - 300, centerX, centerY + 300)
+ "left" -> controller.swipe(centerX + 300, centerY, centerX - 300, centerY)
+ "right" -> controller.swipe(centerX - 300, centerY, centerX + 300, centerY)
+ }
+ }
+ "system_button" -> {
+ when (action.text?.lowercase()) {
+ "back" -> {
+ log("按返回键")
+ controller.back()
+ }
+ "home" -> {
+ log("按 Home 键")
+ controller.home()
+ }
+ }
+ }
+ else -> {
+ log("未知动作类型: ${action.type}")
+ }
+ }
+ }
+
+ /**
+ * MAI-UI 模式执行指令
+ * 使用专用的 MAI-UI prompt 和对话历史管理
+ */
+ private suspend fun runInstructionWithMAIUI(
+ instruction: String,
+ maxSteps: Int
+ ): AgentResult {
+ val client = maiuiClient ?: return AgentResult(false, "MAIUIClient 未初始化")
+
+ // 重置会话
+ client.reset()
+ log("MAI-UI 会话已重置")
+
+ // 获取屏幕尺寸
+ val (screenWidth, screenHeight) = controller.getScreenSize()
+ log("屏幕尺寸: ${screenWidth}x${screenHeight}")
+
+ // 设置可用应用列表
+ val installedApps = appScanner.getApps().map { it.appName }
+ client.setAvailableApps(installedApps)
+ log("已加载 ${installedApps.size} 个应用")
+
+ // 显示悬浮窗
+ OverlayService.show(context, "MAI-UI 模式") {
+ updateState { copy(isRunning = false) }
+ stop()
+ }
+
+ updateState { copy(isRunning = true, currentStep = 0, instruction = instruction) }
+
+ try {
+ for (step in 0 until maxSteps) {
+ coroutineContext.ensureActive()
+
+ if (!_state.value.isRunning) {
+ log("用户停止执行")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "用户停止")
+ }
+
+ updateState { copy(currentStep = step + 1) }
+ log("\n========== Step ${step + 1} (MAI-UI) ==========")
+ OverlayService.update("Step ${step + 1}/$maxSteps")
+
+ // 1. 截图
+ log("截图中...")
+ OverlayService.setVisible(false)
+ delay(100)
+ val screenshotResult = controller.screenshotWithFallback()
+ OverlayService.setVisible(true)
+ val screenshot = screenshotResult.bitmap
+
+ if (screenshotResult.isSensitive) {
+ log("⚠️ 检测到敏感页面")
+ OverlayService.hide(context)
+ bringAppToFront()
+ return AgentResult(success = false, message = "敏感页面,已停止")
+ }
+
+ // 2. 调用 MAI-UI API
+ log("调用 MAI-UI API...")
+ val response = client.predict(instruction, screenshot)
+
+ if (response.isFailure) {
+ log("MAI-UI 调用失败: ${response.exceptionOrNull()?.message}")
+ continue
+ }
+
+ val result = response.getOrThrow()
+ log("思考: ${result.thinking.take(150)}...")
+
+ val action = result.action
+ if (action == null) {
+ log("无法解析动作")
+ continue
+ }
+
+ log("动作: ${action.type}")
+
+ // 记录执行步骤
+ val executionStep = ExecutionStep(
+ stepNumber = step + 1,
+ timestamp = System.currentTimeMillis(),
+ action = action.type,
+ description = result.thinking.take(50),
+ thought = result.thinking,
+ outcome = "?"
+ )
+ updateState { copy(executionSteps = executionSteps + executionStep) }
+
+ // 检查是否完成
+ if (action.type == "terminate") {
+ val success = action.status == "success"
+ log(if (success) "任务完成!" else "任务失败")
+ OverlayService.update(if (success) "完成!" else "失败")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = success) }
+ bringAppToFront()
+ return AgentResult(success = success, message = if (success) "任务完成" else "任务失败")
+ }
+
+ // 检查是否需要人工接管
+ if (action.type == "ask_user") {
+ log("请求用户介入: ${action.text}")
+ OverlayService.update("请手动操作: ${action.text?.take(20)}")
+ // 等待用户操作后继续
+ delay(5000)
+ continue
+ }
+
+ // 检查是否是回答
+ if (action.type == "answer") {
+ log("回答: ${action.text}")
+ OverlayService.update("答案: ${action.text?.take(30)}")
+ delay(3000)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = true) }
+ bringAppToFront()
+ return AgentResult(success = true, message = "回答: ${action.text}")
+ }
+
+ // 3. 执行动作
+ log("执行动作: ${action.type}")
+ OverlayService.update("${action.type}...")
+ executeMAIUIAction(action, screenWidth, screenHeight)
+
+ // 更新步骤状态
+ updateState {
+ val updatedSteps = executionSteps.toMutableList()
+ if (step < updatedSteps.size) {
+ updatedSteps[step] = updatedSteps[step].copy(outcome = "A")
+ }
+ copy(executionSteps = updatedSteps)
+ }
+
+ // 等待动作生效
+ delay(if (step == 0) 2000 else 1000)
+ }
+ } catch (e: CancellationException) {
+ log("任务被取消")
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false) }
+ bringAppToFront()
+ throw e
+ }
+
+ log("达到最大步数限制")
+ OverlayService.update("达到最大步数")
+ delay(1500)
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false, isCompleted = false) }
+ bringAppToFront()
+ return AgentResult(success = false, message = "达到最大步数限制")
+ }
+
+ /**
+ * 执行 MAI-UI 解析的动作
+ */
+ private suspend fun executeMAIUIAction(
+ action: com.roubao.autopilot.vlm.MAIUIAction,
+ screenWidth: Int,
+ screenHeight: Int
+ ) = withContext(Dispatchers.IO) {
+ // 转换归一化坐标到屏幕像素
+ val screenAction = action.toScreenCoordinates(screenWidth, screenHeight)
+
+ when (action.type) {
+ "click" -> {
+ val x = screenAction.x?.toInt() ?: return@withContext
+ val y = screenAction.y?.toInt() ?: return@withContext
+ log("点击: ($x, $y)")
+ controller.tap(x, y)
+ }
+ "long_press" -> {
+ val x = screenAction.x?.toInt() ?: return@withContext
+ val y = screenAction.y?.toInt() ?: return@withContext
+ log("长按: ($x, $y)")
+ controller.longPress(x, y)
+ }
+ "double_click" -> {
+ val x = screenAction.x?.toInt() ?: return@withContext
+ val y = screenAction.y?.toInt() ?: return@withContext
+ log("双击: ($x, $y)")
+ controller.tap(x, y)
+ delay(100)
+ controller.tap(x, y)
+ }
+ "type" -> {
+ val text = action.text ?: return@withContext
+ log("输入: $text")
+ controller.type(text)
+ }
+ "swipe" -> {
+ // 支持方向式滑动和坐标式滑动
+ if (action.direction != null) {
+ val centerX = screenWidth / 2
+ val centerY = screenHeight / 2
+ val distance = screenHeight / 3
+ log("滑动: ${action.direction}")
+ when (action.direction.lowercase()) {
+ "up" -> controller.swipe(centerX, centerY + distance, centerX, centerY - distance)
+ "down" -> controller.swipe(centerX, centerY - distance, centerX, centerY + distance)
+ "left" -> controller.swipe(centerX + distance, centerY, centerX - distance, centerY)
+ "right" -> controller.swipe(centerX - distance, centerY, centerX + distance, centerY)
+ }
+ } else if (screenAction.x != null && screenAction.y != null) {
+ // 从指定位置滑动
+ val startX = screenAction.x.toInt()
+ val startY = screenAction.y.toInt()
+ val distance = screenHeight / 3
+ val direction = action.direction ?: "up"
+ when (direction.lowercase()) {
+ "up" -> controller.swipe(startX, startY, startX, startY - distance)
+ "down" -> controller.swipe(startX, startY, startX, startY + distance)
+ "left" -> controller.swipe(startX, startY, startX - distance, startY)
+ "right" -> controller.swipe(startX, startY, startX + distance, startY)
+ }
+ }
+ }
+ "drag" -> {
+ val x1 = screenAction.startX?.toInt() ?: return@withContext
+ val y1 = screenAction.startY?.toInt() ?: return@withContext
+ val x2 = screenAction.endX?.toInt() ?: return@withContext
+ val y2 = screenAction.endY?.toInt() ?: return@withContext
+ log("拖拽: ($x1, $y1) -> ($x2, $y2)")
+ controller.swipe(x1, y1, x2, y2, 500)
+ }
+ "open" -> {
+ val appName = action.text ?: return@withContext
+ log("打开应用: $appName")
+ controller.openApp(appName)
+ }
+ "system_button" -> {
+ when (action.button?.lowercase()) {
+ "back" -> {
+ log("按返回键")
+ controller.back()
+ }
+ "home" -> {
+ log("按 Home 键")
+ controller.home()
+ }
+ "menu" -> {
+ log("按菜单键")
+ // 通过 keyevent 模拟菜单键
+ controller.back() // 大多数场景用返回代替
+ }
+ "enter" -> {
+ log("按回车键")
+ controller.enter()
+ }
+ }
+ }
+ "wait" -> {
+ log("等待...")
+ delay(2000)
+ }
+ else -> {
+ log("未知动作类型: ${action.type}")
+ }
+ }
+ }
+
+ /**
+ * 执行具体动作 (在 IO 线程执行,避免 ANR)
+ */
+ private suspend fun executeAction(action: Action, infoPool: InfoPool) = withContext(Dispatchers.IO) {
+ // 动态获取屏幕尺寸(处理横竖屏切换)
+ val (screenWidth, screenHeight) = controller.getScreenSize()
+
+ when (action.type) {
+ "click" -> {
+ val x = mapCoordinate(action.x ?: 0, screenWidth)
+ val y = mapCoordinate(action.y ?: 0, screenHeight)
+ controller.tap(x, y)
+ }
+ "double_tap" -> {
+ val x = mapCoordinate(action.x ?: 0, screenWidth)
+ val y = mapCoordinate(action.y ?: 0, screenHeight)
+ controller.doubleTap(x, y)
+ }
+ "long_press" -> {
+ val x = mapCoordinate(action.x ?: 0, screenWidth)
+ val y = mapCoordinate(action.y ?: 0, screenHeight)
+ controller.longPress(x, y)
+ }
+ "swipe" -> {
+ // 支持两种 swipe 方式:
+ // 1. 坐标方式: coordinate + coordinate2
+ // 2. 方向方式: direction (up/down/left/right) + optional coordinate
+ if (action.direction != null) {
+ // 方向方式 (MAI-UI 格式)
+ val centerX = action.x?.let { mapCoordinate(it, screenWidth) } ?: (screenWidth / 2)
+ val centerY = action.y?.let { mapCoordinate(it, screenHeight) } ?: (screenHeight / 2)
+ val distance = 400 // 滑动距离
+ when (action.direction.lowercase()) {
+ "up" -> controller.swipe(centerX, centerY + distance, centerX, centerY - distance)
+ "down" -> controller.swipe(centerX, centerY - distance, centerX, centerY + distance)
+ "left" -> controller.swipe(centerX + distance, centerY, centerX - distance, centerY)
+ "right" -> controller.swipe(centerX - distance, centerY, centerX + distance, centerY)
+ else -> log("未知滑动方向: ${action.direction}")
+ }
+ } else {
+ // 坐标方式
+ val x1 = mapCoordinate(action.x ?: 0, screenWidth)
+ val y1 = mapCoordinate(action.y ?: 0, screenHeight)
+ val x2 = mapCoordinate(action.x2 ?: 0, screenWidth)
+ val y2 = mapCoordinate(action.y2 ?: 0, screenHeight)
+ controller.swipe(x1, y1, x2, y2)
+ }
+ }
+ "type" -> {
+ action.text?.let { controller.type(it) }
+ }
+ "system_button" -> {
+ when (action.button) {
+ "Back", "back" -> controller.back()
+ "Home", "home" -> controller.home()
+ "Enter", "enter" -> controller.enter()
+ else -> log("未知系统按钮: ${action.button}")
+ }
+ }
+ "open_app" -> {
+ action.text?.let { appName ->
+ // 智能匹配包名 (客户端模糊搜索,省 token)
+ val packageName = appScanner.findPackage(appName)
+ if (packageName != null) {
+ log("找到应用: $appName -> $packageName")
+ controller.openApp(packageName)
+ } else {
+ log("未找到应用: $appName,尝试直接打开")
+ controller.openApp(appName)
+ }
+ }
+ }
+ "wait" -> {
+ // 智能等待:模型决定等待时长
+ val duration = (action.duration ?: 3).coerceIn(1, 10)
+ log("等待 ${duration} 秒...")
+ delay(duration * 1000L)
+ }
+ "take_over" -> {
+ // 人机协作:暂停等待用户手动完成操作
+ val message = action.message ?: "请完成操作后点击继续"
+ log("🖐 人机协作: $message")
+ withContext(Dispatchers.Main) {
+ waitForUserTakeOver(message)
+ }
+ log("✅ 用户已完成,继续执行")
+ }
+ else -> {
+ log("未知动作类型: ${action.type}")
+ }
+ }
+ }
+
+ /**
+ * 等待用户完成手动操作(人机协作)
+ */
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+ private suspend fun waitForUserTakeOver(message: String) = suspendCancellableCoroutine { continuation ->
+ com.roubao.autopilot.ui.OverlayService.showTakeOver(message) {
+ if (continuation.isActive) {
+ continuation.resume(Unit) {}
+ }
+ }
+ }
+
+ /**
+ * 等待用户确认敏感操作
+ * @return true = 用户确认,false = 用户取消
+ */
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+ private suspend fun waitForUserConfirm(message: String) = suspendCancellableCoroutine { continuation ->
+ com.roubao.autopilot.ui.OverlayService.showConfirm(message) { confirmed ->
+ if (continuation.isActive) {
+ continuation.resume(confirmed) {}
+ }
+ }
+ }
+
+ /**
+ * 坐标映射 - 支持相对坐标和绝对坐标
+ *
+ * 坐标格式判断:
+ * - 0-999: Qwen-VL 相对坐标 (0-999 映射到屏幕)
+ * - >= 1000: 绝对像素坐标,直接使用
+ *
+ * @param value 模型输出的坐标值
+ * @param screenMax 屏幕实际尺寸
+ */
+ private fun mapCoordinate(value: Int, screenMax: Int): Int {
+ return if (value < 1000) {
+ // 相对坐标 (0-999) -> 绝对像素
+ (value * screenMax / 999)
+ } else {
+ // 绝对坐标,限制在屏幕范围内
+ value.coerceAtMost(screenMax)
+ }
+ }
+
+ /**
+ * 检查错误升级
+ */
+ private fun checkErrorEscalation(infoPool: InfoPool) {
+ infoPool.errorFlagPlan = false
+ val thresh = infoPool.errToManagerThresh
+
+ if (infoPool.actionOutcomes.size >= thresh) {
+ val recentOutcomes = infoPool.actionOutcomes.takeLast(thresh)
+ val failCount = recentOutcomes.count { it in listOf("B", "C") }
+ if (failCount == thresh) {
+ infoPool.errorFlagPlan = true
+ }
+ }
+ }
+
+ // 停止回调(由 MainActivity 设置,用于取消协程)
+ var onStopRequested: (() -> Unit)? = null
+
+ /**
+ * 停止执行
+ */
+ fun stop() {
+ OverlayService.hide(context)
+ updateState { copy(isRunning = false) }
+ // 通知 MainActivity 取消协程
+ onStopRequested?.invoke()
+ }
+
+ /**
+ * 清空日志
+ */
+ fun clearLogs() {
+ _logs.value = emptyList()
+ updateState { copy(executionSteps = emptyList()) }
+ }
+
+ /**
+ * 返回肉包App
+ */
+ private fun bringAppToFront() {
+ try {
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ log("返回App失败: ${e.message}")
+ }
+ }
+
+ private fun log(message: String) {
+ println("[肉包] $message")
+ _logs.value = _logs.value + message
+ }
+
+ private fun updateState(update: AgentState.() -> AgentState) {
+ _state.value = _state.value.update()
+ }
+
+}
+
+data class AgentState(
+ val isRunning: Boolean = false,
+ val isCompleted: Boolean = false,
+ val currentStep: Int = 0,
+ val instruction: String = "",
+ val answer: String? = null,
+ val executionSteps: List = emptyList()
+)
+
+data class AgentResult(
+ val success: Boolean,
+ val message: String
+)
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Notetaker.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Notetaker.kt
new file mode 100644
index 0000000..b315922
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/agent/Notetaker.kt
@@ -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()
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/controller/AppScanner.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/controller/AppScanner.kt
new file mode 100644
index 0000000..e4d0dc6
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/controller/AppScanner.kt
@@ -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? = 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 // 关键词(用于搜索)
+ )
+
+ /**
+ * 搜索结果
+ */
+ data class SearchResult(
+ val app: AppInfo,
+ val score: Float, // 匹配分数 0-1
+ val matchType: String // 匹配类型:exact/contains/pinyin/category/semantic
+ )
+
+ /**
+ * 获取应用列表 (优先内存 -> 文件 -> 扫描)
+ */
+ fun getApps(): List {
+ 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 {
+ 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 {
+ val pm = context.packageManager
+ val apps = mutableListOf()
+
+ 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 {
+ val apps = getApps()
+ val lowerQuery = query.lowercase().trim()
+ val results = mutableListOf()
+
+ // 先检查是否是语义查询,转换为分类
+ 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 {
+ return getApps().filter { it.category == category }
+ }
+
+ /**
+ * 获取所有分类
+ */
+ fun getAllCategories(): List {
+ return getApps().mapNotNull { it.category }.distinct().sorted()
+ }
+
+ /**
+ * 格式化搜索结果给 LLM
+ */
+ fun formatSearchResultsForLLM(results: List): 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 {
+ val keywords = mutableListOf()
+
+ // 从包名提取关键词
+ 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, 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 {
+ val apps = mutableListOf()
+ 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()
+ 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/controller/DeviceController.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/controller/DeviceController.kt
new file mode 100644
index 0000000..6a69a5a
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/controller/DeviceController.kt
@@ -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 {
+ 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\"")
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/data/ExecutionHistory.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/data/ExecutionHistory.kt
new file mode 100644
index 0000000..596561c
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/data/ExecutionHistory.kt
@@ -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 = emptyList(),
+ val logs: List = 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()
+ for (i in 0 until stepsArray.length()) {
+ steps.add(ExecutionStep.fromJson(stepsArray.getJSONObject(i)))
+ }
+ val logsArray = json.optJSONArray("logs") ?: JSONArray()
+ val logs = mutableListOf()
+ 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 = withContext(Dispatchers.IO) {
+ try {
+ if (!historyFile.exists()) return@withContext emptyList()
+ val json = historyFile.readText()
+ val array = JSONArray(json)
+ val records = mutableListOf()
+ 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()
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/data/SettingsManager.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/data/SettingsManager.kt
new file mode 100644
index 0000000..24ab391
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/data/SettingsManager.kt
@@ -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 = emptyList(),
+ val customBaseUrl: String = "" // 仅 custom 服务商使用
+)
+
+/**
+ * 默认推荐模型
+ */
+const val DEFAULT_MODEL = "qwen3-vl-plus"
+
+/**
+ * 应用设置
+ */
+data class AppSettings(
+ val currentProviderId: String = ApiProvider.ALIYUN.id, // 当前选中的服务商
+ val providerConfigs: Map = 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 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 = _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()
+ 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) {
+ 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)
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/service/ShellService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/service/ShellService.kt
new file mode 100644
index 0000000..876e6fa
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/service/ShellService.kt
@@ -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}"
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/skills/Skill.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/Skill.kt
new file mode 100644
index 0000000..5d18456
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/Skill.kt
@@ -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? = 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 = emptyList()
+)
+
+/**
+ * Skill 配置(意图定义)
+ */
+data class SkillConfig(
+ val id: String,
+ val name: String,
+ val description: String,
+ val category: String,
+ val keywords: List,
+ val params: List,
+ val relatedApps: List,
+ val promptHint: String? = null // 提示词约束,如"内容不超过100字"
+)
+
+/**
+ * Skill 执行计划
+ *
+ * 根据用户意图和本地已安装 App 生成的执行方案
+ */
+data class ExecutionPlan(
+ val skillId: String,
+ val skillName: String,
+ val app: RelatedApp,
+ val params: Map,
+ 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
+ ) : 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 {
+ val params = mutableMapOf()
+
+ 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 {
+ 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
+)
+
+/**
+ * 可用应用匹配结果
+ */
+data class AvailableAppMatch(
+ val skill: Skill,
+ val app: RelatedApp,
+ val params: Map,
+ val score: Float
+)
+
+/**
+ * LLM 意图匹配结果
+ */
+data class LLMIntentMatch(
+ val skillId: String,
+ val confidence: Float,
+ val reasoning: String
+)
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillManager.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillManager.kt
new file mode 100644
index 0000000..d3f2e7b
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillManager.kt
@@ -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 {
+ 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
+ ): 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
+ ): 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 {
+ return registry.getAll()
+ }
+
+ /**
+ * 按分类获取 Skills
+ */
+ fun getSkillsByCategory(category: String): List {
+ return registry.getByCategory(category)
+ }
+
+ /**
+ * 检查意图是否有可用应用
+ */
+ fun hasAvailableApp(query: String): Boolean {
+ return matchAvailableApp(query) != null
+ }
+
+ /**
+ * 获取意图的所有关联应用(不管是否安装)
+ */
+ fun getAllRelatedApps(query: String): List {
+ val skillMatch = registry.matchBest(query) ?: return emptyList()
+ return skillMatch.skill.config.relatedApps
+ }
+
+ /**
+ * 获取缺失的应用推荐(用户没装但可以装的)
+ */
+ fun getMissingAppSuggestions(query: String): List {
+ 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillRegistry.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillRegistry.kt
new file mode 100644
index 0000000..9e336ef
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/skills/SkillRegistry.kt
@@ -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()
+ private val categoryIndex = mutableMapOf>()
+
+ // 缓存已安装 App 的包名集合(启动时刷新)
+ private var installedPackages: Set = 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()
+ val paramsArray = obj.optJSONArray("params")
+ if (paramsArray != null) {
+ for (i in 0 until paramsArray.length()) {
+ val paramObj = paramsArray.getJSONObject(i)
+ val examples = mutableListOf()
+ 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()
+ val keywordsArray = obj.optJSONArray("keywords")
+ if (keywordsArray != null) {
+ for (i in 0 until keywordsArray.length()) {
+ keywords.add(keywordsArray.getString(i))
+ }
+ }
+
+ // 解析关联应用列表(新结构)
+ val relatedApps = mutableListOf()
+ 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()
+ 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 = skills.values.toList()
+
+ /**
+ * 按分类获取 Skills
+ */
+ fun getByCategory(category: String): List {
+ return categoryIndex[category] ?: emptyList()
+ }
+
+ /**
+ * 获取所有分类
+ */
+ fun getAllCategories(): List = categoryIndex.keys.toList()
+
+ /**
+ * 匹配用户意图(基于关键词)
+ */
+ fun match(query: String, topK: Int = 3, minScore: Float = 0.3f): List {
+ val matches = mutableListOf()
+
+ 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 {
+ val skillMatches = match(query, topK = 5, minScore = minScore)
+ val results = mutableListOf()
+
+ 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/tools/ClipboardTool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/ClipboardTool.kt
new file mode 100644
index 0000000..52cbe4d
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/ClipboardTool.kt
@@ -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): 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/tools/DeepLinkTool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/DeepLinkTool.kt
new file mode 100644
index 0000000..d18037d
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/DeepLinkTool.kt
@@ -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): 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? {
+ val template = TEMPLATES[templateName] ?: return null
+ var result = template
+ for ((key, value) in params) {
+ result = result.replace("{$key}", value)
+ }
+ return result
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/tools/HttpTool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/HttpTool.kt
new file mode 100644
index 0000000..f412821
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/HttpTool.kt
@@ -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): 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 ?: 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 = emptyMap()): ToolResult {
+ return execute(mapOf(
+ "url" to url,
+ "method" to "GET",
+ "headers" to headers
+ ))
+ }
+
+ /**
+ * 简化的 POST 请求
+ */
+ suspend fun post(url: String, body: String, headers: Map = 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 = emptyMap()): ToolResult {
+ val allHeaders = headers.toMutableMap()
+ allHeaders["Content-Type"] = "application/json"
+ return post(url, json.toString(), allHeaders)
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/tools/OpenAppTool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/OpenAppTool.kt
new file mode 100644
index 0000000..bc44ff1
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/OpenAppTool.kt
@@ -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): 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}")
+ }
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/tools/SearchAppsTool.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/SearchAppsTool.kt
new file mode 100644
index 0000000..1ad854b
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/tools/SearchAppsTool.kt
@@ -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): 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
)
+ if (processedText.contains("") && !processedText.contains("")) {
+ processedText = processedText.replace("", "")
+ processedText = "$processedText"
+ }
+
+ // 提取 thinking
+ val thinkingRegex = Regex("(.*?)", RegexOption.DOT_MATCHES_ALL)
+ val thinking = thinkingRegex.find(processedText)?.groupValues?.get(1)?.trim() ?: ""
+
+ // 提取 tool_call
+ val toolCallRegex = Regex("\\s*(.+?)\\s*", 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 }
+ )
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/VLMClient.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/VLMClient.kt
new file mode 100644
index 0000000..406e879
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/VLMClient.kt
@@ -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> = 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()
+ 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 = emptyList()
+ ): Result = 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 = 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
+ )
+}
diff --git a/android-buyer/app/src/main/res/drawable-xxxhdpi/splash_icon.png b/android-buyer/app/src/main/res/drawable-xxxhdpi/splash_icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..1adfa36aa62f1ba4a38425b58989042ddda29ba9
GIT binary patch
literal 87836
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