笔者之前用EC20和EG25模块配合Asterisk搭了蜂窝网关(《使用EC20模块配合asterisk及freepbx实现短信转发和网络电话》、《将DJI/Baiwang的增强图传模块接入Asterisk并用Telegram Bot收发短信》),又用Flexisip解决了SIP客户端在后台收不到来电的问题(《使用Flexisip和自编译Linphone为Asterisk/FreePBX添加SIP Push》)。这套系统里还缺一块:有些卡插在模块里就是用不好,最省事的办法是让一台普通的Android手机直接当网关。
在本文中,笔者在LLM(Claude)的协助下写了一个App:Voice SIP Agent。它让一台没有root的Android手机以trunk的身份注册到Asterisk/FreePBX/Issabel:蜂窝来电会转到PBX的分机上,分机拨号时则由手机经蜂窝网络外呼,双向语音、DTMF和挂断都会互相映射;与此同时,手机上的Google电话照常可用。本文主要介绍这个App转发通话的原理,以及开发的过程。
为什么又要折腾一个手机网关
4G模块做网关其实有很多优点:它不需要电池,不用定期解锁,也不存在后台保活的问题,插在小主机上可以常年无人值守。但用了几年下来,笔者也遇到了几个很难绕过去的问题:
- IMEI白名单:部分运营商(尤其是美国的运营商)只允许经过认证的机型使用VoLTE,模块的IMEI不在名单里,就算能上网也打不了电话。3G退网之后,没有VoLTE基本就等于不能通话。
- VoLTE/VoWiFi配置:模块里的运营商配置(MBN)很少,缺的时候只能像《从小米手机固件提取MBN,为Quectel EC20/EG25模块补充VoLTE运营商配置》那样自己从手机固件里找,而且不一定能成功。现代手机则由运营商直接下发配置,VoLTE、VoWiFi、eSIM都能被正确配置。
因此,笔者的需求是:
- 一台标准的Android手机(Pixel、moto等),不root,不刷机;
- 手机作为一条SIP trunk注册到现有的Issabel上,来电和去电都走PBX的路由;
- 不替换默认电话App,手机本身还能日常使用;
- 能经过之前搭好的Flexisip走公网(TLS + SRTP)。
笔者调研过的其他方案
蓝牙免提 + chan_mobile
Asterisk的chan_mobile可以把一台Linux设备伪装成蓝牙车载免提,通过HFP协议接听、拨号和收发音频,任何手机都支持,不需要在手机上装App。代价是音质受蓝牙HFP的编码限制,连接也容易受距离和蓝牙栈稳定性影响,而且手机的蓝牙会被一直占着。
运营商的呼叫转移
把电话无条件转移到一个VoIP号码上,是最省事的办法。但它只能处理来电,不能用这张卡外呼;转移的通话通常还要另外计费,主叫号码也可能丢失。对于美国的用户,倒是有一个很方便的选择——google fi webcall,可以做到类似google voice的效果,在web上接打电话,而且无需配对手机,算是完美实现了笔者在这几年有关SIP文章的一切需求。
需要root的App
GitHub上最接近的项目是gsm2sip,同样是Android上的蜂窝↔SIP网关,还能把短信转成SIP MESSAGE。但它需要Magisk + LineageOS,作为系统App安装,并且直接读写高通音频DSP的mixer路径,因此和具体机型的vendor镜像强绑定。
AirSIM
AirSIM是笔者最早看到的、不root就能中继手机通话语音的项目:它用Shizuku以shell身份读写通话PCM,把Android手机的通话转到iPhone或Apple Watch上。但它不接SIP,只能配对iPhone。笔者读了它的代码后受到启发。
Android自带的SIP
Android曾经内置过android.net.sip,但它在Android 12已被废弃,而且它本来就只是一个SIP客户端,从来没有桥接蜂窝通话的能力。
总之,笔者没有找到一个同时满足"不root、标准Android、接入自己的PBX、手机还能日常用"的方案,于是决定自己写一个。
原理一:为什么普通App碰不到通话音频
在现代手机上,通话语音基本不经过应用处理器上常规的音频通路。一通VoLTE电话的语音由基带(modem)编解码,再经由音频DSP直接接到听筒和麦克风:
对方 ◄──── 运营商网络 ────► 基带(modem)
│ telephony-rx / telephony-tx
▼
音频DSP(audio HAL)
┌─────┴──────┐
▼ ▲
听筒 麦克风
应用处理器上的AudioFlinger(App的AudioRecord/AudioTrack)
只有在audio HAL声明了额外路由时,才能接入这条通路
通话建立后,audio HAL会在设备之间建立一条"audio patch":telephony-rx接到听筒,麦克风接到telephony-tx。普通App用AudioRecord录音时只能录到麦克风;而VOICE_CALL、VOICE_DOWNLINK、VOICE_UPLINK这几个通话音源,audio policy要求调用方持有CALL_AUDIO_INTERCEPTION(出于历史原因,CAPTURE_AUDIO_OUTPUT也可以),两者都是系统级权限(AudioPolicyInterfaceImpl.cpp)。这也是为什么第三方的通话录音App长期只能靠录麦克风之类的变通办法。
那么手机上有没有"把通话音频引出来"的硬件通路呢?可以在audio policy里查看:
adb shell dumpsys media.audio_policy | grep -iE "telephony|in-call|incall"
笔者的Pixel 11 Pro上可以看到两条路由:
| 方向 | 路由 | 含义 |
|---|---|---|
| 下行(对方的声音) | telephony-rx → in-call-capture | 可以从通话里录出对方的声音 |
| 上行(送给对方的声音) | in-call-playback(INCALL_MUSIC)→ telephony-tx | 可以往通话里放音 |
另外,telephony-tx的混音规则里,除了in-call-playback,麦克风也是声源。也就是说,注入的声音默认会和手机麦克风收到的声音混在一起送给对方,这一点后面要专门处理。
原理二:Android 13的通话音频拦截API
有了硬件通路,还要有软件接口。Android 13(API 33)加入了一组通话音频拦截的系统API(SystemApi)。按照AOSP里的注释,它们用于通话筛选、通话音频重定向这类功能,只开放给持有特权权限的系统App(源码见AudioManager.java):
| API | 作用 | 源码 |
|---|---|---|
AudioManager.isPstnCallAudioInterceptable() | 这台设备的硬件是否支持拦截蜂窝通话音频 | AudioManager、AudioService |
AudioManager.getCallDownlinkExtractionAudioRecord(format) | 创建一个录取下行(对方声音)的AudioRecord | AudioManager |
AudioManager.getCallUplinkInjectionAudioTrack(format) | 创建一个向上行注入声音的AudioTrack | AudioManager |
AudioManager.CALL_REDIRECT_PSTN | 上面两个对象内部使用的"通话重定向"模式(隐藏常量) | AudioManager |
AudioManager.MODE_CALL_REDIRECT | 通话被重定向时使用的audio mode(公开常量,后文用来让手机自己静音) | AudioManager |
其中isPstnCallAudioInterceptable()在AudioService里的实现,其实就是检查audio policy里是否同时存在TELEPHONY_TX和TELEPHONY_RX这两个设备,也就是上一节看到的那两条路由。
这些接口需要CALL_AUDIO_INTERCEPTION权限(保护级别为signature|privileged|role,声明),只能在通话中(MODE_IN_CALL)创建,普通App拿不到。但shell用户(uid 2000)恰好持有它(见Shell的AndroidManifest.xml),同时还持有CAPTURE_AUDIO_OUTPUT、MODIFY_PHONE_STATE、MODIFY_AUDIO_ROUTING这几个后面要用到的权限:
adb shell dumpsys package com.android.shell | grep -E "CALL_AUDIO_INTERCEPTION|CAPTURE_AUDIO_OUTPUT|MODIFY_PHONE_STATE|MODIFY_AUDIO_ROUTING"
也就是说,只要能以shell的身份运行代码,就能用系统自己提供的接口读写通话语音,不需要root,也不需要碰厂商私有的mixer。这是整个App能成立的前提。
原理三:用Shizuku以shell身份读写通话音频
Shizuku是什么
Shizuku通过无线调试(或者一次adb连接)启动一个以shell用户运行的常驻进程,再把它的Binder分享给其他App。App可以注册一个UserService,让Shizuku以shell身份启动一个app_process来运行App自己的代码。因此,App里与特权相关的代码都跑在这个shell进程里,其余部分则在普通的App进程里:
┌──────────────────── App进程(普通app uid) ─────────────────────┐
│ SIP(pjsua2 / 自写协议栈) │
│ CallBridge:呼叫状态机,两边谁先接、谁先挂 │
│ 伴侣InCallService:接听、挂断、DTMF、静音、拨号 │
└──────────────────────────────┬───────────────────────────────────┘
│ Binder (AIDL) + socketpair(PCM,20 ms一帧)
┌──────────────────────────────┴───────────────────────────────────┐
│ Shizuku UserService(shell uid 2000) │
│ ├─ setup:appops、Telecom伴侣设置、Doze白名单 │
│ └─ CallAudioTap:下行AudioRecord → socket,socket → 上行AudioTrack │
└────────────────────────────────────────────────────────────────────┘
特权部分尽量少:shell进程里只有音频读写和几条cmd/appops设置,SIP、状态机和界面都在普通进程里。
PCM通过一对SOCK_SEQPACKET类型的socketpair交给App,一个包正好是一帧(8 kHz、单声道、16 bit、20 ms,即320字节),和G.711的帧长度天然对齐,不需要重采样。socketpair的一端通过Binder以ParcelFileDescriptor传给App,另一端留在shell进程里。相比在本机开一个TCP端口,这样做本机的其他App无法连上来窃听或注入通话音频。
不过真正写起来时,有两个坑花了笔者不少时间。
坑一:调用方身份(attribution)
getCallDownlinkExtractionAudioRecord这类辅助方法在内部new AudioRecord.Builder()时没有传入Context(源码),调用方身份取的是进程的身份。而在Shizuku的UserService里,进程的uid是2000,包名却是App自己的。AudioFlinger看到"uid 2000 + 非shell的包名"这种组合,会直接以invalid attr拒绝(AudioFlinger校验调用方身份的逻辑见AudioFlinger.cpp)。
解决办法是不用这几个辅助方法,而是自己构造一个归属于com.android.shell的Context,再显式地把它传给AudioRecord.Builder/AudioTrack.Builder:
private class ShellContext(base: Context) : ContextWrapper(base) {
override fun getPackageName() = "com.android.shell"
override fun getOpPackageName() = "com.android.shell"
override fun getAttributionSource(): AttributionSource =
AttributionSource.Builder(2000).setPackageName("com.android.shell").build()
}
坑二:音频属性必须和框架内部完全一致
自己构造AudioRecord/AudioTrack以后,又遇到了一个更隐蔽的问题:对象创建成功,帧计数每秒50帧,Telecom状态也一切正常,但对方听不到注入的声音,录到的也不是对方的声音。实际上,录音悄悄退回成了录麦克风,放音则被当成媒体从手机的扬声器放了出来。
原因是辅助方法内部除了设置CALL_REDIRECT_PSTN,还设置了特定的音频属性(上行、下行),少一个都不行:
// 下行:capture preset 必须是 VOICE_DOWNLINK(3)
val recAttr = AudioAttributes.Builder()
.also { setInternalCapturePreset(it, MediaRecorder.AudioSource.VOICE_DOWNLINK) } // 隐藏API,反射调用
.build()
// 上行:system usage 必须是 USAGE_CALL_ASSISTANT(17),content type 为 SPEECH
val trackAttr = AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.also { setSystemUsage(it, 17) } // 隐藏API,反射调用
.build()
// 两者都再调用 setCallRedirectionMode(CALL_REDIRECT_PSTN) // 隐藏API,反射调用
此问题在自动化测试时,从日志里几乎看不出来。最后是笔者在shell进程里加了每5秒一次的电平统计(只记录峰值和"有声帧"的数量,不记录音频内容),再加上真人拿着另一台手机听,才定位到问题。
以设备为准
这些隐藏API没有文档,网上的资料也很可能和当前系统版本对不上。cs.android.com上的AOSP源码(本文的链接都固定在写作时main分支的某个提交上)是很好的起点,但厂商的系统和AOSP的main分支不一定一致,所以笔者最后还是以手机上的实现为准,直接从手机里把框架拉出来反汇编:
adb pull /system/framework/framework.jar
adb pull /system/framework/services.jar
unzip framework.jar 'classes*.dex' -d framework
$ANDROID_SDK/build-tools/37.0.0/dexdump -d framework/classes*.dex > framework.txt
# 在 framework.txt 中查找 getCallDownlinkExtractionAudioRecord、AudioService.setMode 等方法
上面的属性组合就是这样从AudioManager.getCallDownlinkExtractionAudioRecord的字节码里读出来的。
原理四:不当默认电话App也能控制通话
光有音频还不够,网关还要能接听、挂断、发DTMF,以及拨出电话。Android上能拿到完整Call对象的途径是InCallService,最常见的做法是把App设为默认电话App(AirSIM就是这样做的)。但这样一来,所有通话的界面都会交给这个App,Google电话的来电界面、来电筛选、通话录音等都会失效,手机就没法日常使用了。
好在Telecom还有一种"伴侣(companion)“InCallService,本来是给智能手表这类设备用的:它和默认电话App一样能拿到Call对象,但不接管通话界面。要让Telecom绑定一个第三方的伴侣App,需要三样东西:
| 条件 | 级别 | 获取方式 |
|---|---|---|
CALL_COMPANION_APP | 普通权限(Android 9起) | 在Manifest里声明 |
MANAGE_ONGOING_CALLS | 签名或appop(Android 12起) | shell执行appops set <包名> MANAGE_ONGOING_CALLS allow |
| Telecom伴侣覆盖 | Telecom的测试用shell命令(TelecomShellCommand.java) | shell执行cmd telecom add-or-remove-call-companion-app <包名> 1 |
笔者最早做探针测试时只声明了第一项,结果Telecom在通话中检查MANAGE_ONGOING_CALLS时(InCallController.java)把测试App跳过了(日志里同样被跳过的还有手表的伴侣App)。三项都满足之后,测试App就能在不当默认电话的情况下接听、静音、发DTMF和挂断了。
这里有几个细节:
- 伴侣覆盖在重启或Telecom进程重启后会丢失:从源码看,它只是Telecom进程内存里的一个
ArrayList(RoleManagerAdapterImpl.java)。所以App每次启动都会重新检查,并且每60秒做一次健康检查。 - 也因为它是一个普通的列表,重复添加会出现重复项,删除一次也只删一项,所以设置时要先数清楚已有几项。
- 只接管自己的电话:只有网关来电和网关自己拨出的电话(按号码后8位匹配)会被处理;用户自己手动拨的电话,以及已经有通话时进来的第二路来电,都会交给系统正常处理。
- 紧急号码一律拒绝,不会从SIP侧拨出。
- 拨出使用的是公开的
TelecomManager.placeCall(),接听、挂断、DTMF用的都是Call对象上的公开方法。这部分API从Android 6起就基本没有变过,是整个App里最稳定的部分。
让手机自己保持安静
网关桥接通话时,手机本身既不应该把周围的声音送给对方,也不应该从听筒里放出对方的声音。这需要两步:
InCallService.setMuted(true):前面提到,telephony-tx会混入麦克风的声音。实测静音后,麦克风会被完全移出上行(笔者在Pixel上录下上行,静音期间的RMS为1),而注入的声音不受影响,这正是网关需要的效果。- 把audio mode从
MODE_IN_CALL切到MODE_CALL_REDIRECT:audio policy会因此拆掉基带到听筒/麦克风的audio patch,但重定向的AudioRecord/AudioTrack继续工作,所以手机听筒里不会再有声音。AudioService只允许持有MODIFY_PHONE_STATE的调用方请求这个mode(AudioService.setMode),shell恰好持有,它较新的mode请求会盖过Telecom的IN_CALL;通话结束后把mode设回NORMAL即可撤回请求。AudioService还会给每个请求mode的进程注册一个死亡回调(SetModeDeathHandler),如果shell进程意外退出,它的请求会被自动撤回,手机不会被卡在这个模式里。
AirSIM用的是cmd audio adj-mute来静音听筒,但这个命令在Android 17上已经不存在了。
原理五:呼叫流程与时钟
呼叫流程
蜂窝来电 → SIP:
对方来电 ──► 手机响铃(Telecom: RINGING)
│ 网关向PBX发 INVITE sip:<DID>@pbx,主叫号码放在 P-Asserted-Identity
▼
PBX把电话送到分机,分机振铃 ◄── 180
│
分机接听 ◄── 200 OK
│ 此时才 answer() 接起蜂窝来电
▼
ACTIVE → 静音 → 打开音频tap → 双向桥接
先等PBX那边接听,再接起蜂窝来电。这样如果PBX没人接(默认45秒超时)或者返回错误,手机就直接拒接,对方不会被"接通后没人说话”,也不会产生通话费用。
SIP → 蜂窝去电:
PBX把INVITE送给手机,手机调用placeCall拨号,进入DIALING后回180,进入ACTIVE后才回200 OK。笔者在探针阶段专门测过这个时序:被叫响铃后等几秒再接,Telecom在拨号后约15.6秒才进入ACTIVE,和真实的接听时间一致,所以ACTIVE可以作为对方接听的依据。另外,拨号和振铃期间下行全是静音,Pixel拿不到运营商的回铃音,所以回铃音由Asterisk在本地生成。
单一时钟域
桥接音频时有两个时钟:基带按自己的节奏每20 ms产生一帧下行,RTP这边对端也按自己的时钟发包。如果两边各用一个定时器,时间长了就会出现漂移:缓冲区要么越积越多(延迟变大),要么被读空(断音)。
笔者的做法是只用一个时钟:把通话音频tap标记为时钟源,每从基带读到一帧下行,就同时完成"下行→SIP"和"SIP→上行"两个方向的一次搬运;RTP侧没有自己的时钟,靠抖动缓冲吸收对端的漂移。实测15分钟的长通话没有出现可感知的延迟增长或断音。
SIP侧:从自写协议栈到pjsua2和Flexisip
以trunk而不是分机的身份接入
手机在PBX上被配置成一条trunk,而不是一个分机:
- 来电进入
from-trunk上下文,按Inbound Route处理,不具备内部分机的权限; trust_id_inbound=yes,PBX会采信网关通过P-Asserted-Identity传来的主叫号码;remove_existing=yes,手机换了IP或端口之后,新的注册可以立即顶掉旧的;- 去电通过一个专用的Outbound Route前缀(笔者用的是
9#7#)送到这条trunk。
完整的pjsip_custom_post.conf和拨号规则可以参考项目README。
先自己写一个,再换成pjsua2
最开始只考虑局域网,笔者让LLM用纯Kotlin写了一个最小的SIP UA:UDP、事务层、Digest认证、REGISTER、INVITE/CANCEL/BYE/re-INVITE、SDP、RTP + 抖动缓冲、G.711和RFC 4733 DTMF。它不依赖Android,可以直接在电脑上跑单元测试,调试起来很快。
但要走公网的TLS + SRTP,自己写就不划算了,于是又加了一个基于pjsip 2.17的pjsua2实现,现在是默认选项。两个实现都实现同一个CallEndpoint接口,可以在设置里切换。和pjsua2集成时的几个要点:
- 不编译任何Android声卡后端,只保留空声卡。这样pjsua永远不会去碰
AudioManager或修改audio mode,不会和前面的通话重定向打架。 - 音频通过一个自定义的
AudioMediaPort进出pjsua的会议桥(8 kHz/20 ms,不重采样,关掉回声消除和VAD),两侧各用一个最多5帧的FIFO吸收两个时钟之间的抖动。 - OpenSSL 3.5.8静态链接进
libpjsua2.so,TLS用Android系统的CA库验证证书。
经Flexisip走公网
公网部分直接沿用了上一篇文章里搭好的Flexisip:手机以TLS注册到公网VPS上的Flexisip,Flexisip再经WireGuard把请求转给Issabel。这里遇到了两个问题:
- 通配符证书:笔者的Flexisip用的是
*.sparktour.me的通配证书,而pjsip按RFC 5922的要求拒绝通配证书。于是笔者给pjsip打了一个小补丁,按RFC 6125的规则只接受最左一级的*.匹配。 - Request-URI会被Flexisip用来查注册表:局域网直连时,PBX可以直接把要拨的号码写在Request-URI里(
PJSIP/<号码>@trunk)。但Flexisip是按Request-URI的user部分查注册表来路由的,号码显然不是一个注册过的用户,结果就是404。解决办法是让PBX的Request-URI保持为网关自己的AoR,号码改放在一个自定义头X-VSA-Dial里:
; extensions_custom.conf
[vsa-android-gw-out]
exten => _X.,1,Dial(PJSIP/android-gw,,b(vsa-android-gw-hdr^s^1(${EXTEN})))
exten => _X.,n,Hangup(${HANGUPCAUSE})
[vsa-android-gw-hdr]
exten => s,1,Set(PJSIP_HEADER(add,X-VSA-Dial)=${ARG1})
exten => s,n,Return()
在Issabel中再新建一条Custom trunk,拨号串为Local/$OUTNUM$@vsa-android-gw-out即可。除此之外,Flexisip不需要为网关做任何专门的配置:REGISTER交给Asterisk鉴权,来电沿着手机主动建立的TLS连接送达,手机不需要开放任何端口。SRTP使用SDES,在手机和Asterisk之间端到端加密。
附录:和LLM一起开发的过程
这个项目的代码几乎都是在Claude Code里和LLM一起写的,从读AirSIM的代码到发布第一个release,前后不到两天。笔者觉得过程本身也值得记录:
第一步:先回答"能不能做"
在写任何App代码之前,笔者先让LLM读AirSIM的代码,弄清楚它的音频中继和通话控制是怎么实现的,再判断这些方法能不能在Pixel这样的标准Android上用。LLM给出的判断很谨慎:控制面基本没问题,下行采集大概率可行,上行注入是最大的不确定点,必须在真机上测。
于是验证分成了两个阶段:
- 不打电话的静态检查:用adb查看audio policy里有没有
telephony-tx/rx路由、shell持有哪些权限、isPstnCallAudioInterceptable()的返回值。adb shell本身就是shell身份,和Shizuku的权限完全一样,所以这个阶段连App都不用装,探针程序直接用app_process跑。 - 真实通话测试:笔者用另一台手机给Pixel打电话,LLM事先写好一个按秒计时的脚本,在不同时间段用不同的方法注入不同频率的滴声(400/600/800 Hz),同时只打印下行的RMS和峰值电平。笔者在另一台手机上一边听一边说话,结束后告诉它听到了什么。
前后一共打了五通电话加两次去电:第一通确认三种滴声都能听到(上行可行);第二通发现测试App没有被Telecom绑定,从日志里找到了缺MANAGE_ONGOING_CALLS这一条;第三通App绑定成功,但控制指令一条都没送到,原因是测试脚本发广播时没有指定接收者;第四通第一次在不当默认电话的情况下完成了自动接听、静音、DTMF和挂断;第五通为了不依赖耳朵判断,直接在Pixel上录下送给运营商的上行,确认静音会把麦克风完全移出、注入的声音不受影响。最后两次去电则确认了ACTIVE的时机。
这些结论都写进了一份设计文档,包括架构、状态机、DisconnectCause到SIP错误码的映射、开放问题,以及M0到M5的里程碑和验收标准。下一个session再照着文档开发。
第二步:按里程碑开发
开发阶段大致是这样的:
- 工程骨架、Shizuku UserService、伴侣InCallService;
- 音频tap加一个回声模式:蜂窝来电直接把下行原样送回上行,打进来的人能听到自己的回声,用来单独验证音频通路;
- 自写SIP栈接入局域网的Issabel,来电转到分机302,分机拨
9#7#号码外呼; - 修前面提到的两个音频坑、加上"接管通话"总开关和本机静音;
- pjsua2、TLS/SRTP、Flexisip;
- 开源前的整理:清理git历史、加签名、选许可证(pjsip是GPL-2.0-or-later,加上Apache-2.0的OpenSSL,整体只能用GPL-3.0)、写README。
一些体会
- LLM非常擅长的部分:读一个陌生的代码库并抽出它的核心逻辑;照着RFC写SIP协议栈和状态机;写NDK/SWIG/OpenSSL的交叉编译脚本;反汇编系统框架去找隐藏API的真实用法;写单元测试。这些放在以前,每一项都要花笔者好几个晚上。
- 必须由人来做的部分:打电话、接电话、听声音、说话。前面的坑二就是一个典型例子:所有日志和计数器都显示正常,只有人耳能听出来声音走错了地方。另外,许可证怎么选、PBX和Flexisip的配置要不要改,这些决定也都应该由人来做。笔者要求LLM每次改PBX之前都先说明要改什么并备份,得到同意后再动手。
- 先探针、后开发:比起直接让LLM"写一个网关",先让它写探针、用真机把关键假设逐条验证掉,再写设计文档,最后才写代码,返工要少得多。每个结论都要有日志或真机证据,而不是"根据API文档应该可以"。
实际效果与局限
笔者目前在Pixel 11 Pro(Android 17,Verizon/T-Mobile VoLTE)和一台moto X40(Android 16)上进行了测试,验证过的功能如下:
| 功能 | 状态 |
|---|---|
| 蜂窝来电 → PBX分机,分机拨号 → 手机外呼 | ✅ |
| 双向语音、DTMF(RFC 4733) | ✅ |
| 桥接期间手机听筒和麦克风静音 | ✅ |
| Wi-Fi断线重连(包括通话中) | ✅ |
| Doze一整晚待机 | ✅ |
| 15分钟长通话无时钟漂移 | ✅ |
| 局域网UDP、经Flexisip的公网TLS + SRTP | ✅ |
| 通话等待、pjsua2下的DTMF与断网重连、仅用蜂窝数据的公网通话 | 尚未验证 |
目前还有一些局限:
- 重启后需要手动启动Shizuku:重启后手机处于未解锁(BFU)状态,非root的Shizuku无法自动启动,需要解锁后手动启动一次。这是相比4G模块最大的劣势,也是手机网关绕不开的问题。
- 依赖隐藏API:反射调用的隐藏方法、
ShellContext的写法、Telecom的测试用shell命令,都可能在下一个Android版本里改变。官方的通话音频拦截API虽然是SystemApi,但硬件是否支持仍然由厂商决定,其他机型需要先用isPstnCallAudioInterceptable()检查。 - 同一时间只有一路通话,SIP侧只支持G.711,暂不支持短信。
安装和配置的步骤见README,简单来说就是:安装并启动Shizuku → 从Releases安装APK → 在App里授权Shizuku → 填写PBX的地址、端口、传输方式和trunk凭据。
总结
这个App能做成,主要靠三件事:Android 13起系统提供了通话音频拦截的接口,shell用户恰好持有它所需的权限,而Shizuku让App可以在不root的情况下以shell身份运行代码。再加上Telecom本来给手表准备的伴侣InCallService,就可以在不替换默认电话App的情况下控制通话。剩下的工作,比如SIP、时钟、经Flexisip走公网,都是比较常规的VoIP问题。
和4G模块相比,手机网关的优势是能正确地使用VoLTE/VoWiFi,也不用担心IMEI白名单;劣势是需要电池,重启后要手动启动Shizuku,还依赖未公开的系统行为。两者各有适合的场景,读者可以根据自己的需求,选择最适合自己的方案。
