达是酒中趣,琴上偶然音

让 iPhone 拨打一张不在 iPhone 里的 SIM 卡(六)

· Technology

上一篇:插在 Mac 上 | 下一篇:安卓平板直接驱动

前面几篇的成果是一台树莓派网关:上面插着几张 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 推送的两条硬规则

这两条不遵守,后果都不是报错,而是被系统惩罚:

网关侧的推送

推送需要自己的 Apple VoIP 证书,配置好之前保持关闭,App 只在前台连着事件流时响铃。

测试

单元测试直接解码真实的网关 JSON 数据。任何一侧改了字段名,都会在测试里失败,而不是在界面上显示一片空白。

这是客户端加服务端项目里最省事的一种测试:不需要 mock,不需要跑服务,就是把真实响应存下来喂给解码器。

这一篇的 bug 都长一个样

问题根源
会话为空+ 在查询参数里被解码成空格
错误码 7CallKit 和本地状态只清了一边
没声音音频会话激活和引擎创建的竞态
前台不响只有推送路径报告了来电
TLS 崩溃在网络库的队列上访问主线程状态
事件丢一半共享 channel,多个消费者

六个里有四个是同一件事在两个地方各有一份状态,而代码只维护了一份。写客户端和系统框架打交道时,这个模式值得随时提防。

最后一篇:一台安卓平板,唯一的 USB-C 口插着 dongle,没有电脑、没有 root,怎么把它变成一部电话。

全系列

  1. 它到底是什么
  2. 树莓派上的短信与 eSIM 网关
  3. 从零实现 VoWiFi
  4. USB 话机、浏览器通话与 ESP32
  5. 插在 Mac 上
  6. iPhone 客户端
  7. 安卓平板直接驱动

这个系列是个人学习和技术研究记录。请遵守所在地法律法规和运营商服务条款。