# 一台平板，一只 dongle，没有电脑也没有 root（七·完）

- URL: https://wuming.si/blog/4008/
- Author: Aaron
- Published: 2026-09-23
- Language: zh-Hans
- Categories: Technology
- Originally published at: https://blog.wo.ai/archives/4008

> 上一篇：[iPhone 客户端](https://wuming.si/blog/4007)

系列的最后一篇，条件最苛刻：一台 Pixel Tablet，唯一的 USB-C 口插着那只 4G dongle。没有电脑可以帮忙，没有 root，插了 dongle 就连充电线都插不上。

目标是让平板**自己**变成一部电话：

- 打电话、接电话，声音走平板的扬声器和麦克风；
- 收发短信，中文和 emoji 都要对；
- 通话记录、未接来电通知、通讯录匹配；
- 在"平板用 SIM 上网"和"SIM 只管电话短信"两种模式之间切换。

不依赖前面那台树莓派网关。App 直接驱动模组。

## 约束把设计定死了

这个项目几乎没有多少自由发挥的余地，每一个设计决定都是被某个约束逼出来的：

| 约束 | 结果 |
|---|---|
| 平板上没有 adb 程序，也没有 root | **App 自己实现 adb 协议**，通过 Android 的 USB Host API 直接和模组里的调试服务对话 |
| 模组的串口在 Android 上没有驱动 | AT 命令走 adb 的 shell 流，和 Mac 版一样 |
| 来电和新短信的通知发到 USB AT 口，收不到 | 通话状态和新短信**靠轮询**，不靠等 |
| 语音路由的一切都在内存盘里 | 每通电话前重搭，已搭好时很便宜 |
| 驱动是 GPL 衍生物 | 不打包，用户自己导入一次 |
| **Android 一个 App 只能有一路活跃录音** | 把模组声卡从 Android 手里抢过来，用原生代码自己搬数据 |
| SIM 卡只能存几十条短信，满了就拒收 | 把 SIM 当收件箱，定期清空到平板上 |
| 平板没有听筒，只能外放 | 麦克风用通信模式，让系统的回声消除生效 |

倒数第三条是这一篇的主线，后面会详细讲。

## 装起来

```sh
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```

平板只有一个 USB 口，插了 dongle 就不能插电脑，所以调试只能走无线：

```sh
adb mdns services          # 找到平板的配对服务
adb pair <ip>:<port>       # 用平板上显示的配对码
adb connect <ip>:<port>
```

**坑：系统自带的老版 adb 配不上对。** 要用 Android SDK platform-tools 里的新版。这个错误的表现是配对超时，不会告诉你是版本问题。

**语音驱动**：在 App 里选"从文件夹导入"，或者先**运行一次 App**，再推送到它的私有目录。没有驱动也能接通电话，只是没有声音。

**一个必须改的系统设置**：开发者选项里停用 USB 音频路由，然后把 dongle 拔插一次。

```sh
adb shell settings put secure usb_audio_automatic_routing_disabled 1
```

为什么要改，后面那个坑会解释。

## 自己实现一个 adb 客户端

听起来吓人，实际上几百行。adb 的线协议很简单：一个握手包，可选的密钥认证，然后是打开流、写数据、确认、关闭这四种包。一条 USB 连接多路复用所有的流。

只用老式帧格式（4 KiB 负载、字节和校验），这个年代的调试服务一定接受。手上这只 dongle 从不要求认证。

自己实现还带来一个意外的性能好处：每条 AT 命令只是已打开连接上的一次往返，而 Mac 版每条命令都要启动一个 `adb shell` 进程。差距很明显。

AT 层的结构和 Mac 版一样：一个长期的读流，每条命令一个短的写流。写的时候用八进制转义，是为了让任何字节（引号、控制字符）对模组的 shell 都没有特殊含义。启动前先杀掉模组里残留的读进程——第五篇那个"幽灵读者"的坑，在这里被提前预防了。

老版调试服务不报告退出码（第四篇踩过），所以脚本自己打印标记，程序检查标记而不是返回值。

轮询的节奏：

| 频率 | 命令 | 用途 |
|---|---|---|
| 通话中 1 秒，空闲 2 秒 | `AT+CLCC` | 通话状态，也是发现来电的唯一方式 |
| 空闲每 20 秒 | 列出并删除短信 | 清空 SIM 收件箱 |
| 空闲约 14 秒 | 一组状态查询 | 状态栏和网络页 |

有个固件怪癖：通话列表里会多出一个幻影数据条目，所以只认"类型是语音且有号码"的条目（来电振铃是例外，号码可能是隐藏的）。连续两次轮询都看不到才算挂断，拨号后给 8 秒宽限。

## 最大的那个坑：每通电话都有一边没声音

**现象**：路由正常，电话接通，但"对方"的电平表几乎不动，"我"的电平表完全不动。

**取证**：拨一个有固定语音提示的服务号码，逐秒记录两路录音的音量。

| 打开的录音 | 模组下行音量 | 平板麦克风 |
|---|---|---|
| 模组 + 平板麦克风 | 0.001–0.002 | 0.0000 |
| 只开模组 | **0.28–0.39**，峰值 0.96 | — |
| 只开平板麦克风 | — | 0.0038（环境噪声） |
| 平板麦克风 + 模组 | — | **精确的 0** |
| 换一组录音源类型 | **0.0000** | 0.003–0.005 |

这张表读起来很清楚：单独开任何一路都正常，同时开两路就有一路是死的。而且**换录音源类型只是换了另一边输**。

Android 报告每一路录音都"没有被静音"，路由配置也全对。

**原因**：**Android 只给一个 App 一路活跃录音。** 模组的 USB 音频输入和平板麦克风同时录，其中一路必然拿到静音。这不是 bug，是平台的既定行为。

试过的一条死路：桥接程序有个模式可以把音频通过 adb 流传出来，绕开录音系统。但在这个固件上打不开设备，报 `errno = -22`。

**解决办法只能是釜底抽薪：把模组的声卡从 Android 手里拿走。**

模组一连上，就强制认领它的 USB 音频控制接口和两个流接口。这会把系统的 USB 音频驱动踢掉——从 Android 的角度看，这块声卡消失了。然后通话开始时自己设置采样率，自己提交等时传输请求，自己搬数据。

具体参数：8000 Hz 单声道 16 位，高速总线上每毫秒一个 16 字节的包；每个方向 4 个请求在途，每个请求 8 个包（8 毫秒）。两端时钟不同，所以环形缓冲积压超过 200 毫秒就砍回 60 毫秒。关闭时必须先丢弃并回收所有请求再释放资源——**请求在被回收之前属于内核，不是你的**。

这样 Android 就只看到一路录音：平板麦克风。结果是下行音量 0.40 到 0.51，每个方向精确的 16000 字节每秒，零传输错误。

## Android 把所有声音都送进了 dongle

**现象**：Android 把 dongle 识别成一副 USB 耳机，还设成了通信设备。音频日志里，通话输出在半秒之内从扬声器切到了"USB 耳机"。别的 App 放的音乐也跑进去了。

**原因**：模组的音频功能看起来就是一副标准 USB 耳机，而 Android 在通话和媒体上都优先选耳机。这个行为本身完全合理，只是这副"耳机"是给电话对面的人听的。

**解决**要三步一起做：

1. 桥接把内置扬声器同时钉为通信设备和音轨的首选设备；
2. 认领 USB 接口之后，系统里不再有这块声卡；
3. 但 Android 的音频框架**不会注意到驱动被卸载了**，它会继续往一个已经不存在的耳机送声音，直到设备重新枚举。所以要打开"停用 USB 音频路由"并让 dongle 重新枚举一次。

第 3 条就是前面那个系统设置的由来。这类"框架缓存了一个已经消失的东西"的问题，在整个系列里出现了不止一次。

## 其余的坑

### 拨号把自己的语音路由取消了

**现象**：电话接通了，屏幕上却显示一句奇怪的话——某个操作"因为协程作用域离开了组合"而失败，日志里写着"没有语音路由就开始拨号"，没有声音。

**原因**：每个按钮都在界面自己的协程作用域里启动工作。而拨号会切换到通话界面，拨号盘随之离开组合，它的作用域被取消——正在搭建的语音路由跟着被取消了一半。更糟的是，路由代码把"被取消"当成普通失败报告了，所以错误信息完全没指向真正的原因。

**解决**：所有模组操作都在一个随进程存活的作用域里运行；取消异常要原样抛出，不能当成失败吞掉。

**这是 Android 和 Compose 上非常容易犯的错**：界面的生命周期和工作的生命周期是两回事，绑错了就会在用户操作的那一刻自毁。

### 驱动推上去了，App 却说找不到

**原因**：那个目录是在 App 首次运行之前用命令行创建的，属主是 shell 用户，App 读不了自己目录下的文件。

**解决**：先运行一次 App 让它自己建目录，再推送；或者干脆用 App 内的导入功能。

### 命令的回复被当成了主动通知

移植时发现的一个老 bug：Mac 版把所有"长得像通知"的行一律交给通知处理，即使它们正是当前在途命令的回复。所以在 Mac 上查询 SIM 卡状态总是看起来是空的。

平板版的做法是：如果一行的前缀正好对应当前在途的命令，那它就是回复，留在回复里。

（顺带一提，Mac 版的这个 bug 还在。一个项目做到第七篇，总会有这种"知道但还没修"的东西。）

另外，模组对象还有第二把锁，用于多命令序列——短信流程中间会切换字符集，如果这时插进来一条轮询，它会把 UCS2 的十六进制当成普通文本读。

### 联网模式下平板只有 IPv6

**现象**：刚切到 ECM 模式，平板拿到了运营商的 IPv6，但没有 IPv4，连不上只支持 IPv4 的服务。过一会儿又自己好了。

**原因**：网卡一出现，内核就自行配置了 IPv6；而 IPv4 要靠 DHCP，模组只在自己的数据连接建立之后才应答 DHCP。问早了，Android 就一直停在只有 IPv6 的状态，直到它自己重试。

**解决**：模组侧无能为力。程序检测到这个状态时，在网络页提示用户拔插一次。最近一次测试，点下按钮后 31 秒 IPv4 到位。

### 两个从网关继承过来的短信坑

- 来电和新短信通知发往 USB AT 口 → 用轮询兜底，能改配置就把通知端口改成"全部"。
- SIM 存满会拒收新短信 → 每次清扫**先**存进平板本地，**然后**才从 SIM 删除。顺序反了就会丢短信。

### 一个号码两个会话

发出去用 `138…`，回来变成 `+86138…`，于是开了第二个会话。统一折叠国家码前缀，对收信、去信、通话记录、存储条目全都生效。通讯录匹配用的是同一套规则。

### 平板占着模组时，电脑没法调试模组

dongle 插在平板上，它的调试接口归 App 独占。这让调试变得很尴尬。

**解决**：在 App 里加一个受权限保护的调试接收器（只有 shell 和系统能调用），通过 App 自己的连接转发命令：

```sh
B="adb shell am broadcast -n <包名>/.DebugReceiver -a <包名>.DEBUG"
$B --es shell 'cat /proc/asound/cards'     # 在模组里执行命令
$B --es at 'AT+CLCC'                        # 发一条 AT
$B --es dial 10086   /   $B --es hangup 1   # 走真实的拨号/挂断路径
$B --es route 1                             # 搭语音路由
$B --es usbdesc 1                           # 看接口和端点描述
adb logcat -s VoHiveDebug AudioBridge UsbAudio VoiceRoute
```

音频桥每秒记录两个方向的音量、峰值和字节计数。**上面所有音频问题，都是靠这几个数字诊断出来的**，没有一个是靠读代码猜出来的。

给一个自己都够不着的设备留一条调试通道，前期看着像浪费时间，实际上是这个项目里回报最高的一次投入。

## 其他功能

- **两种模式，不是四种**：模组支持四种联网模式，但平板上只有两个有意义——ECM（Android 当 USB 以太网用）叫"联网模式"，QMI（Android 驱动不了）叫"电话短信模式"。切换要重启模组，App 预期断开，重新枚举后自动重连，大约 30 秒。
- **状态归平板所有**：短信和通话记录存在 App 的私有目录里，由进程级的控制器持有。所以切换模式——这会重启模组并断开重连——不会丢任何东西。
- **通讯录**：读系统通讯录，在通话记录、会话、来电界面和通知里显示名字；处理 `tel:` 链接，在系统通讯录里点号码就能用它拨出；未知号码可以一键交给系统通讯录保存。它自己从不写通讯录。

## 还没做完的

写下来是因为"还没做完"和"做完了"之间的距离，通常比看起来远：

| 问题 | 状态 |
|---|---|
| 真人说话时的上行 | 采集在跑，字节数正确，但安静房间里降噪后音量接近 0，还没让对端确认听到人声 |
| 真实来电 | 检测、铃声、全屏界面、接听都实现了，还没用真实来电验证过 |
| 长短信 | 分段会显示成多个气泡，拼接需要改用 PDU 模式 |
| 回声消除质量 | 外放加系统回声消除，还没在真实对话里评估 |
| 系统电话集成 | 没接 Android 的电话框架，用自己的通知和全屏界面响铃 |

## 七篇之后

同一只 dongle，四个平台，四种完全不同的驱动方式：

| 平台 | 控制通道 | 通话音频 | 最大的坑 |
|---|---|---|---|
| 树莓派 | 串口 AT（udev 固定名） | ALSA 读 USB 声卡 | 每通电话重建路由；Wi-Fi 通话协议栈 |
| Mac | adb 进模组 | CoreAudio 聚合设备 | 时钟域污染、必须签名 |
| iPhone | 通过网关的 HTTPS | WebSocket PCM | CallKit 生命周期 |
| 安卓平板 | App 自己实现 adb | 原生等时 USB 传输 | 一个 App 只能一路录音 |

回头看，反复出现的教训只有五条：

**一、"已就绪"必须带外确认。** 空条件判断出来的 ready，四个标志全是 true 却打不出电话的状态接口，设备节点存在但读出来全是零的声卡——状态值是代码自己写的，事实要去别处查。

**二、一个共享的底层被多个消费者读，就会各丢一半。** 语音丢一半、事件丢一半、两个读进程瓜分 AT 回复，是同一个 bug 的三张脸。发现它的办法是在数据通路的两个层各埋一个计数器。

**三、不要从"沉默"推断对方的策略。** 曾经写下"这是运营商签约问题"，实际上是自己的注册消息少了一个头。把消息打出来，逐条对规范。

**四、不要从一段听不懂的录音推断任何事**，先确认号码拨对了。

**五、迁移时，没有测试覆盖的补丁就是会丢的补丁。** 同一个来电进语音信箱的问题，修了两次。

一只二手价不到一百块的配件，里面是一台完整的 LTE 模组。这大概是这个系列最值得说的一件事：很多看起来封闭的硬件，只是**没有人去问它是什么**。

## 全系列

1. [它到底是什么](https://wuming.si/blog/4002)
2. [树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003)
3. [从零实现 VoWiFi](https://wuming.si/blog/4004)
4. [USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)
5. [插在 Mac 上](https://wuming.si/blog/4006)
6. [iPhone 客户端](https://wuming.si/blog/4007)
7. [安卓平板直接驱动](https://wuming.si/blog/4008)

---

*这个系列是个人学习和技术研究记录。模组的语音驱动是 GPL 衍生物，不随项目分发，需要自行准备。请遵守所在地法律法规和运营商服务条款。*
