# dsh-dot/phone · 她的手机

An Android phone the plugin installs and runs for the character, exposed to the model as `mcp__dot-phone__*`. No Android Studio, no Java: the plugin fetches three archives from Google's repository, writes the AVD itself, boots the emulator and mounts [mobile-mcp](https://github.com/mobile-next/mobile-mcp) over adb.

## 它做什么

1. **安装**（第一次）：接受 [Android SDK 许可](https://developer.android.com/studio/terms)后（`/phone accept`，或配置 `acceptLicense: true`，或 `~/.dsh/dsh-phone.json` 里 `{"acceptLicense": true}`），从 `dl.google.com/android/repository` 读索引，下载 stable 通道的 emulator、platform-tools 和 `system-images;android-<api>;google_apis;<abi>`（约 1.7 GB），解压到 `~/.dsh/phone/`。`google_apis` 镜像没有 Play 商店——检测面最小，App 用 APK 装。
2. **AVD**：`~/.dsh/phone/avd/dot.avd/config.ini` 由插件写（Pixel 6 尺寸、1080×2400、3 GB 内存、数据分区默认 16 GB、不带 Play）。数据分区大小在手机建好之前选：`/phone accept <GB>` 或 `/phone size <GB>`（8–256）；建好后模拟器不会扩大已有分区，`/phone size` 只显示当前大小。想改尺寸或内存，改配置后删掉这个目录重建（手机里的数据会没）。
3. **开机**：`emulator -avd dot -port <端口> -no-boot-anim -gpu auto`，等 `sys.boot_completed`，然后常亮屏幕、关动画、解锁。上一次 dsh 留下的还在跑的模拟器会被直接接管，不会开两个——但只接管控制台报告 AVD 名是 `dot` 的那台（`adb emu avd name`）：MuMu、雷电、BlueStacks 等也会以 `emulator-5554` 出现在 adb 里，占着端口不等于是她的手机。端口从 `port`（5554）起找第一个 adb 没列出的（到 5584 为止）。模拟器自己退出会在 `restartDelayMs` 后重开。手机在 dsh 退出后继续运行。
4. **MCP**：开机后以 stdio 挂一个 mobile-mcp（随插件一起安装，`ANDROID_HOME` 指向 `~/.dsh/phone`，`ANDROID_SERIAL` 是上一步占到的 `emulator-<端口>`），工具名 `mcp__dot-phone__mobile_*`：列设备、截图、列屏幕元素（带坐标）、点、滑、长按、输入、按键、打开/关闭 App、装卸 App、开 URL。
5. **提示**：手机就绪后系统提示里多一段 `phone:device`，告诉模型设备号和用法（先看元素再点；验证码、人脸停下来叫用户）。
6. **闸门**：`mobile_install_app` / `mobile_uninstall_app` 弹审批（目前审批框只显示工具名，不显示是哪个 App）；`mobile_type_keys` 的内容像密码 / 支付 / 验证码时弹审批（`sensitivePatterns`）。幕间的观察轮只放行只读工具（看、滑、点进去、开 App、返回），其余直接拒绝、不审批。
7. **通知叫醒她**：插件盯着系统的事件日志（`adb logcat -b events notification_enqueue`），每有 App 发通知就立刻读一次通知栏（`dumpsys notification`），另外每 `notifyPollMs` 兜底轮询一次——QQ 这类 App 在聊天窗口开着时会立刻撤回通知，光靠轮询会漏，新出现的通知（启动时已经在栏里的不算；系统包不算；`notifyApps` 可以只挑几个 App）交给 dot，dot 往她最近的会话里追加一条 `[11-04 04:52 手机通知] QQ · 宽：……`（开头是收到的时间），她自己决定去 App 里回还是只回 `DOT_SILENT`。这条回复不会转发到任何 IM。所以她不需要定闹钟去看 QQ——有人找她，手机会响。**任何 App 都算**，包括她以后自己装的：新 App 默认叫醒；像手机自己的通知设置一样可以按 App 静音——`/phone notify mute <包名>` / `wake <包名>`（持久化在 `dsh-phone.json`），`/phone notify` 看每个 App 发过多少条和当前策略。她在 Android 设置里关掉某个 App 的通知也一样生效（通知栏里没有就不会叫）。几秒内连来的多条合成一次叫醒（dot 的 `notifyBatchMs`）；dot 的 `notifyHours` 可以设一个静音时段（默认不设）。`/phone notifications` 看最近 20 条。
8. **实时画面**：两种。一是模拟器窗口本身（`window: true`）；二是插件的网页，挂在 **dsh 自己的 web server** 上：`<dsh 地址>/dsh-dot/phone/`——dsh 右侧栏的「手机」标签页就是它：屏幕每秒刷新几次（PNG 流），能直接点、滑、按返回/主页、输入文字，登录 App 就在这里做。**它不单独监听端口**：页面、画面流和每一次点击都先过 dsh 的 `connection` 服务（dsh 自己的登录态 + Host/Origin 围栏，转发头一概不认），只有登录了这个 dsh 的人能看能点。想在别的设备上看，把 dsh 本身暴露出去（如 `tailscale serve` dsh 的端口，或 dsh 的远程访问），再把那个地址写进 `publicUrl`，审批提醒里的链接就是 `<publicUrl>/dsh-dot/phone/`。无头跑（服务器）时 `window: false` 只用网页。网页里的文字输入走 `adb shell input text`，只支持 ASCII（账号、验证码够用）；中文由她自己的工具输。
9. **摄像头**：后置摄像头是模拟器的虚拟场景（`hw.camera.back=virtualscene`，前置没有）——一个有电视和书架的 3D 房间。场景里的 `wall` 海报每次开机前被挪到镜头正前方 1.5 米、1 米见方（插件改写模拟器的 `resources/Toren1BD.posters`，原文件存成 `.orig`，`table` 海报不动），所以往海报上放一张图，她打开相机或扫一扫看到的就是它。`dshPhone.showToCamera(png)` 负责放：先按 HOME 回桌面（有 App 开着摄像头时换图不生效），`adb emu virtualscene-image wall` 清空海报，再设成新写的 `resources/dsh-dot-poster-<毫秒>.png`（同一路径或不先清空，模拟器会一直显示这个进程里放的第一张），旧文件删掉。QQ 扫码登录电脑端就靠它（见 [qq.md](qq.md)）。0.10.8 之前开起来的手机要 `/phone restart` 一次海报才会挪过去；更早建的、没有摄像头的 AVD 会被自动补上 `virtualscene`，同样下次开机生效。
10. **表情包在相册里**：她电脑 QQ 那边存的表情包（`~/.dsh/stickers/`，见 qq.md）同步到手机 `/sdcard/Pictures/dot-stickers/<id>.<png|jpg|gif|webp>`（扩展名按文件头判断），推完发 `MEDIA_SCANNER_SCAN_FILE` 广播，相册里就有一个 dot-stickers 相册，任何 App 都能从相册选图发。手机就绪时、表情包有增减时各同步一次（按文件名 + 大小比，已经在的不重推；从收藏里挤掉的在手机上也删掉；文件夹里不是 `<id>.<ext>` 的文件不动）。手机提示词最后一段（`phone.stickers`）教她各 App 怎么把相册里的图加进自己的表情：QQ 长按 → 添加到表情、微信长按 → 添加表情、小黑盒评论配图从相册选；做成一次就记到 self。只教不催，用不用随她。`/qq stickers` 看同步了几张，`/qq stickers sync` 立刻推一次。

`/phone` 看状态（含存储大小），子命令：`accept [GB]`、`size <GB>`、`retry`、`restart`、`screenshot`、`notifications`、`notify [mute|wake <包名>]`。

## 配置

| 配置项 | 默认 | 作用 |
|---|---|---|
| `home` | `~/.dsh/phone` | SDK 三件套和 AVD 所在 |
| `api` / `tag` / `abi` | `35` / `google_apis` / 跟随宿主 CPU | 系统镜像；Apple silicon 用 `arm64-v8a`，x86 用 `x86_64` |
| `avd` / `device` | `dot` / `pixel_6` | AVD 名和机型 |
| `ramMb` / `dataGb` | `3072` / `16` | 内存、数据分区（`dataGb` 也可用 `/phone accept <GB>` / `/phone size <GB>` 在建手机前设） |
| `port` | `5554` | 控制台端口的起点：被别的模拟器占着就往后找（+2，到 5584）；adb 序列号 `emulator-<端口>` |
| `window` / `DSH_DOT_PHONE_WINDOW=off` | `true` | 显示模拟器窗口 |
| `liveView` | `true` | 在 dsh 的 web server 上挂实时画面页（`/dsh-dot/phone/`）；`false` 关闭 |
| `publicUrl` | 无 | 这个 dsh 对外的地址（你自己的暴露方式）；实时画面链接 = `<publicUrl>/dsh-dot/phone/`，写进审批提醒 |
| `gpu` | `auto` | 模拟器 `-gpu` |
| `keepAppsAlive` / `keepAliveEveryMinutes` | `true` / `10` | 关掉 doze、standby、cached-apps freezer，给所有第三方 App（含她以后装的）后台运行许可，否则 QQ 退后台几分钟就被冻结、收不到消息（真机实测：发出到弹通知差 3.5 分钟） |
| `locale` / `timezone` | `zh-CN` / `Asia/Shanghai` | 手机的语言和时区 |
| `number` | 无 | 她的手机号（注册、登录 App 用）；也可用 `/phone number <号码>` 设 |
| `installers` | 浏览器、文件管理器 | 开机时被允许安装未知应用的包；`[]` 则她得自己去设置里开 |
| `acceptLicense` / `DSH_DOT_PHONE_ACCEPT_LICENSE=yes` | `false` | SDK 许可 |
| `channel` | `stable` | 模拟器通道 |
| `bootTimeoutMs` / `restartDelayMs` | `300000` / `10000` | 开机等待、崩溃重开间隔 |
| `notifications` | `true` | 盯不盯通知栏 |
| `notifyPollMs` / `notifyApps` / `notifyMuted` | `4000` / `[]` / `[]` | 兜底快照间隔（事件到了会立刻读，`0` = 只靠事件）；只关心哪些包（空 = 所有非系统 App，含以后装的）；静音的包（`/phone notify mute` 也写这里） |
| `mcp` / `toolCallTimeoutMs` | `true` / `60000` | 挂 mobile-mcp |
| `askTools` / `sensitivePatterns` | 装卸 App / 密码支付验证码 | 审批规则 |
| `DSH_DOT_PHONE=off` | | 整个组件不加载 |

桌面版没有 shell 环境变量时，用 `~/.dsh/dsh-phone.json` 写同名字段。

## 装 App 和登录

- **她自己装。** 手机没有应用商店，和一个人拿到新手机一样：她用手机上的浏览器打开官网，下载安装包，点开安装。插件开机时已允许浏览器和文件管理器安装未知应用（`installers`），所以不用进设置。这是故意的——养一个会自己折腾手机的角色，而不是替她把一切装好。`adb install` 当然也能用，但那是你的事，不是她的。
- 注册、登录她自己来（见下一节）；你也可以在模拟器窗口里亲手登录。验证码、人脸、短信验证都由你过；她遇到会停下来告诉你。
- **用小号**：各平台条款都不允许自动化，模拟器也可能被检测。登录态在 AVD 里，重启不丢；`~/.dsh/phone/avd/` 整个目录就是这部手机，可以备份。

## 她的手机号和账号

- **手机号**：`/phone number 13800138000`（`/phone number -` 清掉），或 `dsh-dot.json` 里 `phone.number`；命令设的优先，不用重启。设了以后她的系统提示词里多一句「你的手机号是 …」，验证码发到你那里，她点了发送就会停下来问你要。
- **账号**：存在 `~/.dsh/dsh-dot-accounts.json`（0600，原子写入），不进她的记忆——整理记忆本来就不记密码，记了也会丢。
- 她用 `account` 工具：`get <app>` 取登录名和密码，没记过的给她手机号和默认密码去注册；`set <app>` 注册成功或被逼改密码后马上记下；`list` 只列 App 和登录名。
- **默认密码**：第一次用到时随机生成一个（12 位字母数字，不含 0/O/o/1/l/I），存在同一个文件的 `_default`，以后每个 App 都用它，除非 App 逼她换。
- 想看、想改：直接编辑那个文件（JSON）。审计日志（`dsh-phone-audit.jsonl`）只记 `account-get` / `account-set` 和 App 名、登录名，不记密码。
- `/phone` 的状态里有号码和记了几个 App 的账号，不显示密码。

## 平台

| | 可用 | 备注 |
|---|---|---|
| macOS Apple silicon | ✅ | arm64 镜像原生速度；第一次启动系统会问是否允许模拟器用虚拟化 |
| macOS Intel | ✅ | x86_64 镜像，依赖 Hypervisor.framework |
| Linux x86_64 | ✅ | 需要 KVM（`/dev/kvm` 可写）；无头跑用 `window: false` |
| Linux arm64 | ❌ | Google 不发 Linux arm64 模拟器 |
| Windows x64 | ✅ | 需要 WHPX 或 Hyper-V；解压用 PowerShell |
| 另一台 Linux 虚拟机里 | ❌ | 嵌套虚拟化。手机和电脑是两台设备，不是一台装另一台 |

## 已知限制

- App 开在前台时不会发通知（QQ 的聊天窗口开着就收不到那个人的消息通知）。提示里让她用完 App 按 HOME 回桌面。

- 第一次下载约 2 GB，取决于到 `dl.google.com` 的速度。
- 没有 Play 商店和 Google 服务；需要 GMS 的 App 不能用。
- 模拟器检测：部分 App 会限制功能或拒绝登录。退路是一台真安卓机接 adb——`ANDROID_SERIAL` 换成真机序列号即可，组件其余不变（待做：`serial` 配置项）。
- mobile-mcp 的工具每次调用都要带 `device`，模型偶尔会忘；提示段里给了设备号。
- 多台手机未做。
- 数据分区建好后不能扩大（改 `config.ini` 的 `disk.dataPartition.size` 不生效，实测）；满了只能清 App 数据、卸 App，或清空重建。

## 模拟器挂了

接管的模拟器（上次 dsh 留下的）不是我们的子进程，QEMU 卡死崩溃（11-03：所有 QEMU 线程 15 秒无响应后 crash）时没有退出事件。所以就绪后每 `aliveEveryMs`（15 s）问一次 `adb get-state`，连续 3 次没回应就记 `emulator-gone`、状态转 error、停通知监听，`restartDelayMs` 后像退出一样重开。
