☰
Android实时提取SIM卡信令与通话声音:双通道时间轴对齐方案
2026/10/16 18:49:25 网站建设 项目流程

简介:安卓端实时提取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 这种瞬态事件。所以示例程序走的是公开 API:TelephonyManager 事件回调负责信令,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:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.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, fs=16000): pcm = np.fromfile(pcm_path, dtype=np.int16).astype(np.float32) energy = np.abs(pcm).reshape(-1, fs // 20).mean(axis=1) 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 校验再谈结论。数据可信度比数据量重要得多,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询