Android通话信令监听与录音方案:TelephonyCallback+MediaProjection实战

Android通话信令监听与录音方案:TelephonyCallback+MediaProjection实战 简介一份面向Android中高级开发者的电话信令与通话声音实时提取示例程序基于应用逆向工程与网络抓包得到的逻辑模拟智能拨号器调用流程。实测支持手机连接、向外拨打电话、来电事件处理、拒接与挂断通话声音可先保存为本地WAV文件。资源共83个文件以Java源码、XML配置为主并包含Gradle工程文件、属性配置、so库及一个WAV音频样例整体体积仅601KB适合直接导入Android Studio查看与调试。目前已有497人学习下载适合正在研究手机通信层交互或需要快速搭建信令截获原型的技术人员。压缩包内还附带readme说明结合作者总结篇可了解最终解决方案的思考过程。 先说结论。普通Android应用能实时拿到的“信令”是Telephony框架翻译后的呼叫状态事件不是基带Modem空口上真正跑的3GPP协议信令声音这块在不root、不改系统、不碰系统签名的前提下能稳定做的是麦克风采集你的语音加MediaProjection捕获系统媒体音频。想完整录下传统电话对端的声音普通SDK做不到——这是平台安全边界不是代码水平问题。这篇文章就围绕“SIM卡打电话的信令声音实时提取”这个需求给出一套能跑通的示例程序用TelephonyCallback监听呼叫状态机用AudioRecord录麦克风用MediaProjection加AudioPlaybackCapture抓屏幕和媒体音频再把信令事件和音频数据按同一时间轴落到本地。适合想做通话记录工具、呼叫状态监控、自动化测试脚本的人参考Android 9到Android 14的适配差异我放在最后单说。1. 先拆清楚SIM卡通话的“信令”在Android上到底暴露到哪一层很多初学者一上来就找“开机自启”“底层抓包”的偏方其实卡在了概念混淆上。Android手机的处理器至少分两块AP应用处理器和BP基带处理器。SIM卡、射频收发、空口协议栈都在BP侧Android系统本身跑在AP侧普通App连BP的边都摸不到。1.1 基带信令与应用层事件的边界SIM卡和基站之间握手、鉴权、分配信道、建立RRC连接、发送NAS消息这些全部发生在Modem固件里。Android把Modem上报的状态翻译成Java层可读的API翻译通道就是RILRadio Interface Layer。App能拿到的只有RIL“翻译”出来的结果比如呼叫状态、信号强度、网络注册状态。真正想抓空口信令只有三条路一是工程机加QC Diag或MTK Catcher这类专用工具二是实验室里用CMW500这类综测仪做非信令测试三是自己写固件或者用调试口。这三条路都跟普通App开发者没关系。所以别再把“App提取信令”理解成能看到Layer3消息看不到的。打个比方基带是发电厂RIL是小区配电房App是房间墙上的插座。你只能从插座取电别指望徒手摸到高压母线。想摸母线可以你得先成为电工系统应用签名或者拿到总闸钥匙root。1.2 普通应用能拿到的“信令类”数据清单能在用户态拿到的是下面这个清单里的东西全是事件、状态、数值不是原始报文数据项获取方式版本限制说明呼叫状态TelephonyCallback / PhoneStateListener无空闲、来电、通话中三态是核心来电号码onCallStateChanged的phoneNumberAndroid 9受限非默认拨号器经常拿不到网络制式注册状态ServiceStateAndroid 7有行为变化可判断2G/3G/4G/5G注册信号强度SignalStrength无可记录电平值辅助定位漫游状态ServiceState无roam标志SIM卡基础信息TelephonyManagerAndroid 10高度受限IMSI/ICCID基本不可见这里特别提醒不要再写getSimSerialNumber这类代码Android 10之后对非系统应用返回空值这是合规收紧的结果不是代码问题。能用的是getSubscriberId做设备识别但现在也基本拿不到除非你是运营商合作应用。2. 信令监听核心TelephonyCallback状态机设计与代码实现呼叫信令在应用层的直观体现就是“状态机”IDLE空闲→ RINGING来电→ OFFHOOK接通/去电→ IDLE挂断。把这四个状态实时抓到你就能还原一次完整通话的骨架。2.1 为什么用TelephonyCallback替代PhoneStateListenerAndroid 12API 31开始PhoneStateListener和TelephonyManager.listen()这套老API被正式标记废弃官方推荐TelephonyCallback。TelephonyCallback不再用位掩码注册监听器而是直接用对象实现对应的Listener接口代码更清晰也不会出现“忘了传掩码导致监听失效”这种低级问题。如果你要兼容Android 7到Android 11的设备还是得保留旧方式。我的做法是用SDK_INT判断API 31以上走TelephonyCallback以下走PhoneStateListener两套逻辑共用同一个事件处理方法。别嫌麻烦现在市面上的设备跨度就这么大。2.2 完整状态机Service示例代码实际项目里我建议把监听放在Service里因为通话期间Activity随时可能被杀。以下是一个精简可用的Kotlin实现class CallEventMonitorService : Service() { private lateinit var telephonyManager: TelephonyManager private var legacyListener: PhoneStateListener? null private var callback: TelephonyCallback? null private var lastState TelephonyManager.CALL_STATE_IDLE private val eventHandler { state: Int, phoneNumber: String? - if (state lastState) returnlet val event CallEvent( timestamp System.currentTimeMillis(), oldState lastState, newState state, phoneNumber phoneNumber ) lastState state // 这里建议写文件或发广播不要只打日志 Log.d(CallSignal, event.toString()) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(NOTIFICATION_ID, buildNotification()) telephonyManager getSystemService(TELEPHONY_SERVICE) as TelephonyManager if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { callback object : TelephonyCallback(), TelephonyCallback.CallStateListener { override fun onCallStateChanged(state: Int, phoneNumber: String?) { eventHandler(state, phoneNumber) } } telephonyManager.registerTelephonyCallback(mainExecutor, callback) } else { Suppress(DEPRECATION) legacyListener object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { eventHandler(state, phoneNumber) } } telephonyManager.listen(legacyListener, PhoneStateListener.LISTEN_CALL_STATE) } return START_STICKY } override fun onDestroy() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { telephonyManager.unregisterTelephonyCallback(callback) } else { telephonyManager.listen(legacyListener, PhoneStateListener.LISTEN_NONE) } super.onDestroy() } override fun onBind(intent: Intent?) null }注意几个关键点startForeground必须第一时间调用否则后台启动Service直接崩onCallStateChanged的state要自己做防抖系统有时候会连续上报相同状态挂断后一定要把lastState复位成IDLE否则下一次通话状态机是错的。2.3 来电号码与漫游等扩展事件怎么处理Android 9之后onCallStateChanged里的phoneNumber参数对非默认拨号器基本返回空。不要在号码上死磕把RINGING事件本身当成信号就好。如果产品上确实需要号码我的思路是拿AccessibilityService去读系统电话应用的“来电卡片”文本但这需要用户开启无障碍服务且触发用户隐私风险务必只在用户明确授权的场景里做不建议个人开发者引入。除呼叫状态外建议同时订阅ServiceState和SignalStrengths。这两个事件不需要额外权限配合呼叫状态一起记录能还原“当时在哪个基站信号下打的电话”对掉话排查很有用。TelephonyCallback里实现对应Listener接口即可注册方式和CallStateListener一样。3. 声音提取的合规路线MediaProjection与AudioPlaybackCapture组合声音部分要比信令敏感得多先说清楚能做什么、不能做什么再给代码。3.1 为什么不要碰VOICE_CALL和root方案Android的AudioRecord里有几个特殊的音频源VOICE_CALL、VOICE_DOWNLINK、VOICE_UPLINK。看着名字好像就是为通话录音准备的实际上这些音频源只对系统应用开放普通应用用了要么报错要么录到的全是静音。它们需要CAPTURE_AUDIO_OUTPUT权限这个权限的protectionLevel是signature|privileged普通应用永远申请不到。root或者Xposed模块确实可以绕过但这会引发三连击Play Integrity检查失败银行类App直接罢工隐私风险全部转嫁到自己头上Android版本一升模块随时失效。个人项目玩玩还行做成产品就是埋雷。我的观点很直白合规场景下能拿到的声音就两个——麦克风采集的这侧语音以及系统允许被捕获的媒体音频。3.2 录音模块的授权流程与启动停止策略麦克风授权走常规RECORD_AUDIO运行时权限但这只是开始。如果你想同时抓屏幕或者捕获系统媒体音频就必须用MediaProjection它会弹一个“是否允许录屏/投屏”的系统对话框用户拒绝就什么都干不了。流程是透明Activity发起createScreenCaptureIntent拿到resultCode和data再通过MediaProjectionManager取实例最后把实例传给Service。用AndroidX Activity Result API写起来很干净class ProjectionRequestActivity : ComponentActivity() { private val launcher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode RESULT_OK result.data ! null) { val manager getSystemService(MediaProjectionManager::class.java) val projection manager.getMediaProjection(result.resultCode, result.data!!) val intent Intent(this, SoundMonitorService::class.java) startForegroundService(intent) } finish() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val manager getSystemService(MediaProjectionManager::class.java) launcher.launch(manager.createScreenCaptureIntent()) } }注意MediaProjection实例不能直接Parcelable传给Service要用别的方式共享比如放在全局单例或者通过投影回调绑定。真实项目里我推荐让Service自己持有一个“外部注入”的入口Activity通过Binder把MediaProjection喂进去。3.3 与信令状态机联动的音频采集示例一个重要结论AudioPlaybackCapture只能捕获走AudioTrack的媒体音频。传统电话和VoLTE的通话对端语音走的是电话音频路径不在捕获范围内所以这部分注定录不到。但VoIP应用微信、钉钉的音频通常走USAGE_VOICE_COMMUNICATION或USAGE_MEDIA多数情况下能被AudioPlaybackCapture捕获这取决于应用和厂商实现。麦克风采集用AudioRecord在OFFHOOK时启动IDLE时停止这样不需要一直录class MicRecorder(private val outputFile: File) { private var audioRecord: AudioRecord? null private var recording false fun start() { val sampleRate 16000 val minBuf AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) audioRecord AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBuf * 2 ) audioRecord?.startRecording() recording true Thread { val buf ByteArray(minBuf) val fos FileOutputStream(outputFile) while (recording) { val read audioRecord?.read(buf, 0, buf.size) ?: 0 if (read 0) { fos.write(buf, 0, read) } } fos.close() }.start() } fun stop() { recording false audioRecord?.stop() audioRecord?.release() audioRecord null } }这段代码的关键是setAudioSource(MIC)不要因为想录对端就改成VOICE_CALL那个在普通权限下直接失败。另外采样率我用16kHz因为通话语音本身的窄带频域就是300-3400Hz16kHz足够而且PCM文件体积小一半。PCM直接落盘后续可以用ffmpeg转wav或者做语音分析不着急压成mp4。4. 最终方案落地Service 前台通知 权限配置清单把前两章拼起来的完整架构是这样的一个前台Service常驻内部跑TelephonyCallback负责信令事件一个MicRecorder负责麦克风采集一个MediaProjection实例负责屏幕和系统音频捕获所有数据带时间戳写入独立文件。这样你在复盘一次通话时能看到“哪个时间点来电→哪个时间点接通→当时信号多少→这侧说了什么”。4.1 AndroidManifest配置与运行时权限处理Android 14API 34对前台服务类型的限制比之前严格很多同时申请麦克风权限和服务类型缺一不可uses-permission android:nameandroid.permission.READ_PHONE_STATE / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION / application service android:name.CallEventMonitorService android:exportedfalse android:foregroundServiceTypephoneCall|microphone|mediaProjection / /application注意foregroundServiceType里的phoneCall类型需要系统电话权限普通应用声明了会影响启动。我实测的结论是注册服务时只写microphone和mediaProjection就够了CallState是权限敏感事件但不是电话服务不强制要求phoneCall类型。这里需要按自己的目标平台不断调整别照抄。4.2 前台服务类型与Android 14的限制Android 14强制要求如果前台服务要访问麦克风必须在启动服务前就拿到RECORD_AUDIO权限并且服务声明里带microphone类型否则启动时抛ForegroundServiceStartNotAllowedException或SecurityException。别指望先启动服务再申请权限顺序反了必崩。MediaProjection也有自己的约束Android 14上使用MediaProjection的前台服务必须声明mediaProjection类型。而且系统的“投屏录制”授权是一次性的用户取消或应用被杀后下一次还要重新弹框授权。这点务实在UI里引导用户不然用户以为坏了。4.3 两路数据的同步与本地落盘信令和音频来自不同的线程回放时最怕对不上时间轴。我的做法是统一用SystemClock.elapsedRealtime()打时间戳因为这个时间从开机开始单调递增不受NTP校时和应用改时间影响。信令事件写一行CSV附带elapsedRealtime和wallTime音频块写入PCM的时候在文件头单独记一个startElapsedRealtime字段。回放时用时间差对齐即可。本地落盘推荐用应用私有目录下的一个会话文件夹比如files/call_logs/20250120_103000/里面放events.csv、mic.pcm、screen.mp4。不要直接写公共存储一是Android 11起分区存储限制多二是会话数据属于隐私内容私有目录更安全。调试时导出到电脑用ffprobe和Python一打开就能核对时间戳是否对齐。5. 实测中的坑与边界从Android 9到Android 14的行为差异最后写几个我实测过的坑按Android版本排开省得你从9到14每个版本都踩一遍。5.1 号码获取从Android 9开始被收紧Android 9之前READ_PHONE_STATE权限能让你拿到来电号码Android 9之后只有默认拨号器或者系统应用onCallStateChanged里的phoneNumber才会返回实际号码其他情况返回空。这个不是你在清单里多加权限能解决的。Android 10之后IMSI、ICCID这类SIM标识也被彻底锁死。如果业务上必须识别用户别往SIM卡方向想换设备唯一标识、或者走登录体系更靠谱。5.2 后台启动Service限制Android 8开始禁止后台直接startServiceAndroid 12开始后台启动前台服务也加了时间窗口限制应用在后台基本只能靠系统允许的白名单场景拉起。实测中如果你的应用没有在前台电量优化又是默认状态来电时Service可能根本不会被系统拉起来。解决方案是在应用还处于前台时就启动监听服务并且让用户把应用加入电池优化白名单。这个白名单引导没法在代码里强制执行只能通过Intent跳到系统设置页让用户手动开页面文案写清楚目的转化率才会高。5.3 SELinux与麦克风授权等诡异问题Android的SELinux是enforcing模式普通应用只会被允许访问自己申请的接口。麦克风数据流的节点、音频HAL服务的策略都已经被SELinux规则限制好了没有RECORD_AUDIO权限时连打开AudioRecord的调用都会被拒绝。这不是Java层的异常是内核层面的权限判断。所以别研究什么“隐藏API绕过”在SELinux面前都是白费。国产ROM还有自己的小动作就算权限全给了后台超过几分钟系统就可能把Service杀掉。实测最有效的组合是“前台服务关闭电池优化用户手动加白名单”。如果还不能常驻看看是不是开了“智能省电”或者“纯净后台”把这些开关关掉。5.4 合规底线与使用场景建议通话录音和信令数据都属于高敏感数据不管技术怎么实现有几个底线不要碰录别人的通话要先获得授权尤其是通话对方不要做监听类的工具现在应用商店对通话录音类的审核极严不要用root方案做产品分发安全性完全不可控。这套示例程序最合适的场景是开发者自己手机上的调试工具、售后环节里用户主动授权的通话质量回溯、以及自动化测试时的呼叫状态断言。把这些场景做透了已经很有价值不需要去踩平台红线。实际把这套工程跑起来后我最大体会是Android给开发者提供的是一套“装好安全阀的水管”你只能在水池里接水拧不开墙里的主水管。与其花大力气找绕过方案不如把能接的水接好——信令事件、这侧语音、屏幕画面按同一时间轴对齐后能解决的问题远比想象中多。如果哪天真的需要完整双向通话录音正路就两条申请系统签名应用或者外接硬件设备。认清边界方案才真正落地得了。本文还有配套的精品资源点击获取