一台平板,一只 dongle,没有电脑也没有 root(七·完)
上一篇:iPhone 客户端
系列的最后一篇,条件最苛刻:一台 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 当收件箱,定期清空到平板上 |
| 平板没有听筒,只能外放 | 麦克风用通信模式,让系统的回声消除生效 |
倒数第三条是这一篇的主线,后面会详细讲。
装起来
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
平板只有一个 USB 口,插了 dongle 就不能插电脑,所以调试只能走无线:
adb mdns services # 找到平板的配对服务
adb pair <ip>:<port> # 用平板上显示的配对码
adb connect <ip>:<port>
坑:系统自带的老版 adb 配不上对。 要用 Android SDK platform-tools 里的新版。这个错误的表现是配对超时,不会告诉你是版本问题。
语音驱动:在 App 里选”从文件夹导入”,或者先运行一次 App,再推送到它的私有目录。没有驱动也能接通电话,只是没有声音。
一个必须改的系统设置:开发者选项里停用 USB 音频路由,然后把 dongle 拔插一次。
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 在通话和媒体上都优先选耳机。这个行为本身完全合理,只是这副”耳机”是给电话对面的人听的。
解决要三步一起做:
- 桥接把内置扬声器同时钉为通信设备和音轨的首选设备;
- 认领 USB 接口之后,系统里不再有这块声卡;
- 但 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 自己的连接转发命令:
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 模组。这大概是这个系列最值得说的一件事:很多看起来封闭的硬件,只是没有人去问它是什么。
全系列
这个系列是个人学习和技术研究记录。模组的语音驱动是 GPL 衍生物,不随项目分发,需要自行准备。请遵守所在地法律法规和运营商服务条款。