让 iPhone 拨打一张不在 iPhone 里的 SIM 卡(六)
前面几篇的成果是一台树莓派网关:上面插着几张 SIM 卡,能收发短信、能打电话、还能通过 Wi-Fi 连上运营商核心网。
但它是一台放在角落里的机器。真正想要的是:手机上多出几条线路,用起来和手机自己的号码一样——来电有全屏来电界面,锁屏能接,短信是熟悉的气泡会话,拨号时能用系统键盘输 IVR 的分机号。
iPhone 没有 USB Host 能力,插不了也驱动不了模组。所以这个 App 是网关的客户端。界面四个标签:电话、短信、录音、设置,导航栏里一个线路选择器。
不用 WebRTC,也不做 SIP 客户端
第一反应可能是在手机上实现一个 SIP 客户端,或者上 WebRTC。两条路都没必要——网关已经把难的部分做完了,手机只要会说 HTTPS 就够了。
| 手机需要 | 网关提供 |
|---|---|
| 线路列表 | GET /api/lines:编号、名称、号码、信号、能否拨号、进行中的通话。全部来自缓存,回到前台随便刷,不花模组一条 AT 命令 |
| 所有线路的事件 | GET /api/events:一条 SSE 流,通话状态变化和新短信,每条带线路标识,外加心跳 |
| 通话音频 | 一条双向 WebSocket,二进制帧,8 kHz 单声道 16 位 |
| 录音 | 列表加支持断点的下载 |
| 后台来电 | VoIP 推送 |
这些接口全部写进了一份 OpenAPI 描述(72 个路径)。其中语音 WebSocket 的帧格式,之前只有那个处理函数自己知道——一个接口的格式如果只存在于代码里,它就还不算一个接口。
通话用 CallKit,而不是 App 内部自己画的界面。CallKit 是 iOS 给第三方通话应用的系统框架,用它的好处很实在:来电界面能出现在锁屏上,通话在离开 App 之后继续,按键音用系统键盘(这样自动语音菜单才好用)。
音频这边要在 8 kHz 和手机硬件的 48 kHz 之间转换,并保持一个 60 毫秒的抖动缓冲。为什么需要缓冲:音频走的是 TCP,到达是一阵一阵的,而扬声器消耗是匀速的,没有缓冲每次突发之间都会留下一个空洞。但缓冲又必须浅,因为这里加的每一毫秒延迟都会被直接听成通话延迟。
装起来
cd ios/VoHivePhone
xcodegen generate # 工程由配置生成,不手改工程文件
open VoHivePhone.xcodeproj # 选开发团队,连真机运行
首次打开在设置里填网关地址。输入框接受人类会输入的任何形式——192.168.1.20:7575、主机名、完整 URL——其余部分自动补全。
网关地址是整个 App 的”根”:改掉它意味着重新登录、丢掉旧网关的缓存、重连事件流。这类”改一个设置要连带做五件事”的地方,最好一次写对,否则会留下各种看不见的残留状态。
网关先要补两个洞
为了支持一台常连着的手机,网关这边先补了两样东西。
服务刚启动时,来电没人知道
原因:模组的来电回调属于一个控制对象,而这个对象只在有客户端主动查询通话状态时才被创建。刚启动的服务上根本没有回调,来电在有人来问之前不会被注意到。
这在网页后台时代不是问题——人打开页面就会开始轮询。但一台一直开着事件流的手机,不能依赖”有人来问”。
解决:启动时就为每个设备安装一个事件监视器,随设备增减自动调整。
两个消费者,各看到一半事件
原因:事件是一个共享 channel,而话机桥已经在读它了。两个读者各拿到大约一半的状态变化。
这和第三篇”语音丢一半”是完全一样的陷阱:Go 的 channel 一次只把一个值送给一个接收者。同一个 bug 在这个项目里出现了三次,形式各不相同。
解决:改成订阅模式,每个消费者一个独立 channel。投递是有损的——发送发生在模组的通知路径上,绝不能被一个慢读者阻塞。
验证:从一张国内卡给网关上的另一条线路发短信,经 Wi-Fi 通话链路到达,从事件流里出来:
{"type":"sms.received","line_id":"us","sms":{"sender":"+86138xxxxxxxx", ...}}
App 这边的坑
短信会话打开是空的
原因:URLComponents 在查询参数里保留字面量的 +,而服务端按表单规则把 + 解码成空格。于是 +86138… 变成了 86138…,前面多一个空格,什么都匹配不上。
用线上网关验证了一遍:字面量形式返回空数组,百分号编码形式返回消息。
解决:所有要放进查询参数的电话号码,统一在一处编码,而不是在每个调用点各写一遍。这类”每个调用点自己处理”的约定,迟早会有人漏掉一个。
第二通电话被拒绝,错误码 7
原因:错误码 7 是”通话组数量达到上限”。CallKit 的 Provider 只允许一个通话组,而一个没接通的拨号从未被报告为结束,永远占着那个组。
本地状态清了,CallKit 没被告知——两边只清一边,系统就会一直相信有一通根本不存在的电话正在进行。
解决:每一条失败路径都要向 CallKit 报告通话结束;发起新通话前先清掉残留;既没接通也没被拒绝的拨号设一个 45 秒的截止时间。
只清一边是这类系统集成里最常见的错误形状:状态存在两个地方,代码只更新了自己那份。
每通电话都会没声音
这个 bug 是读代码时按时序推出来的,不是在真机上撞到的。
原因:CallKit 在拨号动作完成后立刻交出音频会话,而这发生在音频引擎被创建之前。于是”音频会话已激活”的回调里,对着一个 nil 调用了启动方法,什么也没发生,之后也没有任何人再启动它。
解决:把”音频会话激活”和”引擎已创建”当成两个顺序不定的事件,两者都去调用同一个”条件满足就启动”的函数,后到的那个生效。引擎自身的保护让重复调用无害。
这类竞态值得强调一句:nil 上的可选调用不会崩溃,也不会报错,它只是什么都不做。Swift 里 try audio?.start() 这行代码,在 audio 为 nil 时是一个完全合法、完全静默的空操作。
App 在前台时,来电不响
原因:事件流明明告诉了 App”有电话在响”,App 什么都没做——报告来电的代码只写在推送回调里。
解决:前台收到的来电也报告给 CallKit。两个来源可能对同一通电话都触发,所以已经报告过的要跳过,否则屏幕上会出现两个来电界面。网关说通话结束时也要告诉 CallKit,否则来电界面会一直挂在那儿,而后面什么都不会发生。
第一次 TLS 握手就崩溃
原因:局域网网关用自签证书,需要在网络会话的代理方法里放行。而那个代理方法跑在网络库自己的队列上,代码从这里去访问主线程隔离的数据,触发了底层的队列断言,进程在第一次握手时直接挂掉。
解决:只对配置的那个主机放行证书,不是所有主机;持有例外的代理对象自己保存一份主机名快照,完全不去碰主线程的状态。
VoIP 推送的两条硬规则
这两条不遵守,后果都不是报错,而是被系统惩罚:
- 报告新来电必须在推送回调里同步调用。否则 iOS 直接杀掉进程,并且会减少后续推送的投递。
- 音频引擎必须在 CallKit 的”会话已激活”回调里启动,而不是拨号时。否则它会绑定到错误的音频路由,第一通电话没声音。
网关侧的推送
- 用证书认证的 APNs,HTTP/2,没有引入新依赖。
- 一个很安静的坑:Go 里一旦设置了
TLSClientConfig,自动 HTTP/2 就被关掉了,而 APNs 拒绝 HTTP/1.1。必须显式打开ForceAttemptHTTP2。这个错误不会告诉你原因。 - 沙盒还是生产环境由客户端上报,不靠猜。
- APNs 报告永久失效的令牌直接删除,不要在以后每通电话都重试一遍。
- 推送和事件流里的”振铃”由同一个状态转换触发,保证前台的手机和睡着的手机不会被告知不同的事。
- 有一个状态接口,推送失败时能看见,而不是静默。
推送需要自己的 Apple VoIP 证书,配置好之前保持关闭,App 只在前台连着事件流时响铃。
测试
单元测试直接解码真实的网关 JSON 数据。任何一侧改了字段名,都会在测试里失败,而不是在界面上显示一片空白。
这是客户端加服务端项目里最省事的一种测试:不需要 mock,不需要跑服务,就是把真实响应存下来喂给解码器。
这一篇的 bug 都长一个样
| 问题 | 根源 |
|---|---|
| 会话为空 | + 在查询参数里被解码成空格 |
| 错误码 7 | CallKit 和本地状态只清了一边 |
| 没声音 | 音频会话激活和引擎创建的竞态 |
| 前台不响 | 只有推送路径报告了来电 |
| TLS 崩溃 | 在网络库的队列上访问主线程状态 |
| 事件丢一半 | 共享 channel,多个消费者 |
六个里有四个是同一件事在两个地方各有一份状态,而代码只维护了一份。写客户端和系统框架打交道时,这个模式值得随时提防。
最后一篇:一台安卓平板,唯一的 USB-C 口插着 dongle,没有电脑、没有 root,怎么把它变成一部电话。
全系列
这个系列是个人学习和技术研究记录。请遵守所在地法律法规和运营商服务条款。