- RokidLab 基础定制
- 蓝牙手柄开发指南
- 键盘输入功能开发指南
- 眼镜端游戏开发
- 商店应用提交指南
- AIUI Lab 工具桥开发指南(v3.5 新增)
- Agent 核心架构(v3.5 升级)
- 手机端六层架构(v3.5 重构)
RokidLab 使用 Crowdin 进行社区协作翻译。
翻译提交地址:
https://crwd.in/rokidlab/de7a762ecacaa13d21021e1b5f66b0132805017
- 中文(默认):
phone-app/src/main/res/values/strings.xml - 英语:
phone-app/src/main/res/values-en/strings.xml
- 在
LocalizationManager.kt的AppLocale枚举中添加新语言:
// phone-app/src/main/java/com/rokidlab/phone/util/LocalizationManager.kt
enum class AppLocale(val code: String, val displayName: String) {
ZH_CN("zh", "简体中文"),
EN("en", "English"),
// 添加新语言:
JA("ja", "日本語"),
}- 在
res/下创建对应 values 文件夹和strings.xml,如res/values-ja/strings.xml,以英文版为模板翻译。 - 在 Crowdin 项目中添加新语言,译者可直接在平台上翻译。
翻译缺口状态(2026-09-12):✅ 已清零。中文
values/strings.xml1100 条 = 英文values-en/strings.xml1100 条(此前缺 5 条:guide_ready_title、guide_reinstall_link_btn、guide_skip_btn、unknown_author、wifi_config_success,已补齐)。构建期强制:
phone-app的preBuild挂着checkI18nKeysSynced任务,校验 phone-app 与 RokidLink 两模块values/↔values-en/的<string name>集合完全相等(只比 key,不比顺序与文案)。新增/删除中文条目而不同步英文 → 构建直接失败并列出差异键名。
| 文件 | 用途 |
|---|---|
design/theme/BrewColors.kt |
配色接口,定义所有颜色字段 |
design/theme/VelvetDarkColors.kt |
默认暗色主题实现 |
design/theme/CoolBlueColors.kt |
浅蓝主题实现 |
design/theme/BrewThemeManager.kt |
主题管理器,切换主题 |
每个主题需实现 BrewColors 接口的全部字段:
interface BrewColors {
// 底色
val bg: Color // 主背景
val panel: Color // 暗灰板
val panelAlt: Color // 亮灰板
val panelHi: Color // 高亮面板
// 文字
val textBright: Color // 正文
val text: Color // 次要文字
val muted: Color // 辅助文字
val dim: Color // 禁用文字
// 边框
val border: Color
// 模块色(固定用途)
val store: Color // 商店
val chat: Color // 乐奇聊天
val mirror: Color // 屏幕镜像
val projection: Color // 手机投屏
val fileManager: Color // 文件管理
val adbTools: Color // ADB 工具
val hidGamepad: Color // HID 手柄
val settings: Color // 设置
}第一步:创建配色实现类
// design/theme/ForestGreenColors.kt
package com.rokidlab.phone.design.theme
import androidx.compose.ui.graphics.Color
object ForestGreenColors : BrewColors {
override val bg: Color = Color(0xFF0A1A0F)
override val panel: Color = Color(0xFF0F2214)
override val panelAlt: Color = Color(0xFF142C1A)
override val panelHi: Color = Color(0xFF1A3722)
override val textBright: Color = Color(0xFFE8F0E8)
override val text: Color = Color(0xFFC0D0C0)
override val muted: Color = Color(0xFF809880)
override val dim: Color = Color(0xFF405540)
override val border: Color = Color(0xFF2A4A30)
override val store: Color = Color(0xFFE8914A)
override val chat: Color = Color(0xFF6BE86B)
override val mirror: Color = Color(0xFF4AE8B5)
override val projection: Color = Color(0xFFB58AFF)
override val fileManager: Color = Color(0xFFFFB84D)
override val adbTools: Color = Color(0xFF5CB8FF)
override val hidGamepad: Color = Color(0xFFFF6B8A)
override val settings: Color = Color(0xFF8AB8D8)
}第二步:注册主题枚举
// BrewThemeManager.kt
enum class BrewTheme(val displayNameResId: Int, val descriptionResId: Int) {
VELVET_DARK(R.string.theme_velvet_dark, R.string.theme_velvet_dark_desc),
COOL_BLUE(R.string.theme_cool_blue, R.string.theme_cool_blue_desc),
FOREST_GREEN(R.string.theme_forest_green, R.string.theme_forest_green_desc),
}第三步:关联配色
// BrewThemeManager.kt
val currentColors: BrewColors
get() = when (_currentTheme) {
BrewTheme.VELVET_DARK -> VelvetDark
BrewTheme.COOL_BLUE -> CoolBlue
BrewTheme.FOREST_GREEN -> ForestGreenColors
}第四步:添加多语言字符串
<!-- values/strings.xml -->
<string name="theme_forest_green">森林绿</string>
<string name="theme_forest_green_desc">深绿自然主题</string>
<!-- values-en/strings.xml -->
<string name="theme_forest_green">Forest Green</string>
<string name="theme_forest_green_desc">Deep forest green theme</string>RokidLab 通过 Android BluetoothHidDevice API 将手机注册为蓝牙 HID 设备,眼镜作为 HID Host 接收输入。
手机(HID Device)──蓝牙──▶ 眼镜(HID Host)
手机端 HID 描述符包含 3 个 Report:
| Report ID | 类型 | 用途 | 眼镜端设备 |
|---|---|---|---|
| 2 | Consumer Control | ↑↓←→ / Select / Start | event2 Consumer Control |
| 1 | Keyboard | A/B/C/X/Y/Z/L/R | event3 Keyboard |
| 3 | Mouse | 鼠标模式 | event4 Mouse |
| 手柄按键 | HID 通道 | 发送数据 | 眼镜接收键码 | Keyboard 映射 |
|---|---|---|---|---|
| ↑ | Consumer | 0x42 0x00 |
KEYCODE_DPAD_UP (19) |
— |
| ↓ | Consumer | 0x43 0x00 |
KEYCODE_DPAD_DOWN (20) |
— |
| ← | Consumer | 0x44 0x00 |
KEYCODE_DPAD_LEFT (21) |
— |
| → | Consumer | 0x45 0x00 |
KEYCODE_DPAD_RIGHT (22) |
— |
| Select | Consumer | 0x41 0x00 |
KEYCODE_DPAD_CENTER (23) |
— |
| Start | Consumer | 0x41 0x00 |
KEYCODE_DPAD_CENTER (23) |
— |
| A | Keyboard | scancode 0x1D |
KEYCODE_Z (52) |
z |
| B | Keyboard | scancode 0x1B |
KEYCODE_X (47) |
x |
| C | Keyboard | scancode 0x06 |
KEYCODE_C (43) |
c |
| X | Keyboard | scancode 0x04 |
KEYCODE_A (29) |
a |
| Y | Keyboard | scancode 0x16 |
KEYCODE_S (47) |
s |
| Z | Keyboard | scancode 0x07 |
KEYCODE_D (32) |
d |
| L | Keyboard | scancode 0x14 |
KEYCODE_Q (45) |
q |
| R | Keyboard | scancode 0x1A |
KEYCODE_W (51) |
w |
// BluetoothHidManager.kt
const val KEY_A = 0
const val KEY_B = 1
const val KEY_C = 2
const val KEY_X = 3
const val KEY_Y = 4
const val KEY_Z = 5
const val KEY_L = 6
const val KEY_R = 7
const val KEY_SELECT = 8
const val KEY_START = 9
const val KEY_UP = 10
const val KEY_DOWN = 11
const val KEY_LEFT = 12
const val KEY_RIGHT = 13RokidLab 提供在鼠标模式下向眼镜焦点 App 输入中英文文字的功能。用户通过手机键盘打字,文字经三层方案依次尝试写入眼镜。
手机输入法 ──→ [键盘弹窗] ──→ TCP TextInputService ─→ 设置剪贴板 ─→ input keyevent KEYCODE_PASTE
│
├── 失败 → AdbPasteCompat (ADB 原始协议) ─→ shell input keyevent KEYCODE_PASTE
│
└── 失败 → BluetoothHidManager.sendCtrlV() (HID Ctrl+V)
眼镜端 — TextInputService.kt:
- 在端口 7656 启动 TCP Server 线程
- 接收手机发送的文字数据(UTF-8 编码的字符串,以换行符结尾)
- 使用 Java
StringBuilder阻塞读取BufferedReader.readLine() - 将接收到的文字设置到系统剪贴板:通过
context.getSystemService<ClipboardManager>().setPrimaryClip() - 尝试
Runtime.exec("input keyevent KEYCODE_PASTE")注入粘贴事件 - 回复 "OK\n" 到客户端,表示处理完成
- 循环等待下一条文字
注意:从普通 App 内执行
input keyevent缺少INJECT_EVENTS系统权限,此方案在部分系统上可能失败。
手机端 — GamepadActivity.kt:
- 触控板区域下方显示键盘按钮,点击弹出
AlertDialog - 对话框内包含
TextField(默认OutlinedTextField)用于文字输入 - 点击"发送"后顺序执行三层方案
- 自动从
LabApplication.phoneMirrorIp获取眼镜 IP
手机端 — AdbPasteCompat.kt:
- 通过 TCP Socket 直连眼镜 ADB 端口 5555,无需 adb.exe
- 实现 ADB 原始二进制协议(24 字节小端序报头 + 负载)
- 完成 RSA 认证握手(
CNXN/AUTH/SIGNATURE消息交换) - 打开
shell:service,执行input keyevent KEYCODE_PASTE - 读取服务端
OKAY+ 输出流确认命令已执行 - 连接完成后自动关闭 Socket
核心代码结构:
data class Packet(val cmd: String, val arg0: Int, val arg1: Int, val data: ByteArray)
// 连接 → 认证 → 打开 shell → 执行 input keyevent KEYCODE_PASTE → 关闭
fun execPaste(ip: String, port: Int = 5555): Boolean从 ADB shell 执行
input keyevent拥有INJECT_EVENTS系统权限,可以跨 App 注入粘贴事件。
手机端 — BluetoothHidManager.kt 的 sendCtrlV() 方法:
- 发送 8 字节标准 HID 键盘报告:
byteArrayOf(0x08, 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00)- 第一个字节
0x08= 左 Ctrl 修饰键 - 第三个字节
0x19= 按键码 'v'(对应的 Usage ID)
- 第一个字节
- 通过
BluetoothHidDevice.sendReport()发送到眼镜 - 松键报告:
byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
| 方向 | 格式 | 说明 |
|---|---|---|
| 手机 → 眼镜 | UTF-8 字符串 + \n |
要输入的文字内容 |
| 眼镜 → 手机 | OK\n |
处理完成(剪贴板已设置,粘贴已尝试) |
完整实现 ADB TCP 通信协议,包含以下消息交换:
| 阶段 | 客户端发送 | 服务端响应 | 说明 |
|---|---|---|---|
| 连接 | CNXN(版本+最大负载+系统标识) |
CNXN / AUTH |
协商版本 |
| 认证 | AUTH(TOKEN) 签名响应 |
OKAY / AUTH |
RSA 签名认证 |
| Shell | OPEN(local_id, "shell:input...") |
OKAY → WRTE(输出) |
执行粘贴命令 |
关键实现细节:
- RSA 密钥对使用
KeyPairGenerator生成,SHA1withRSA签名 - 公钥以
signature + styles格式发送(@adb后缀) - 24 字节报头:
[4B cmd][4B arg0][4B arg1][12B data_length](小端序)
| 文件 | 位置 | 说明 |
|---|---|---|
TextInputService.kt |
RokidLink/.../rokidlink/ |
眼镜端 TCP 文字接收服务 |
AdbPasteCompat.kt |
phone-app/.../adb/ |
手机端 ADB 原始协议粘贴工具 |
GamepadActivity.kt |
phone-app/.../hid/ |
手机端键盘弹窗 UI 和发送逻辑 |
BluetoothHidManager.kt |
phone-app/.../hid/ |
HID Ctrl+V 兜底方案 |
Rokid 眼镜的 Linux 内核 HID 驱动不支持标准 Gamepad Input Device。所有按键通过 Keyboard 和 Consumer Control 两个输入设备发送,眼镜端接收后转换为 Android 键码。
| 编程框架 | 监听方式 | 示例代码 |
|---|---|---|
| Unity | Input.GetKeyDown() |
Input.GetKeyDown(KeyCode.Z) |
| Android View | onKeyDown() |
KEYCODE_Z (52) |
| Android Compose | onKeyEvent |
KeyEvent.Key.Z |
class GameActivity : Activity() {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_Z -> { /* A 键按下 */; true }
KeyEvent.KEYCODE_X -> { /* B 键按下 */; true }
KeyEvent.KEYCODE_C -> { /* C 键按下 */; true }
KeyEvent.KEYCODE_A -> { /* X 键按下 */; true }
KeyEvent.KEYCODE_S -> { /* Y 键按下 */; true }
KeyEvent.KEYCODE_D -> { /* Z 键按下 */; true }
KeyEvent.KEYCODE_Q -> { /* L 键按下 */; true }
KeyEvent.KEYCODE_W -> { /* R 键按下 */; true }
KeyEvent.KEYCODE_DPAD_UP -> { /* ↑ */; true }
KeyEvent.KEYCODE_DPAD_DOWN -> { /* ↓ */; true }
KeyEvent.KEYCODE_DPAD_LEFT -> { /* ← */; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { /* → */; true }
KeyEvent.KEYCODE_DPAD_CENTER -> { /* Select / Start */; true }
else -> super.onKeyDown(keyCode, event)
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_Z -> { /* A 键释放 */; true }
// ...
else -> super.onKeyUp(keyCode, event)
}
}
}using UnityEngine;
public class RokidController : MonoBehaviour
{
void Update()
{
// 功能键
if (Input.GetKeyDown(KeyCode.Z)) Debug.Log("A pressed"); // KEYCODE_Z
if (Input.GetKeyDown(KeyCode.X)) Debug.Log("B pressed"); // KEYCODE_X
if (Input.GetKeyDown(KeyCode.C)) Debug.Log("C pressed"); // KEYCODE_C
if (Input.GetKeyDown(KeyCode.A)) Debug.Log("X pressed"); // KEYCODE_A
if (Input.GetKeyDown(KeyCode.S)) Debug.Log("Y pressed"); // KEYCODE_S
if (Input.GetKeyDown(KeyCode.D)) Debug.Log("Z pressed"); // KEYCODE_D
if (Input.GetKeyDown(KeyCode.Q)) Debug.Log("L pressed"); // KEYCODE_Q
if (Input.GetKeyDown(KeyCode.W)) Debug.Log("R pressed"); // KEYCODE_W
// 方向键
if (Input.GetKeyDown(KeyCode.UpArrow)) Debug.Log("Up");
if (Input.GetKeyDown(KeyCode.DownArrow)) Debug.Log("Down");
if (Input.GetKeyDown(KeyCode.LeftArrow)) Debug.Log("Left");
if (Input.GetKeyDown(KeyCode.RightArrow)) Debug.Log("Right");
// Select / Start 都映射到回车
if (Input.GetKeyDown(KeyCode.Return)) Debug.Log("Select or Start");
}
}- 非标准 Gamepad:Rokid 眼镜内核不支持
Usage Game Pad (0x05),请使用键盘键码。 - Select 和 Start 键码相同:两者都映射为
KEYCODE_DPAD_CENTER (23)。建议 Select 作"确定",Start 作"暂停"。 - 单键释放:松开按键即发送释放报告。
- 鼠标模式:RokidLab 提供"鼠标"标签页,使用 Mouse Report ID 3,非游戏场景适用。
# 查看所有输入设备
adb shell getevent -p
# 实时监控键盘事件
adb shell getevent -l /dev/input/event3
# 实时监控 Consumer Control 事件
adb shell getevent -l /dev/input/event2// RokidGameTemplate.kt — 可复用的 Rokid 手柄游戏 Activity 模板
import android.app.Activity
import android.os.Bundle
import android.view.KeyEvent
class RokidGameTemplate : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 设置你的游戏视图
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return handleRokidKey(keyCode, isDown = true)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return handleRokidKey(keyCode, isDown = false)
}
private fun handleRokidKey(keyCode: Int, isDown: Boolean): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> onAction("UP", isDown)
KeyEvent.KEYCODE_DPAD_DOWN -> onAction("DOWN", isDown)
KeyEvent.KEYCODE_DPAD_LEFT -> onAction("LEFT", isDown)
KeyEvent.KEYCODE_DPAD_RIGHT -> onAction("RIGHT", isDown)
KeyEvent.KEYCODE_Z -> onAction("A", isDown)
KeyEvent.KEYCODE_X -> onAction("B", isDown)
KeyEvent.KEYCODE_C -> onAction("C", isDown)
KeyEvent.KEYCODE_A -> onAction("X", isDown)
KeyEvent.KEYCODE_S -> onAction("Y", isDown)
KeyEvent.KEYCODE_D -> onAction("Z", isDown)
KeyEvent.KEYCODE_Q -> onAction("L", isDown)
KeyEvent.KEYCODE_W -> onAction("R", isDown)
KeyEvent.KEYCODE_DPAD_CENTER -> onAction("SELECT", isDown)
else -> return false
}
return true
}
private fun onAction(action: String, isDown: Boolean) {
if (isDown) {
// 按键按下逻辑
} else {
// 按键释放逻辑
}
}
}- 开发适配 Rokid 眼镜(RV101)的 Android 应用
- 目标 API:
minSdk=31(Android 12),targetSdk兼容即可 - 确保在 480×640 单绿色屏 下正常显示
- 打包为 Release APK
- 上传 APK 到 GitHub Releases 或 Gitee Releases
- 获取 APK 直链(
releases/download/格式)- GitHub:
https://github.com/{user}/{repo}/releases/download/{tag}/{file}.apk - Gitee:
https://gitee.com/{user}/{repo}/releases/download/{tag}/{file}.apk
- GitHub:
- 按下方字段说明编写应用 JSON,连同 APK 一起提交给商店维护者
{
"schemaVersion": 1,
"generatedAt": "2026-06-22T00:00:00.000Z",
"apps": [
{
"id": "example-my-app",
"name": "示例应用",
"type": "glasses",
"category": "Utility",
"version": "1.0.0",
"summary": "一句话简介",
"description": "详细描述,2-3句话说明应用功能",
"author": "你的昵称",
"sourceUrl": "https://github.com/yourname/your-repo",
"phoneRequired": false,
"featured": false,
"featuredRank": null,
"publishedAt": "2026-06-01T00:00:00.000Z",
"newUntil": "2026-08-01T00:00:00.000Z",
"iconUrl": "https://raw.githubusercontent.com/yourname/your-repo/main/icon.png",
"screenshotUrls": [
"https://raw.githubusercontent.com/yourname/your-repo/main/screenshot1.png",
"https://raw.githubusercontent.com/yourname/your-repo/main/screenshot2.png"
],
"listing": {
"about": "Markdown 格式的详细介绍,支持换行\n\n### 功能列表\n- 功能一\n- 功能二"
},
"releases": [
{
"version": "1.0.0",
"date": "2026-06-01T00:00:00.000Z",
"sourceReleaseUrl": "https://github.com/yourname/your-repo/releases/tag/v1.0.0",
"notes": "版本更新说明",
"changes": [
"新增功能一",
"优化功能二",
"修复问题"
]
}
],
"artifacts": [
{
"target": "glasses",
"url": "https://github.com/yourname/your-repo/releases/download/v1.0.0/app-release.apk",
"sha256": "apk的sha256校验值,可选",
"sizeBytes": 1048576,
"packageName": "com.example.app",
"versionCode": 1
}
]
}
]
}| 字段 | 必填 | 说明 |
|---|---|---|
schemaVersion |
是 | 固定为 1 |
generatedAt |
否 | 生成时间,ISO 8601 |
brewVersion |
否 | RokidLab 自身版本号(第三方勿改) |
brewVersionCode |
否 | RokidLab 自身 versionCode(第三方勿改) |
brewApkUrl |
否 | RokidLab 自身 APK 下载地址(第三方勿改) |
brewReleaseUrl |
否 | RokidLab 自身 Release 页面(第三方勿改) |
brewNotes |
否 | RokidLab 自身更新说明(第三方勿改) |
brewChanges |
否 | RokidLab 自身更新列表(第三方勿改) |
apps |
是 | 应用列表(数组) |
| 字段 | 必填 | 说明 | 示例 |
|---|---|---|---|
id |
是 | 唯一标识,建议 作者-应用名 |
dlover1314-rokid-xiaozhi |
name |
是 | 应用展示名称 | Rokid 小助手 |
type |
是 | 固定为 glasses |
glasses |
category |
是 | 分类(见下方) | Utility |
version |
是 | 版本号(字符串) | 1.0.0 |
summary |
是 | 一句话简介(12 字以内) | Rokid 眼镜语音助手 |
author |
是 | 作者名称 | dlover1314 |
sourceUrl |
否 | 源码仓库地址 | https://github.com/... |
phoneRequired |
否 | 是否需要手机端配合 | false |
featured |
否 | 是否置顶展示 | false |
featuredRank |
否 | 置顶排序(越小越靠前) | 1 |
publishedAt |
否 | 发布时间(ISO 8601) | 2026-06-01T00:00:00.000Z |
newUntil |
否 | 此时间前显示"NEW"标记 | 2026-08-01T00:00:00.000Z |
iconUrl |
否 | 应用图标 URL(512×512 PNG) | https://.../icon.png |
screenshotUrls |
否 | 截图 URL 列表(2-3 张) | ["https://..."] |
description |
否 | 详细描述 | 这是一款...的应用 |
listing |
否 | Markdown 详细介绍 | 见下方 |
releases |
否 | 版本更新日志 | 见下方 |
artifacts |
是 | APK 下载配置 | 见下方 |
| 值 | 说明 |
|---|---|
Utility |
工具 |
Game |
游戏 |
Media |
影音 |
Social |
社交 |
Navigation |
导航 |
Health |
健康 |
Education |
教育 |
Productivity |
效率 |
Entertainment |
娱乐 |
Other |
其他 |
{
"target": "glasses",
"url": "https://.../app.apk",
"sha256": "可选,SHA256 校验值",
"sizeBytes": 1048576,
"packageName": "com.example.app",
"versionCode": 1
}
url必须是 APK 直链,不能是 Release 页面地址。
{
"about": "Markdown 格式的详细介绍"
}每次更新版本时追加新记录,不修改旧记录。
{
"version": "1.0.0",
"date": "2026-06-01T00:00:00.000Z",
"sourceReleaseUrl": "https://github.com/.../releases/tag/v1.0.0",
"notes": "版本更新说明",
"changes": ["功能一", "功能二"]
}修改对应应用的以下字段:
version— 新版本号releases— 追加新 release 记录artifacts[0].url— 新 APK 下载地址artifacts[0].sizeBytes— 新 APK 文件大小artifacts[0].versionCode— 新 versionCodeartifacts[0].sha256— 新 SHA256(可选)
- 远程注册表的
apps数组只需维护你的应用,不需要包含全部应用 - 远程应用
id与本地内置应用相同时,远程数据覆盖本地数据 - 手机端 RokidLab 切换商店源或下拉刷新时自动获取最新注册表
- 应用图标和截图建议托管在你自己的代码仓库中,用 raw URL 引用
AIUI 页面(.ink 智能体)运行在眼镜端 @yodaos-pkg/ink 的 quickjs-wasm 沙箱里,与 WebView 主 realm 隔离。原本只能渲染不能调用外部能力。v3.5 引入 Lab 工具桥,让页面可以调用手机端工具(ToolRegistry 全量 34 个中除自指递归的 open_aiui_app 外全部开放,即 33 个,涵盖音乐 / 天气 / 搜索 / 提醒 / 设备信息 / 电话 / 日历 等)。
页面 (ink 沙箱 realm)
│ globalThis.Lab.callTool(name, args)
▼
fetch('https://ink.local/__lab/tool_call_sync?name=...&args=...&cbId=...')
│ (同源 https://ink.local,被 WebView shouldInterceptRequest 拦截)
▼
AiuiLinkActivity.shouldInterceptRequest (后台线程)
│ ArrayBlockingQueue<String>(1) 同步阻塞 poll(15s)
│ invokeToolCall() 调 AsrPushServer.pushControl
▼
AsrPushServer.pushControl("__LAB_TOOL__" + JSON)
│ (复用现有 ASR 推送 RFCOMM 通道,不新开通道
│ —— 眼镜端同一时刻只允许一条 RFCOMM,adb 隧道已占满)
▼
手机端 AsrPushClient.onText
▼
AsrBridgeCoordinator(识别 __LAB_TOOL__ 前缀 → 后台线程分流)
▼
CxrLHiRokidSession.handleAiuiToolCall(payload)
▼
ToolGateway.call(context, name, arguments)
│ - 检查 isEnabled / DENY_TOOLS / ALLOWED_DOMAINS
│ - 新建 worker 线程跑 ToolRegistry.execute (15s 超时)
│ - 结果截断到 8000 字符
▼
结果按原路回传:CMD_AIUI_MSG → KeyButtonService.dispatchMessageToActive
→ AiuiLinkActivity.dispatchHostMessage → hostMessage
→ 同时 cacheToolResult 写入轮询缓存(页面 realm 桥可 fetch 拉取)
▼
同步队列被 offer 唤醒 → fetch 同步返回 JSON → 页面 Promise resolve
关键文件:
| 文件 | 角色 |
|---|---|
RokidLink/src/main/assets/ink/host.js |
主 realm 桥:window.Lab.callTool / listTools / onToolResult(适配可访问 window.Android 的页面) |
RokidLink/src/main/assets/ink/lab-page-bridge.js |
页面 realm 桥(关键):在 AiuiLinkActivity.buildBundleJson 装配 bundle 时前置注入到 app.js,让 ink 沙箱页面也能用 globalThis.Lab.callTool |
RokidLink/src/main/java/.../AiuiLinkActivity.kt |
眼镜端宿主:@JavascriptInterface callTool + __lab/* 端点拦截 + 同步工具队列 + 启动参数下发 |
RokidLink/src/main/java/.../AsrPushServer.kt |
CTRL_TOOL_CALL = "__LAB_TOOL__" 工具调用前缀常量 |
phone-app/src/main/java/.../ai/ToolGateway.kt |
手机端统一工具网关,单一入口调度 ToolRegistry.execute(执行前先过 ApprovalGate.preExecute(AIUI_PAGE, …)) |
phone-app/src/main/java/.../ai/approval/ApprovalGate.kt |
工具调用唯一审批入口:合成 guard → 解析 Ask → 审计(详见 §8.2)。原 ToolPolicy.kt 已并入此类 |
phone-app/src/main/java/.../ai/approval/ToolGuards.kt |
5 个内置策略源:页面准入 / 未知名 / per-source 限流 / 本机模式眼镜依赖 / 风险确认 |
phone-app/src/main/java/.../glasses/AsrBridgeCoordinator.kt |
__LAB_TOOL__ 前缀分流到 onToolCall 回调 |
phone-app/src/main/java/.../glasses/CxrLHiRokidSession.kt |
handleAiuiToolCall(payload) 委派 ToolGateway,结果经 sendAiuiHostMessage 下发 |
// 必须写 globalThis.Lab 或 window.Lab —— ink 沙箱页面 realm 不走 globalThis 解析裸标识符,
// 直接写 Lab.callTool 会 ReferenceError。
const text = await globalThis.Lab.callTool('play_song', { songName: '西厢' });四条硬性要求(生成方模型必须遵守,见 lab-runtime.md 第 8 章):
- 必须 try/catch:官方渲染环境(
Sys_AIUI_Start/AgentStore)没有 JS bridge,callTool会直接 reject。页面必须能降级,不能白屏或卡死 - 必须有 loading 态:一次调用含蓝牙往返 + 可能的网络请求,通常 1~3 秒,最长 20 秒超时。调用前
setData置 loading,成功和失败都要清除 - 结果是字符串:要什么格式自己解析;超长结果不要整段铺满屏幕
- 一次只做一件事:蓝牙通道是串行的,不要并发发起多个
callTool
用户说「用 AIUI 播放西厢」时,模型调用 open_aiui_app 工具的 params 字段携带 {"songName":"西厢"}:
ToolRegistry.open_aiui_app调parseLaunchParams(args.optString("params"))解析(非法 JSON 直接丢弃,绝不把脏串下发到页面)AiuiFrontendController.pushAixToRokidLinkHost(aixFile, launchParams=...)→openAiuiHost(name, launchParams)→sendAiuiHostCmd("open", name, launchParams)- 眼镜端
KeyButtonService收到CMD_AIUI_OPEN,从caps[2]取启动参数,调AiuiLinkActivity.open(context, aixPath, launchParams) AiuiLinkActivity.onCreate读EXTRA_LAUNCH_PARAMS,在bootJs时作为config.launchParams带入boot(),页面 boot 完成后deliverLaunchParams()调hostMessage({type:"launch", params})下发- 页面在
onMessage里接收(不能在onLoad里拿,启动参数只在页面渲染完成后投递一次):
export default {
data: { song: '', loading: false },
onMessage(e) {
// ⚠️ Lab 自托管 ink 宿主把真实 payload 放在 e.data(JSON 字符串),
// 不是直接放在 e.type/e.params。必须 JSON.parse(e.data)。
const msg = (typeof e.data === 'string') ? JSON.parse(e.data) : (e.data || e);
if (msg.type !== 'launch') return;
const p = msg.params || {};
if (p.songName) this.play(p.songName);
},
}同一包再次打开换参数(如「再用 AIUI 放一首别的」):AiuiLinkActivity.onNewIntent 检测到 p == currentAixPath 时不重启宿主,直接更新 launchParamsJson 字段,若已 boot 则 deliverLaunchParams() 下发新参数,避免打断页面当前播放/动画。
ToolGateway.kt 是 AIUI 页面调用手机端工具的唯一入口,改代码前请先读以下约束:
- 域白名单按 DOMAIN 继承而非逐工具配置 — 后期加工具只改
ToolRegistry一行注册,网关 / 协议 / JS / skill 全不动。逐工具配会破坏这个性质。 - 域白名单基线是全开放(用户 2026-09-09 拍板),当前唯一例外是
shell域 (2026-09-22 摘除;2026-09-23 追加:该域现有run_shell与install_packages两个能力原语, 加上脚本库四件套save_script/list_scripts/run_script/delete_script—— 后者等价于"把任意命令存下来再跑",同样是侧门,一并摘除)。 保留ALLOWED_DOMAINS就是为了收窄时只改一处。 DENY_TOOLS只放会造成技术故障的工具(自指递归),不放「危险」工具 — 安全边界由isEnabled总开关负责,不做逐工具安全判断;按「危险」收窄要落域白名单(见第 2 条),不加进这张表。- 本类是同步阻塞 API,调用方必须在非主线程调用。
AsrBridgeCoordinator已切到后台线程。 - 结果截断 8000 字符:RFCOMM 单帧上限 64KB,且页面也渲染不下超长文本。
- 15s 超时后不 interrupt:工具可能持有文件/网络资源,强中断会留下半写状态,让线程自己跑完(daemon 线程不阻塞进程退出)。
- 执行前先过
ApprovalGate(ai/approval/,v3.5 加入时叫ToolPolicy,现已删除):以SOURCE_AIUI_PAGE来源做 30/min 限流 + 风险闸门 + 审计。「问不到用户时放行还是拒绝」由工具自声明(ToolEntry.confirmPolicy,见 §8.2):BLOCK的(未信任的 MCP 工具、send_sms)问不到就Deny并给可操作出路;PROCEED的(本机可重做,如delete_file)问不到才降级放行 —— 只有用户显式取消在所有分支上都Deny。isEnabled总开关仍是页面侧唯一的一键断能兜底。 ⚠️ 本机执行(proot 容器)不受上面这条 URL 闸门约束:容器里的curl/apt以 App uid 直接开 socket,既不经过ai/NetGuard也不经过network_security_config(两者都只作用于 Java 层)。容器出网的内网/回环可达性是已知事实,不要用LD_PRELOAD/hosts做半吊子拦截 —— 边界只能落在工具声明上。完整三条边界(出网 / MediaStore 登记 / rootfs 独占)见RULES.md§12.21,执行环境本体与按需组件(Python / Node.js / Git)见 §8.7。
- WebView 未开启远程调试:页面 / 宿主的
console.log在 logcat 里看不到,全部走window.Android.log(msg)→AiuiLinkActivity.bridge.log()→ logcat(tag=AiuiLink) host.js主 realm vs 页面 realm:ink 沙箱页面 realm 看不到主 realm 的window.Lab/window.Android(实测typeof=undefined),必须靠lab-page-bridge.js注入到app.js前置- 同步 vs 异步:
__lab/tool_call_sync同步阻塞 fetch 拿结果(页面侧无需轮询),__lab/tool_call异步 fetch 返回 202 + 后续onMessage回传(兼容旧桥,新代码不用) toolResult双路径下发:手机端执行完既走CMD_AIUI_MSG→dispatchHostMessage,又写入toolResults缓存(页面 realm 桥可fetch('/__lab/tool_result?cbId=...')轮询拉取,30s TTL)
CxrLHiRokidSession 曾是最大的 god class(v3.4 前 3550 行,v3.5 首轮拆分后 2780 行)。v3.5 六层重构(Phase 3~5)把它继续拆成 L3 domain/ 领域服务 + L2 协调器 两组,现为 887 行的薄路由层:
| 新类 | 职责 |
|---|---|
domain/ConnectionService |
连接编排(connectAnd* 系列) |
domain/AuthorizationService |
SDK 内部权限补齐 |
domain/DeviceControlService |
设备控制域 |
domain/AiConfigService / domain/AiConversationService |
AI 配置 / AI 对话(含 AI 下行主链路) |
domain/AiuiHostService |
AIUI 宿主域 |
domain/MirrorCoordinator |
投屏/长连接通道协调 |
domain/FileTransferService |
文件传输 |
domain/PhotoQuizService |
拍照问答(编排 PhotoQuizFlow) |
glasses/AsrBridgeCoordinator |
双通道 ASR(SDK + link)去重、控制标记、下行 ping |
glasses/AiuiFrontendController |
整个 AIUI 微前端 pipeline(push .aix、open/close/msg、launchParams) |
glasses/PhotoQuizFlow |
拍照 → OCR → RAG → AI 答案 |
CxrLHiRokidSession 保留每一个 public method 作为委派 facade(如 photoQuizService / aiConfig / connection / deviceControl / aiConversation / aiuiHost 等字段),调用方完全不动。同时把持有的 Activity 改为 app context,需要 lifecycle owner 的地方改用 WeakReference(leak fix)。
ASR 文字有三个入口:RFCOMM 推送主通道 / ADB 文件轮询兜底 / CXR 全局指令监听。AsrBridgeCoordinator 是去重核心:
v3.4 之前的去重逻辑:3 秒内相同文字只处理一次。
v3.5 新逻辑:仅当「文字与上次相同 且 上一条仍在处理中(asrHandling=true 且未超 90s 兜底)」才丢弃。
- 用户连说两次「停止播放」应当执行两次,不再被当成重复指令吞掉
- 真正的「上一条还没处理完」判定交由
asrHandling标志(由CxrLHiRokidSession.dispatchGlassesAsrText在处理开始 / 结束时调markAsrHandling(true/false)) - 90s 兜底超时防止异常路径下标志卡死,导致该指令被永久吞掉
推送断连→恢复补读:
pushWasDown=true ── 推送断连 ──> 文件轮询兜底(积压文字写入文件)
│
▼ 推送恢复,AsrPushClient.onConnected 回调
│ catchUpRequested = true
▼ 下一轮 poll 立即跳出退避,补读积压文件
│ readAiAsrBridgeTextOnce(lastTs)
▼ catchUpRequested = false
之前推送恢复就放心不再读文件,断连期间积压的文字永久丢失(眼镜已显示提问却等不到回复)。
start() 防抖:连接建立期 onConnected 回调与显式调用可能连着两次进入,1.5 秒内重入直接忽略 — 避免重复创建 AsrPushClient 抢同一条 RFCOMM 通道把通道搞断。
AsrPushClient socket 时序修复:必须在 connect() 成功之后才置 socket。旧实现在 connect() 前赋值,导致 isConnected 在建链握手期间/建链失败后短暂为 true — 上层据此认为「主通道健康」而跳过 ADB 文件兜底轮询,建链失败期间产生的 ASR 文字便永久丢失。
多轮会话记忆(AgentSessionManager):
- 跨请求多轮历史,按对话独立保存(
agent_sessions/<sessionId>.jsonl事件流),重启不丢 - v3.5:超长上下文被裁剪时,前缀折叠为 ≤800 字的 pinned system message
[summary of earlier conversation],跨数小时长会话不再失忆 - 上限随模型上下文窗口浮动(默认 12 条 / 6000 字符,大窗口按份额放宽并受天花板约束),超出时把最旧一轮压进置顶滚动摘要
- 开关是纯开关:关 = 不注入也不记录,但不销毁已积累的数据,重新开启后仍然生效
- 没有"空闲自动过期":原先的「10 分钟无活动自动清空」(
maybeExpire)已于 2026-09-20 移除 —— 它与「聊天记录永久保存」的用户心智正面相抵,而且是静默发生的 - 清空分两级作用域,别混:聊天页「清空对话」= 当前对话的记录+记忆(
clear());设置页「清空全部会话记忆」= 全部对话的记忆(clearAll(),一个字节都不碰聊天记录)
长期记忆(LongTermMemoryManager):
- 跨会话记住用户称呼与偏好,每次对话注入 system prompt
- v3.5:存储从 SharedPreferences 改为 SQLite
- FIFO 上限 200 条
- 90 天过期
- 首次启动自动迁移旧 SharedPreferences 数据
- 注入策略从「全量」改为「检索」:
- 中文 bigram 重叠度评分
- 注入 top 12 而非全量
- 长记忆库增长后不再撑爆 system prompt
知识库(KnowledgeBase):
- v3.5:BM25-style IDF 评分;命中条目带 provenance(
docName/chunkIdx),AI 回答可引用来源 - 导入必须经
TextEncoding判定编码(2026-09-20 修)。中文用户的 txt 常是ANSI(GBK)/Unicode(UTF-16), 按 UTF-8 硬解会整篇变成 U+FFFD,而文件名与字节数照旧正确 ⇒ 症状是"导入看着成功、里面的内容永远问不出来"。 判定顺序:BOM → 无 BOM 的 UTF-16(NUL 密度 + 奇偶位)→ 严格 UTF-8 试解 →GB18030兜底。⚠️ 严格 UTF-8 试解必须容忍样本尾部被切断的多字节字符,否则会把正常 UTF-8 文件误判成 GB(反向破坏)。⚠️ docs表 v2 新增的charset列走ALTER TABLE——onUpgrade绝不 drop 重建(那等于"升级即清空用户资料")。 - 双通道检索:对话主循环自动检索 top-2 注入 system 提示词;未命中时也要把"库里有哪些文档"告诉模型
(否则模型连知识库存在都不知道,更不会去调
search_knowledge_base);search_knowledge_base空结果会返回 文档名清单让模型换关键词重试。检索 token 上限 32(每次 token 一次全表 LIKE),评分与 LIKE 同为大小写不敏感。 - 管理弹窗(
KbManageDialog)显示块数 + 编码,点开预览正文 —— 让"导入到底进去了什么"可见: 仅看文件名/大小是看不出乱码的,这正是该 bug 能潜伏的原因。 - 同一套解码也用在
FileWorkspace.readTextFile(用户文件同样是 GBK 的高发区)。
SSE 重连(OpenAiService):
- v3.5:重连条件从「已开始」收紧为「已发出内容」
- 无内容时重放是安全的,工具增量可重发
- 已发出内容后重连会导致重复,所以不重连
- 指数退避 500ms × 2^attempt,上限 4s
- 远程重试从 2 次升到 3 次
L5 app/ · feature/ · store/(UI) UI 与入口 + 手动 DI 容器 AppContainer
L4 ai/ Agent:ToolRegistry + tools/ Provider + approval/ + llm/ + compaction/ + ToolRisk
L3 domain/ 领域服务(Connection / Authorization / DeviceControl /
AiConfig / AiConversation / AiuiHost / MirrorCoordinator /
FileTransfer / PhotoQuiz)
L2 glasses/ 会话层:状态与协议(CxrLHiRokidSession / LinkProtocol /
GlassesHandshake / AsrBridgeCoordinator /
AiuiFrontendController / PhotoQuizFlow)
L1 connection/ 通道与仲裁(ChannelArbiter / ConnectionRouteManager)
L0 platform/ 能力与 hook 适配(CapabilityProbe / SdkBridge / SdkFieldMap /
HidBridge / AdbTransport / ShellOps / RomAdapter / AvrcpLyricBridge)
约束:每一层只依赖下一层。UI(L5)不 import 传输层(L1),更不 import 反射(L0);platform/ 是全仓唯一允许出现 getDeclaredField / Class.forName / setAccessible 的包,SDK / ROM 升级只改这一个包。跨 feature 共享的长生命周期对象统一由 app/AppContainer(手动 DI,不引 Hilt)装配,feature 层禁止自建。
包名以代码为准:会话层落在
glasses/、通道层落在connection/,分别对应架构设计稿里的session/与transport/。
注册(ToolRegistry):全量 52 个工具 / 13 个域(info / knowledge / glasses / timer / media / display / web / files / aiui / phone / research / vision / shell;另有 mcp 域承载运行期动态工具,见 §8.2.1)。域集合决定会话装配:
| 会话 | 装配域 | 说明 |
|---|---|---|
| 主 Agent(在线模型) | SESSION_AGENT_DOMAINS = 全部域 |
眼镜语音 / 手机聊天 |
| 本地模型 | SESSION_LOCAL_DOMAINS = 空集 |
本地小模型背不动数十个 schema |
| AIUI / 代码生成 | SESSION_AIUI_DOMAINS = aiui + files + info |
命中技能后切到子集省 token |
执行(ai/tools/):ToolRegistry.execute 解析参数后按 toolNames 路由到 15 个静态 ToolProvider 之一(Info / Knowledge / Glasses / Timer / Media / Display / Web / Files / Shell / Aiui / Phone / Status / Subagent / Vision / Motion;接口 ToolProvider.kt + schema ToolSchemas.kt),外加 1 个动态 provider(McpToolProvider,工具集运行期才知道,见 §8.2.1)。
新增工具 = 在对应 Provider 的 tools() 里加一条 ToolEntry + 在 execute 加一个分支(风险档/副作用/要眼镜/过程文案都在那条声明里,六张表由它派生);网关 / 协议 / JS / skill 全不动。
OpenAiService 系统提示词、其他 schema 的交叉引用、assets/skills/aiui-dev/lab-runtime.md。漏了这些不会编译错,只会让模型不知道该在什么时候用这个工具。
skills/rokidlab-chat-standalone-mode/scripts/check_tool_wiring.py:它守住「每个 provider 的 toolNames ↔ tools() 双向相等」「声明必带 risk/schema/statusText」「字符串资源存在」等自洽性。工具名支持三种写法(字面量 / 接收者.常量 / 同 object 内裸常量名)。
2026-09-20 补齐的三块对话能力(此前是缺口,不是配置问题):
look_at_view(vision域):把眼镜相机暴露成模型可主动调用的工具 —— 此前模型对「看看面前有什么」无工具可调,只能编出「没有相机权限」。出图两条路径:确认模型支持看图 ⇒ base64 暂存ThreadLocal,由对话主循环补一条带image_url的 user 消息交给主模型;否则兜底本地 OCR 转文字。⚠️ 不可在工具内回调sendAiTextMessage(aiSendLock非重入 ⇒ 死锁)。- 文件工作区(
ai/FileWorkspace.kt,新增 6 个文件工具):list_files/read_text_file/search_files/edit_text_file/delete_file/move_file。两个边界 ——project(filesDir/aiui_projects/<项目>/私有镜像,全能力)与downloads(MediaStore,只列/读/删)。MANAGE_EXTERNAL_STORAGE只在 proot 容器通道上使用(Download/Lab↔/mnt/lab内核直连),文件工具不因此承诺"读任意文件",边界必须写进各工具 schema。 - 技能管理三件套(伪工具):
install_skill/list_skills/delete_skill,可在对话里装/看/删技能(此前只能进设置页)。执行体复用既有installFromMarkdown/installFromZip/listSkills/delete。
外部 MCP(动态工具集,v3.9 新增):ai/mcp/(McpClient 传输 / McpServerStore 落盘 / McpRegistry 注册与分发)+ ai/tools/McpToolProvider;设置页在「乐奇聊天 → 设置 → 外部 MCP」。
连接链路:McpClient.initialize(读 Mcp-Session-Id)→ tools/list(分页)→ 每个工具过 ToolSchemaValidator,不合规的整个丢弃(服务端 schema 校验是整请求级,1 个坏节点会让整轮 400、全部工具失效)。
McpToolProvider 不写进 ToolRegistry.providers 静态列表(那是 object 初始化时求值的编译期常量,装不下运行期数据),而是由 McpRegistry.rebuildIndex() 调 ToolRegistry.setDynamicProviders() 注入 —— 漏调的表现是「设置页看得见新工具、模型却永远不调」且不报错。
network_security_config 禁明文;唯一例外=回环 http,见下)、工具首次出现时显式写 false、信任标记由用户显式给出;②闸门 未信任的第三方工具派生 confirmPolicy = BLOCK(2026-09-23 起)⇒ 问不到用户即拒绝并给出出路,不再是从前那样静默放行(见 §8.2)。单 server 上限 30 个工具(每个 schema 都进每一轮请求)。
⚠️ 回环例外不要照抄成「按BuildConfig.DEBUG放开 http」:debug 变体的network_security_config被src/debug/整体覆盖成cleartextTrafficPermitted="true",那样写就等于「调试能跑、发布才挂」。 判据是两份变体的白名单交集:回环(localhost/127.0.0.1/::1)两边都有 ⇒ 行为一致,故只放它。 判定只有一处 ——McpRegistry.isUrlAllowed(),UI 必须复用它而不是自己写startsWith。
风险分级(ToolRisk / ToolRiskMap):
| 档位 | 含义 | 例子 |
|---|---|---|
READ_ONLY |
纯读取 | get_weather / search_knowledge_base |
LOCAL_SIDE_EFFECT |
本机可控/可撤销副作用 | set_timer / set_phone_volume / call_phone |
EXTERNAL_SIDE_EFFECT |
不可撤销,必须过确认闸门 | delete_file / delete_script / install_packages / send_sms |
未登记兜底:真实工具的漏登记在结构上已不可能 —— 风险档是每个工具自己声明结构体(ToolEntry.risk)里的必填字段,跟工具定义写在一起;完全未知的名字(模型幻觉/攻击构造)→ EXTERNAL_SIDE_EFFECT(最保守,连带 confirmPolicy = BLOCK),但它会先被 UnknownToolGuard 单调拒绝,不会触发一次注定超时的确认。
策略闸门(ApprovalGate.preExecute,唯一审批入口):每次工具执行(AIUI 页面路径 / 对话路径 / 伪工具)都先过它:
- 页面准入(仅 AIUI 页面):域白名单 + 黑名单
- 未知名:既不是真实工具也不是伪工具 → 单调拒绝(旧实现会让它走风险兜底、白等一次确认)
- per-source 滑动窗口限流:AIUI 页面 30/min(防页面死循环刷工具)、对话路径 120/min(兜底失控循环)—— 被拒的调用不扣配额
- 本机模式(仅对话路径):用户开了「本机模式」时,需要眼镜的工具单调拒绝
- 风险闸门:
EXTERNAL_SIDE_EFFECT必须经用户确认(内置工具当前是四个:delete_file/delete_script/install_packages/send_sms;外部未信任的 MCP 工具默认此档)。问不到用户时放行还是拒绝,由该工具自己声明的confirmPolicy决定(见下) - 审计:每次决策打一行日志(ALLOW/DENY +
[ORIGIN]+ 原因),系统日志面板(LogCollector)可观测;TAG 为ApprovalGate
合成规则(ApprovalGate.compose):任一 guard 返回 Deny 立即短路(单调最终拒绝,对齐 DSH 的 ctx.tools.guard());否则取第一个 Ask;都不表态则 Allow。
确认通道有两条,取值=眼镜优先、手机兜底(ApprovalGate.activeChannel()):
GlassToolConfirmChannel(ApprovalGate.confirmationResolver):下行LinkProtocol.TOPIC_TOOL_CONFIRM(caps = [requestId, 工具名, 摘要]),眼镜端显示摘要 + TTS 播报,短按 = 允许 / 双击·长按 = 取消,眼镜端 30s 超时视为取消;上行LinkProtocol.TOPIC_TOOL_CONFIRM_RESULT(caps = [requestId, "yes"/"no"])。通道常驻(GlassToolConfirmChannel.global),会话上线bind、下线unbind。PhoneToolConfirmChannel(ApprovalGate.phoneConfirmationResolver,LabApplication.onCreate自注册):透明ToolConfirmActivity弹 AlertDialog,补的是「本机模式 = 不连眼镜」与「眼镜通道要求在线」在定义上互斥这个洞。手机通道不替换眼镜通道 —— 眼镜在线时行为与改造前完全一致。 两条通道都实现ConfirmResolver(channelId分别是"glass"/"phone",用于日志归因)。摘要由闸门随confirm(prompt)传入,产地是工具自己的ToolEntry.summarize。⚠️ 通道不可用时必须如实返回 false,绝不假装问过:confirm()返回 true 的含义是"用户同意了",未注入 Context 就返回 true 会让需点头的动作被静默执行而用户什么都没看到。
⚠️ 降级语义(2026-09-23 重构,务必如实理解):确认通道不可用(会话不在线 / 眼镜端旧版不支持 / 手机通道未init)或确认超时未响应(手机端等待上限:眼镜通道 35s、手机通道 40s)时,ApprovalGate.resolveAsk按该工具自己声明的confirmPolicy分流:
BLOCK(越出本机边界、不可撤销:send_sms、未信任的 MCP 工具)⇒ 返回Deny并给出可操作出路(ApprovalGate.denyUnconfirmed,按ToolRegistry.isMcpTool的名字前缀分流 MCP / 内置两套文案);PROCEED(影响不出本机、可重做:delete_file/delete_script/install_packages)⇒ 返回Allow(降级放行,硬拒会让功能表现为"被安全策略挡住")。
confirmPolicy缺省按风险档派生(EXTERNAL_SIDE_EFFECT⇒BLOCK,其余 ⇒PROCEED),只有PROCEED/BLOCK两个值。这条语义的唯一产地是ApprovalGate.resolveAsk的 KDoc 与其实现。⚠️ 只有用户显式取消(wasCancelled()为 true)在两条分支上都Deny—— 那是唯一真正的"用户说不"。通道异常 / 超时不等于取消(denyUnconfirmed会说明"没问到用户",而不是"用户拒绝了")。⚠️ 未知名(模型幻觉)的兜底也是BLOCK("未知即最保守"),但它会先被UnknownToolGuard单调拒绝,不会真的拖用户进一次注定超时的确认。⚠️ 显式给 EXTERNAL 档工具写PROCEED是一个"越界也照做"的洞,check_tool_wiring.py会把它报成错误;确属有意(本机可重做)必须登记进脚本的EXTERNAL_FAIL_OPEN_ALLOWLIST并写明理由。⚠️ 外部 MCP 工具:闸门现在真的会拦(2026-09-23 起)——McpRegistry.toEntry把未信任的第三方工具登记为EXTERNAL_SIDE_EFFECT⇒ 派生BLOCK⇒ 无通道时拒绝;用户显式标了「信任」才降为LOCAL_SIDE_EFFECT(此时不产生 Ask)。改造前这里写的是"闸门 fail-open ⇒ 无通道照样放行",那句话已经失效。但准入仍是第一道防线(https-only + 默认关 + schema 全过校验),详见 §8.2 的「外部 MCP」段。
手机侧蓝牙栈对「同一设备 + 同一 SCN」只允许一条客户端 RFCOMM 通道。此前由各消费方手工 reserveTunnel() / releaseTunnel()(一个 AtomicInteger 计数)保证,分散在多个 Activity/Service + 兜底轮询里,漏调用 release 即永久泄漏且无从观测。connection/ChannelArbiter 把它收敛为「按优先级持有租约」:
| 优先级 | 用途 | 让路接入点 |
|---|---|---|
BACKGROUND |
ASR 兜底轮询(断连期间积压文件补读):更高优先级占用时应让路 | CxrLHiRokidSession 的 adbClientProvider 内 shouldYield(BACKGROUND) |
NORMAL |
ADB 工具页、AI 工具查询、AIUI 工具、定时任务等常规控制面 | CxrLHiRokidSession.getAdbShellClient() 内 shouldYield(NORMAL) |
LONG_LIVED |
屏幕镜像 / 手机投屏 / 文件浏览:需长时间独占 RFCOMM SCN | 由 domain/MirrorCoordinator 取租约 |
acquire(owner, priority)非阻塞、必成功,返回ChannelLease(close()幂等,重复调用安全)shouldYield(priority):存在严格更高优先级持有者时为 true → 调用方自行退避(与AsrBridgeCoordinator的退避语义一致);同级互不让路- 只有
LONG_LIVED需要acquire():NORMAL各消费者(ADB 工具页 / AI 工具 / AIUI 工具 / 定时任务)共享同一条 ADB 会话(platform/AdbTransport为唯一所有者),彼此互不让路,持常驻租约只会让BACKGROUND兜底轮询被误伤 snapshot()暴露当前持有者;LONG_LIVED持有超 10 分钟打「疑似未释放」告警- 设计取舍:这是协作式闸门而非互斥锁,避免长连接建链期被锁阻塞
- 与 L0
platform/AdbTransport的分工:AdbTransport管「会话所有权 + 串行化」(谁在用 socket、何时重建/释放),ChannelArbiter管「优先级让路」;长连接上场前需同时做两件事 —— 取LONG_LIVED租约 +releaseAdbShellClient()腾出 RFCOMM
LinkProtocol(v2):把散落的「裸 CXR 频道名」和 __LAB_* 控制帧标记收敛为常量,手机端 phone-app/.../glasses/LinkProtocol.kt 与眼镜端 RokidLink/.../rokidlink/LinkProtocol.kt 是双端同源副本(除 package 行与空行外逐字节一致)。
- 构建期守护:
phone-app/build.gradle.kts注册checkProtocolSynced,校验LinkProtocol.kt+AiChannel.kt两端同源,并禁止业务文件出现裸协议字面量,挂在preBuild上(手机端构建即拦截)。眼镜端独立构建,不参与该校验 —— 改协议常量后务必手动同步两端。 - 另一道 preBuild 门禁:
checkI18nKeysSynced,校验 phone-app 与 RokidLink 两模块values/↔values-en/的<string name>集合完全相等(见 §1.1 多语言)。 - 第四道 preBuild 门禁:
checkNoBareCatch,扫描双端src/main全量空 catch,未带// catch-ok: <原因>标注的数量以bareCatchBudget = 47为棘轮预算(只降不升,新增即失败,见RULES.md§12.14)。四道门禁都挂在preBuild,本地构建即拦截;出 release 包另有两道packageRelease发布闸门(详见 §8.5 末)。 - 常量:频道名
CXR_CHANNEL_AI / SYS / WIFI / JSAI / AI_RENDER;标记MARKER_MUSIC_STOP/MARKER_ABORT_AI/MARKER_PHOTO_ASK/MARKER_TOOL_CALL("__LAB_TOOL__") /MARKER_ASR_READY;握手 topicTOPIC_HELLO/TOPIC_HELLO_REQ;确认 topicTOPIC_TOOL_CONFIRM/TOPIC_TOOL_CONFIRM_RESULT - 能力位
Cap:TOOL_CONFIRM/LYRIC_OVERLAY/AIUI_HOST/SELF_HEAL_PING/SHOW_IMAGE/OPEN_APP,PROTOCOL_VERSION = 2(v1 为无能力协商的旧眼镜端),Cap.ALL为当前版本默认能力全集
GlassesHandshake 三态:眼镜端服务就绪时主动上报 TOPIC_HELLO(caps = [version, capsBitmask, linkVersion]);手机端连接建立后也会下发 TOPIC_HELLO_REQ 主动询问(覆盖「眼镜端后启动」场景)。supports(bit) 的返回值语义:
| 返回值 | 状态 | 调用方策略 |
|---|---|---|
null |
尚未握手,能力未知 | 乐观:按支持处理,失败再兜底 |
true |
已握手且支持该能力 | 正常路径 |
false |
已确认不支持 | 快速降级:立即拒绝,不再空等超时 |
旧版眼镜端(v1)不应答握手:手机端发出请求后 4s(LEGACY_DETECT_DELAY_MS)仍未收到通告即 markLegacy() 判为旧版 —— 避免每次工具确认都空等 35s。断开 / 销毁时 reset() 复位,下次连接重新握手。
v3.5 起测试从「只覆盖最好测的纯逻辑层」扩到历史真出过 bug 的高危文件,当前 14 个测试类 / 155 个 @Test(phone-app 150 + RokidLink 5)。
# 在仓库根 d:\rokidapp 执行
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-17.0.11.9-hotspot"
& D:\gradle-8.7\bin\gradle.bat :cxrl:RokidLab:phone-app:testDebugUnitTest :cxrl:RokidLab:RokidLink:testDebugUnitTest --offline| 测试类 | 例数 | 锁住什么 |
|---|---|---|
adb/AdbSyncProtocolTest |
15 | AdbShellClient.pullFile 的 FAIL / CLSE / 读流异常分支(历史 A4 事故:远端 FAIL 被当成功 → 产出 0 字节文件)+ 提取残包回归锁(未 DONE 断流 / 字节数不符 / 畸形帧)与分片收尾语义(pullFileSharded) |
adb/AdbFileManagerSyncTest |
6 | AdbFileManagerClient sync 帧编解码 + drainStalePackets 陈旧 CLSE 排空 + parseDateTime |
hid/HidReportTest |
12 | BtHidCompat.normalize 截断/补零/未声明返 null、declaredLength 全矩阵、描述符 Report ID 与 declaredReportIds 交叉校验 |
glasses/AiChannelTest |
21 | AiChannel 跨端载荷 v0/v1 矩阵 + 常量名稳定性(改名即断双端) |
ai/ToolRiskMapTest |
3 | ToolRiskMap.unregisteredTools() 必须为空(§12.10)+ 风险表 / 无人值守只读名单 |
ai/approval/ApprovalGateTest |
56 | 合成语义(Deny 短路 / 首个 Ask 胜出 / null 不表态)+ 伪工具已知 + 按 confirmPolicy 分流的降级三态(BLOCK 拒 / PROCEED 放行 / 显式取消) + 眼镜优先·手机兜底的通道选择 + per-source 限流 + 页面准入文案 + 风险表三消费者 |
ai/PhoneToolConfirmChannelTest |
4 | 手机确认通道的未 init 必须不可用且不假装问过(isAvailable() / confirm() 均为 false)+ 通道标识 "phone" 稳定性(日志归因) |
store/ChatHistoryStoreTest |
14 | 聊天历史 JSONL 落盘格式:同 id 后写覆盖先写、旧版 JSON 数组迁移不丢历史、崩溃截断半行不毁历史、clear() 删文件 |
RokidLink/AiChannelProtocolTest |
5 | 眼镜端能解析手机端下发的 v1 载荷(边界矩阵留在 phone-app 侧,避免重复维护) |
写测试的三条硬约束(详见 RULES.md §12.15):
- 执行位置:必须在仓库根
d:\rokidapp跑(RenewCXRLSample,任务名:cxrl:RokidLab:phone-app:*)。cxrl\RokidLab不再是独立 Gradle 根(其settings.gradle.kts/gradle/libs.versions.toml已删除),在该目录执行会沿目录树上溯命中根构建,任务名必须带:cxrl:RokidLab:前缀。 - Android 桩:
phone-app已开testOptions { unitTests.isReturnDefaultValues = true }(否则Log一碰即抛RuntimeException("Stub!"));ADB 客户端用internal fun attachStreamsForTest(input, output)注入脚本化对端(AdbTestPeer.kt),无需真机与 socket。 - 改字节必改测试:动 HID 描述符 / sync 帧格式 / 跨端载荷格式的提交必须同步更新测试;#8 实测已推翻
buildQtiCompatibleDescriptor旧注释声称的「≤ 64 字节」,实为 67(无 Mouse)/ 121(含 Mouse) 字节。
尚未覆盖:
KeyButtonService/CxrLHiRokidSession;androidTest为 0。⚠️ ChatStateHolder本体现在可测(见store/ChatStateHolderTraceTest):isReturnDefaultValues = true⇒Looper.myLooper()与getMainLooper()同为 null、runOnMain走内联分支;appContext为 null ⇒ 落盘静默跳过。 写这类测试要在@Before/@After清messages并finishTrace()释放锚点(单例跨用例复用)。
构建期门禁(四道,挂 preBuild,本地构建即触发):checkProtocolSynced(双端协议同源 + 禁裸协议字面量)、checkI18nKeysSynced(两模块 zh↔en key 集合相等)、checkKeyPathEmptyCatch(6 个关键链路文件空 catch 零容忍)、checkNoBareCatch(全仓空 catch 棘轮预算 47,只降不升)。
发布闸门(两道,挂 packageRelease,双端共用 gradle/local-gates.gradle.kts):checkGitClean(git status --porcelain 必须为空,否则出包即失败;临时验证可 -PallowDirtyWorktree=true)+ packageRelease 依赖 testDebugUnitTest(单测没绿出不了包)。
为何不建托管 CI(#10 结论):四道门禁已挂在
preBuild,本地每次构建都会跑,托管 CI 属重复执行;且 Android CI 需复刻 SDK/NDK/16KB 校验环境、Gitee Go 免费额度有限,而本项目是单作者、唯一发布路径就是本地构建 —— 收益为负。真正缺的「出包必跑测试 / 脏工作区不许出包」用两道 Gradle 闸门即可闭合。
问题(#9 修复前):ChatStateHolder.add() 在主线程调 persist(),而 persist() 把整个列表重新 JSON 序列化后 writeText —— 每条消息都是 O(n) 写盘(累计 O(n²)),长会话下卡顿掉帧;且 clear() 只写空数组、不删文件。
现状:格式与回放逻辑抽到纯 JVM 的 store/ChatHistoryStore.kt(可单测),ChatStateHolder 只负责"何时写、在哪个线程写"。
调用线程(Compose 主线程) 单线程 daemon: chat-history-writer
───────────────────────────── ───────────────────────────────────
add / addImage / finalizeLastAi
├─ messages.add / 替换(快照列表)
├─ ChatHistoryStore.toLine(msg) ──► appendLine(file, line) ← JSONL 追加一行
└─ writer.execute { ... }
init(context)(Application.onCreate)
└─ writer.execute {
readHistory(file) ← 读盘
rewrite(file, 每 id 一行) ← 旧格式迁移 + 压实
mainHandler.post { applyLoaded } ─► messages.addAll(...) (回主线程)
}
- 格式:JSONL,每行一条完整消息;同 id 后写覆盖先写(
finalizeLastAi用一行"覆盖行"修正流式消息,位置不变)。 - 迁移:旧版"整份 JSON 数组"的
chat_history.json(首字符[)在首启被识别并重写为 JSONL,不丢历史。 - 容错:崩溃留下的半行 / 非法行被跳过,其余历史照常恢复。
- 不变量:
SnapshotStateList只在主线程读写;后台任务只碰字符串;clear()必须删文件。 ⚠️ 一轮的「过程」落在哪条消息上由ChatStateHolder内部的traceAnchorId锚点决定,不看位置。 历史教训(2026-09-20 真机 bug「图片已经显示出来了,过程里还在思考」):原先按"列表末尾那条 非用户非状态消息"定位,而位置不是身份 —— 一轮进行中只要别的消息被追加,"末尾"就换人, 同一个tool:<call_id>的RUNNING留在旧气泡、OK/FAILED写进新气泡;AgentStep的 「同 key 覆盖」只在单条消息内生效 ⇒ 覆盖失效,旧气泡永久转圈。 两条互相独立的插入路径都踩过(与调用了哪个工具无关):①show_image的图片气泡; ② 拍照流程的状态气泡(onStage→add/onStageText→updateLastStatus在末尾不是状态气泡时会新增一条)。 ⇒ 现在新增"轮中途插消息"的能力不必再动过程代码,但禁止把落点改回"取末尾那条"。⚠️ 收尾只结清锚点那一条(finishTrace)。不要改成"扫全表把见到RUNNING的都标 OK" —— 那是拿兜底盖症状(会把"这里为什么会有残留"一起抹掉)。锚点制让残留结构上不再产生。⚠️ 回放时的RUNNING归一是崩溃修复,不是本 bug 的解法:正常收尾必写终态,所以盘上 还留着RUNNING只可能是那一轮被杀进程(addImage会在轮中途带着RUNNING落一次盘, 之后才被终态覆盖)。别拿"读的时候会兜住"当理由省掉收尾。⚠️ 「过程」卡片显示的是用户视角的名字,落盘的仍是标识符:AgentStep.title对工具步骤存的是 模型的 wire name(get_current_time/mcp__<serverId>__<工具名>),禁止把显示名写进去 (会污染历史数据:改名/换语言后旧消息不跟着变,也断掉与日志的对应)。显示名由 UI 在渲染时查表:ChatBubble.stepText()→ToolRegistry.displayNameOf()—— 就是工具设置页那一套名字 (静态工具取ai_tool_*_name、MCP 取"服务器名 · 原始工具名"),查不到原样回退。 ⇒ 旧历史消息无需迁移即自动变成友好名;代价是界面名 ≠ 日志名,要原始标识符排查走SessionTraceDialog。 思考行的两态文案("正在思考" / "思考完毕")在ChatBubble.stepLabel()本地化,服务层只发语义。
约束详见
RULES.md§12.16;回归测试见store/ChatHistoryStoreTest(24 例)+store/ChatStateHolderTraceTest(6 例)。⚠️ ChatStateHolder本体可测(isReturnDefaultValues = true⇒runOnMain内联、appContext为 null ⇒ 落盘跳过); 写这类测试必须在@Before/@After清messages并finishTrace()释放锚点(单例跨用例复用)。
设置页「设置 → 本机执行环境」(settings/LocalExecScreen.kt)对应的东西:随包只带 libproot.so +
libproot-loader.so(共 214 KB,不借道 Termux),rootfs 与所有组件都运行期按需下载。
files/proot/ ← ProotShell.workDir(「删除执行环境」= rm -rf 这里)
├── tmp/ ← 脚本先落盘,再由 guest 执行 /tmp/x.sh
├── rootfs/ ← Ubuntu 24.04.5 base arm64(下载 29,936,675 B,四镜像容错)
├── rootfs.version ← 就绪标记:version + abi + sha256
└── extras/ ← 按需组件(当前只有 node/),重装 rootfs 不丢
├── node.version ← 组件自己的标记,与 rootfs 那份互不牵连
└── node/
guest 视图: /mnt/lab ← Download/Lab(内核 write() 直连,见 RULES §12.21②)
/opt/extras ← extras/(guestExtraPath 追加 /opt/extras/node/bin)
| 文件 | 职责 |
|---|---|
platform/ProotInstaller.kt |
rootfs:四镜像容错下载 → sha256 → 解压 → 裁剪 → 写标记;uninstall 整目录删除 |
platform/ProotShell.kt |
guest 执行(读锁 + Semaphore(2),装包走写锁)、挂载、apt 装包与进度解析、状态探测 |
platform/NodeAddon.kt |
Node.js 自包含包下载 → 原子就位 → 写 <prefix>/etc/npmrc(npmmirror registry) |
platform/ProcessRun.kt |
「一次性命令 + 超时强杀」模型(tar / rm / proot 都走它) |
settings/LocalExecScreen.kt |
页面:装环境 / 三张组件卡 / 共享文件夹 / 检测环境 / 删除环境 |
对话侧只暴露 shell 域两个工具:run_shell / install_packages(后者 EXTERNAL_SIDE_EFFECT + PROCEED)。
shell 域不对 AIUI 页面开放(§6.4 第 2 条)—— 第三方 .aix 页面不能任意执行命令。
RULES.md §12.21。