
简介安卓端实时提取SIM卡打电话信令与声音的示例程序面向移动通信逆向、网络抓包及安卓应用开发人员。工程依据《总结篇》中的最终解决方案将应用逆向工程和网络抓包所得逻辑落地为可运行的智能拨号器调用逻辑经实测可完成手机连接、外拨电话、通话声音保存为本地wav音频文件以及来电、拒接、挂断等事件回调通话质量基本可用与电脑版C#方案效果对等。压缩包内共83个文件以Java源码和XML配置为核心辅以Gradle构建脚本、properties配置、so动态库及jar包构成可直接导入开发环境的完整安卓工程压缩后约601KB目录结构清晰主工程与依赖库分离便于对照学习关键调用链。已有508位开发者学习下载通过该示例可快速掌握从设备连接、呼叫建立到音频回传的关键通路减少重复逆向成本适合需要研究安卓语音信令提取或参考真实拨号器实现的开发者。1. Android 实时提取 SIM 卡信令与通话声音这套方案把两条链路拧成一根时间轴做终端测试的人最头疼的一件事通话进行中信令和声音是分开的。信令藏在 modem 日志里声音在音频子系统里两个时间轴对不上问题就没法定位。这份资源给的是完整落地方案——在 Android 手机里同时抓 SIM 卡相关的通话信令和通话 PCM 声音附可编译运行的示例程序覆盖从权限配置到离线对齐的完整链路。它适合做网络调试、功耗测试、以及需要精确还原一次通话全过程的开发者。代码和脚本我都拆过一遍下面按「链路怎么走 → 代码怎么写 → 坑怎么避 → 数据怎么验证」的顺序讲透。2. 信令从哪条链路来RIL 分层结构与双通道采集的选型依据2.1 RIL 分层信令在 Android 系统的完整路径Android 的通话信令不是应用层产生的它有一条完整的硬件到软件链路。基站和手机基带芯片之间走的是空口信令GSM 网络上是 L3 层的呼叫控制消息VoLTE 网络上是 SIP 消息。基带处理完这些消息后把关键事件上报给 RIL 守护进程也就是/dev/socket/rild。RIL 守护进程再通过 socket 把事件转发到应用层的 RIL Java 层最终由 TelephonyManager 分发给注册了监听器的应用进程。SIM 卡在这条链路里的角色是认证和加密。每次呼叫发起或呼入应答网络会向 SIM 卡下发随机数SIM 卡用内部存储的密钥做算法运算后返回响应这个过程在基带内部完成应用层拿不到原始的 APDU 报文但能拿到鉴权的结果——呼叫能不能建立、以什么状态呈现给用户。所以「提取 SIM 卡打电话的信令」在 Android 工程语境里实际能拿到的有三层信息第一层是呼叫状态机事件包括 IDLE空闲、RINGING来电振铃、OFFHOOK摘机/通话中三个主状态第二层是附加参数比如来电号码、呼出号码、通话时长第三层是 ROM 暴露的 RIL 日志里面包含呼叫控制消息甚至部分 SIP 摘要。示例程序主抓第一层和第三层的关键节点第二层通过 Telephony 事件回调补全。这里有一个新手常见的理解偏差以为换一张 SIM 卡打电话信令内容会变。实际上SIM 里的签约信息影响的是鉴权算法和网络侧的用户识别而应用层收到的状态机事件在结构上是一样的变的只是事件发生的时刻和频次。做实验室比对测试时本质上比的是不同 SIM 卡在网络侧的响应行为而不是状态机本身。VoLTE 场景要单独说。IMS 注册成功后的通话不走电路域信令是 SIP over IP。示例程序在状态机层面能抓到 OFFHOOK 和 IDLE 的切换但要拿 SIP 明细需要在系统设置里打开 IMS 日志开关拉 logcat 后过滤 SIP 关键词。不同 ROM 的开关路径差异很大这部分资源里只给了开关路径和过滤规则没写死具体机型写死了反而误导。2.2 双通道方案为什么既要信令又要声音只看信令的话一条adb logcat -b radio就能拿到大部分内容。但实际测试里经常要回答的问题是「掉话那一刻音频流有没有断」「回声是出现在接通前还是接通后」这些问题信令回答不了必须有和信令对齐的声音数据。有人会问能不能直接用 AT 命令轮询呼叫状态这条路我走过一次血泪经验是别依赖它。消费级 Android 设备上modem 串口被 RIL 独占应用层拿不到 AT 通道部分设备通过工程模式能打开但响应延迟不稳定轮询间隔稍大就会漏掉 RINGING 这种瞬态事件。所以示例程序走的是公开 APITelephonyManager 事件回调负责信令AudioRecord 负责音频流两条链路都用系统公开接口兼容性最好也最容易复现。两个通道的采集在数据特征上完全不同。信令是事件驱动的状态变化才产生数据稀疏但每一条都精确声音是流式的每秒 16000 个采样点密集但本身不带事件语义。把它俩对齐的关键策略是统一时间基准所有事件入口都打SystemClock.elapsedRealtimeNanos()时间戳不用currentTimeMillis()。后者会跟随系统设置和网络时间跳变短则几十毫秒、长则几秒的跳变足以让信令和音频错开一个不可接受的距离。采样率的选择也值得说两句。语音通话窄带是 8kHz宽带是 16kHz。示例程序默认 16kHz、单声道、16 位 PCM一分钟大约 1.8MB。这个配置有两个好处一是 16kHz 覆盖 PESQ、POLQA 这类语音质量评测算法的输入要求二是文件体积可控连续录一小时也就 100MB 左右离线分析不会把磁盘撑爆。另外示例程序不依赖任何厂商私有调试接口。做底层测试的人会提到厂商抓包工具能拿到更底层的信令那是实验室方案适合复现问题但做不了可复现的自动化测试——厂商工具通常要连 PC 或专用授权脚本没法在手机上独立完成采集闭环。这份资源没有这个限制一部手机、示例 APK、外加一个 Python 验证脚本就能跑完整流程。3. 工程化落地权限配置、信令解析与 PCM 音频同步采集3.1 权限与编译环境哪些是硬性约束先把权限清单说清楚少一个都会翻车。uses-permission android:nameandroid.permission.READ_PHONE_STATE / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE /READ_PHONE_STATE和RECORD_AUDIO都是危险权限Android 6 以后必须在运行时动态申请只写在清单文件里不够。FOREGROUND_SERVICE不需要弹窗但声明了才能在前台跑采集任务不然进程在后台待几分钟就被系统回收。Android 10 之后AudioSource.VOICE_CALL被系统标记为受限枚举值普通应用使用它要么拿到静音数据要么直接抛SecurityException。示例程序的做法是运行时探测先尝试VOICE_CALL如果初始化失败或者读到全零 buffer就回退到MIC并把回退原因写进状态文件。这样至少保证程序不崩数据能不能用看机型。建议用 targetSdk 30 编译可以避开前台服务类型的前台声明约束。如果你要跑 Android 12 以上的设备把 targetSdk 提到 31 或更高时需要额外声明foregroundServiceType示例代码的 manifest 注释里标了位置照着补就行。注意targetSdk 超过 30 后前台服务必须声明foregroundServiceType否则 Android 12 以上设备会在创建服务时直接抛ForegroundServiceStartNotAllowedException。3.2 信令采集主流程事件回调与 CSV 落盘核心代码是一个继承PhoneStateListener的类public class SignalMonitor extends PhoneStateListener { private static final String TAG SignalMonitor; Override public void onCallStateChanged(int state, String incomingNumber) { long tsUs SystemClock.elapsedRealtimeNanos() / 1000; String line String.format(Locale.US, %d,%s,%s, tsUs, stateName(state), (incomingNumber null ? : incomingNumber)); appendCsv(signal.csv, line); Log.i(TAG, tsUs - line); } private String stateName(int state) { switch (state) { case TelephonyManager.CALL_STATE_IDLE: return IDLE; case TelephonyManager.CALL_STATE_RINGING: return RINGING; case TelephonyManager.CALL_STATE_OFFHOOK: return OFFHOOK; default: return UNKNOWN_ state; } } private void appendCsv(String name, String line) { File f new File(Environment.getExternalStorageDirectory(), name); try (FileOutputStream fos new FileOutputStream(f, true)) { fos.write((line \n).getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { Log.e(TAG, CSV write failed: e.getMessage()); } } }逻辑说明回调一触发先取elapsedRealtimeNanos()换算成微秒再映射状态名最后追加写 CSV。文件输出用追加模式打开FileOutputStream第二个参数传true避免每次回调都覆盖前一条记录。参数说明时间戳取微秒而不是纳秒因为 16kHz 采样率下每个采样点是 62.5 微秒微秒精度足够和音频流对齐。incomingNumber在 OFFHOOK 状态通常为空别用它拿呼出号码呼出号码要另外通过拨号 intent 配合记录。注册和注销监听在主界面里做TelephonyManager tm (TelephonyManager) getSystemService(TELEPHONY_SERVICE); SignalMonitor monitor new SignalMonitor(); tm.listen(monitor, PhoneStateListener.LISTEN_CALL_STATE);对应地onDestroy()里执行tm.listen(monitor, PhoneStateListener.LISTEN_NONE)。这一步漏掉注销的话Activity 销毁后 listener 还被系统持有内存泄漏加重复回调信令文件里会出现成对的重复记录。3.3 音频同步采集PCM 流与双时钟标定音频采集线程的骨架public class AudioCaptureThread extends Thread { private volatile boolean running true; private static final int SAMPLE_RATE 16000; Override public void run() { int minBuf AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); int bufSize Math.max(minBuf, SAMPLE_RATE / 10); // 100ms AudioRecord record new AudioRecord( MediaRecorder.AudioSource.VOICE_CALL, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize); if (record.getState() ! AudioRecord.STATE_INITIALIZED) { Log.e(AudioCapture, init failed, fallback to MIC); record new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize); } record.startRecording(); long t0Elapsed SystemClock.elapsedRealtimeNanos() / 1000; AudioTimestamp baseStamp record.getTimestamp(); FileOutputStream fos openPcmFile(); while (running) { byte[] buf new byte[bufSize]; int n record.read(buf, 0, buf.length); if (n 0) { long audioAgeUs toAgeUs(record.getTimestamp(), baseStamp, t0Elapsed); writePcm(fos, buf, n, audioAgeUs); } } record.stop(); record.release(); closeQuietly(fos); } }逻辑说明这里的关键是双时钟标定。AudioRecord.getTimestamp()返回的是音频硬件时钟和系统时钟走了不同的晶振直接拿它的framePosition当时间戳会和信令时间轴对不上。做法是启动录制瞬间记录t0Elapsed系统时钟和baseStamp音频时钟之后每次读 buffer 都换算两个时钟的差值保证写入 PCM 文件的事件标注用的是系统时钟。参数说明bufSize取getMinBufferSize和 100 毫秒采样数两者中的较大者兼顾实时性和 IO 次数。read()是阻塞调用默认行为就可以别开 non-blocking 模式否则空转耗电而且容易漏帧。3.4 前台服务与文件轮转长时间采集不崩的工程细节采集任务必须挂在前台服务里。原因很简单Android 的后台进程会被 LMK 回收尤其是录音状态下系统认为你有明显耗电嫌疑回收优先级很高。示例程序里用一个前台通知常驻通知文案写的是「正在采集通话信令」这个字符串同时用于告知用户当前有采集行为符合系统对前台服务的审计要求。文件轮转建议按通话次数切分。最省事的方案每次从 IDLE 进入 OFFHOOK 时新建一个 PCM 文件回到 IDLE 关闭当前文件。这样每个文件的时长天然等于一段通话时长后续离线分析不用再切割。CSV 信令文件单独维护里面的每条记录都有时间戳按时间戳关联到 PCM 文件即可。一个容易忽略的点是外部存储写入。Android 11 之后直接用getExternalStorageDirectory()写文件需要MANAGE_EXTERNAL_STORAGE权限普通应用很难拿。示例程序里用的是应用专属外部目录也就是getExternalFilesDir()这个目录不需要额外权限采集结束用adb pull导出或者直接共享目录里的文件。4. 避坑指南信令丢失、音频静音与时间漂移的五个排查记录4.1 VOICE_CALL 录音全零权限受限而不是硬件问题现象在 Android 10 以上设备运行示例程序初始化 AudioRecord 不报错录音文件也正常生成但打开 PCM 文件听到的是全静音波形是一条直线。原因AudioSource.VOICE_CALL在 Android 10 起被标记为受限源普通应用即使拿到了RECORD_AUDIO权限系统也会在内核层屏蔽上行语音。这是安全策略不是麦克风坏了也不是耳机孔接触不良。解决三个可选的路径。第一使用系统签名把应用编译成 privilege app申请CAPTURE_AUDIO_OUTPUT权限这条路径只在定制 ROM 里有效。第二回退到AudioSource.MIC采集的是环境声加扬声器外放不是严格意义上的通话内录但做 DTMF 检测和回声测试时仍有参考价值。第三部分机型的开发者选项里有「通话录音」开关打开后继续用VOICE_CALL实测可用。示例程序会做运行时探测并打印当前生效的 source 类型跑之前先看一眼日志确认走的是哪条路。4.2 信令事件重复出现listener 没注销导致多次回调现象signal.csv 里同一时刻出现两条相同的 OFFHOOK 记录中间间隔只有几十毫秒看起来像一次通话被记录了两次。原因Activity 重建时旧实例的 TelephonyManager listener 没有注销新实例又注册了一个系统同时向两个实例派发事件。配置变更旋转屏幕和进程重启场景下特别容易发生日志里通常能看到两个 register 的痕迹。解决onDestroy()里必须执行tm.listen(monitor, PhoneStateListener.LISTEN_NONE)。更稳妥的做法是把监听器放在 Application 层而不是 Activity 层应用进程只有一个实例天然避免重复注册。示例程序最新版已经改为 Application 持有如果你拿到的是第一版代码建议先改这一点。4.3 时间戳错位currentTimeMillis 受 NTP 和手动改时影响现象核对信令 CSV 和录音文件的时间关系时发现两者的对齐偏移在一次通话前后不一致有时差几十毫秒有时差几秒没有规律。原因把System.currentTimeMillis()写入 CSV 作为信令时间戳。这个方法在系统时间被 NTP 校时、用户手动调整时区、或运营商下发时间时会跳变。信令事件发生的真实时刻没变但记录下来的 wall-clock 时间变了导致对不齐。解决全链路统一使用SystemClock.elapsedRealtimeNanos()换算的微秒时间戳这个时钟从系统启动开始累计不受 wall-clock 调整影响。如果确实需要和外部系统比如 PC 侧抓包对齐在采集开始时记录一次 wall-clock 和 elapsed 的映射作为基准后续所有换算都以这个基准为锚点。4.4 OFFHOOK 之后没有 RINGING某些第三方拨号器绕过了系统流程现象用第三方拨号器或某类融合通信应用发起呼叫信令 CSV 里跳过了 RINGING直接出现 OFFHOOK。对比系统自带拨号器行为不一致。原因第三方应用的呼叫流程没有走 TelephonyManager 的完整状态机发布部分状态在应用侧就消化了系统层只收到了最终状态。如果应用本身是 VoIP通话根本不经过移动网络信令状态机只在应用进程内有效。解决先确认测试边界。用系统拨号器做基准数据验证链路没问题后再切换到被测应用。如果被测应用是 VoIP信令采集要移步到应用自己的日志体系用移动网络信令的采集方案测 VoIP方向就错了。4.5 双卡设备的 subId 问题状态事件没绑卡槽现象双卡手机上副卡来电话时信令 CSV 记录的和主卡来电时完全一样区分不了是哪张卡在通话。原因PhoneStateListener的onCallStateChanged原始回调不携带 subId。Android 7 之后的 TelephonyManager 支持按 subscription 注册监听但示例程序第一版没处理这个参数导致事件全部汇总到了默认的 subscription 上。解决用SubscriptionManager获取当前可用的 subscription 列表为每个 subId 注册独立的 TelephonyManager 监听器回调里通过 subId 参数区分卡槽。录音侧同样要注意指定对应的音频源不然双卡语音可能串到同一个采集通道。5. 验证与进阶用事件对齐法确认信令和声音的同源关系采集完数据第一件事不是听录音而是验证时间对齐是否正确。我的标准做法是事件对齐法。第一步打一通测试电话接通后拨号盘依次按 1、2、3、#每按一次间隔约 200 毫秒第二步从 signal.csv 里取 OFFHOOK 时间戳第三步用 Python 读 PCM 文件分帧算短时能量定位 DTMF 频点出现的位置第四步对比按键实际时刻和检测到的 DTMF 位置差值应该稳定在常量附近这个常量就是端到端的音频管道延迟。如果偏移随通话时长线性增长说明时间基准没统一好回查录音线程的双时钟标定代码如果偏移是常量但数值超过一秒说明音频 buffer 排队太深把bufSize从 100 毫秒调到 50 毫秒再试一次。自动对齐可以上互相关代码很简短import numpy as np def align(events_us, pcm_path, fs16000): pcm np.fromfile(pcm_path, dtypenp.int16).astype(np.float32) energy np.abs(pcm).reshape(-1, fs // 20).mean(axis1) pulses np.zeros(len(energy)) for ev in events_us: idx int(ev // (1_000_000 // (fs // 20))) if idx len(pulses): pulses[idx] 1 corr np.correlate(energy, pulses, full) delay_us (np.argmax(corr) - len(energy) 1) * 50_000 return delay_us逻辑说明把信令事件时间戳映射到 50 毫秒能量帧的索引构成稀疏脉冲序列再与 PCM 能量包络做互相关峰值位置就是两个通道的延迟差。参数说明窗口选 50 毫秒兼顾 DTMF 时长的分辨率和计算量如果设备音频延迟在 300 毫秒以内corr 的峰值会非常尖锐如果出现多个相近峰说明某个事件写错了时间基准回到 CSV 逐条核对。从那以后我每次测完数据都强制走一遍对齐验证哪怕只是五秒的通话片段也先跑这段 Python 校验再谈结论。数据可信度比数据量重要得多希望帮到你。本文还有配套的精品资源点击获取