简介:针对Android开发者的SIM卡管理工具工程,核心通过反射调用Android隐藏API,实现SIM卡联系人、短信的增删改查及导出,适合想研究系统级存储接口的移动开发者。压缩包共738个文件,大小7.69MB,包含xml布局与配置、java及gradle工程源码、dex/class编译产物、jar依赖库、APK安装包等,整体结构完整,可直接导入主流IDE还原项目。已有564人学习浏览,可用作反射调用隐藏API的典型范例:从反射封装、权限处理到SIM卡数据读写均有现成代码,同时提供了短信导出、联系人导出等功能模块,便于二次开发或换成个人SIM卡管家工具。适合作为Android进阶项目阅读,也能为双卡手机主卡槽识别等场景提供排错思路。
1. Android SIM 卡开发为什么绕不开隐藏 API
做通讯录类 App 时,产品最容易提的一个需求是:把 SIM 卡里的联系人和短信导出来。我最初的条件反射是去查ContactsContract和Telephony.Sms,结果发现 Android 官方 SDK 根本没有暴露 SIM 卡存储区的读写接口——SIM 卡上的联系人叫 ADN 记录,短信是 EF-SMS 里的固定长度记录,它们在 Framework 层被封装成AdnRecord、SmsRawData这类隐藏对象,只留了几个@hide方法。也就是说,凡是涉及 SIM 卡联系人和短信的增删改查,都必须走反射。这篇以 SIM 卡管家为例,把反射调用隐藏 API 做联系人、短信管理的实现路径完整拆一遍,适合要做通讯录备份、SIM 卡工具箱的 Android 工程师参考。
2. 反射入口清单:SimContacts 与 IccSmsInterfaceManager 的调用模型
2.1 SIM 卡上的文件和 Android 侧的对象映射
SIM 卡存储不是数据库,而是一套 ISO 7816 文件系统。联系人存放在 EF-ADN(0x6F3A)里,短信存放在 EF-SMS(0x6F3C)里。每条记录长度固定:ADN 记录通常是一个姓名加一个号码,短信记录则是一段 PDU 字节流。Android 的 telephony 模块在启动时会把这些 EF 文件加载到内存,并封装成两个核心隐藏类:
com.android.internal.telephony.AdnRecord:联系人记录,字段包括mName、mNumber、mEmails、mEfid、mRecordNumber等。com.android.internal.telephony.SmsRawData:短信原始 PDU 的包装,内部就是一个byte[]。
操作入口分别对应SimContacts(联系人)和IccSmsInterfaceManager(短信)。理解这层映射关系后,反射的目标就清晰了:先把隐藏对象拿出来,再通过反射读字段或调方法。这里有个常见的错误预期——有人以为拿到List<AdnRecord>后可以直接强转编译期类型,实际上工程代码里根本引用不到隐藏类,只能统一用Object接,再按字段名反射取值。
2.2 关键隐藏方法签名与参数表
以下是 SIM 卡管家中实际会用到的方法清单。这些方法在 AOSP 中真实存在,但不同 ROM 可能改动实现,编译期无法引用:
| 类 | 方法 | 用途 | 参数说明 |
|---|---|---|---|
SimContacts | getSimContacts(Context) | 读取全部联系人 | Context传 applicationContext |
SimContacts | insertSimContact(Context, String name, String number) | 新增联系人 | 姓名、号码均为字符串 |
SimContacts | updateSimContact(Context, AdnRecord) | 更新指定记录 | 必须传入修改后的完整记录对象 |
SimContacts | deleteSimContact(Context, AdnRecord) | 删除指定记录 | 按recordNumber定位 |
IccSmsInterfaceManager | getAllMessagesFromIccEf() | 读取全部短信 | 返回List<SmsRawData> |
IccSmsInterfaceManager | updateMessageOnIccEf(int index, int status, byte[] pdu) | 更新短信状态 | index 为记录槽位,status 见后文 |
IccSmsInterfaceManager | deleteMessageOnIccEf(int index) | 删除短信 | 按槽位删除 |
注意updateSimContact和deleteSimContact的第二个参数是AdnRecord类型,反射getMethod时必须用adnRecordClass去精确匹配参数类型,不能直接传Object.class,否则会报NoSuchMethodException。
2.3 targetSdk 与隐藏 API 限制
从 targetSdk 28(Android 9)开始,系统会对非 SDK 接口做拦截。logcat 里出现Accessing hidden method Landroid/...时,反射调用会在运行时抛NoSuchMethodException。我一般会先试一次普通反射,捕获异常后判断是否被 hidden-api 策略拦截:
private static Object invokeStatic(Class<?> clazz, String methodName, Class<?>[] paramTypes, Object... args) throws Throwable { Method m = clazz.getMethod(methodName, paramTypes); m.setAccessible(true); return m.invoke(null, args); }这里setAccessible(true)在 patch 掉 hidden-api 检查的 ROM 上足够,但很多原生 ROM 仍然拦得住。通用做法是双反射:先通过 native 方法拿到被遮蔽的getDeclaredMethod,再用它去解析目标方法。这个方案对不同 Android 版本的适配率较高,但要接受厂商定制 ROM 可能改动内部类名的事实,所以我对所有反射点都做了 try/catch 降级,失败时至少保证 App 不闪退。
提示:
AdnRecord的字段名在不同 ROM 上有差异,常见的有recordNumber和index两种命名,解析时建议两个字段都尝试,取第一个非空值。
3. 联系人增删改查:把 AdnRecord 当成没有主键的数据行
3.1 读取并解析联系人字段
先通过SimContacts.getSimContacts拿到List<Object>,然后对每个元素做字段反射。因为编译期拿不到AdnRecord,我封装了一个通用的字段读取器:
private static String readField(Object record, String... fieldNames) { Class<?> clazz = record.getClass(); for (String name : fieldNames) { try { Field f = clazz.getDeclaredField(name); f.setAccessible(true); Object value = f.get(record); if (value != null) { return String.valueOf(value); } } catch (Throwable ignored) { // 继续尝试下一个字段名 } } return ""; }这段代码的核心是字段名兜底:先读recordNumber,读不到再读index。getDeclaredField只能拿当前类声明的字段,不能拿父类字段,所以如果遇到私有父类字段,需要先getSuperclass()再继续反射。解析完成后把数据封装成自定义的结构体:
public class SimContactInfo { public String name; public String number; public String[] emails; public String[] anrs; public int efid; // EF 文件标识,一般 0x6F3A public int recordNumber; // SIM 卡中的物理槽位 }这一步是后面增删改查的基础。recordNumber不是数据库主键,它是记录在 SIM 卡文件中的物理位置,写入和删除都依赖它定位。
3.2 新增联系人:insertSimContact 的参数与返回
新增联系人直接反射insertSimContact,传Context、name、number三个参数:
Class<?> simContacts = Class.forName("com.android.internal.telephony.SimContacts"); Method insert = simContacts.getMethod("insertSimContact", Context.class, String.class, String.class); boolean success = (Boolean) insert.invoke(null, appContext, contactName, phoneNumber);返回的boolean表示是否写入成功。这里有几个工程坑:
Context必须传ApplicationContext,传Activity实例在某些 ROM 上会导致静态方法内部持有的 context 泄漏。- 姓名和号码内部会做 SIM 卡字符集编码判断,中文名在 GSM 7-bit 字母表里不存在时会自动切换 UCS2。如果号码里带了
+86前缀,部分 ROM 会直接拒绝写入,建议先格式化成纯数字。 - 新增后立即再调用
getSimContacts可能读不到最新数据,因为 RIL 层写入是异步的。稳妥做法是延迟 500ms 再回读校验。
3.3 更新与删除:靠 recordNumber 定位而不是靠 ID
更新记录要先把原AdnRecord整个对象读出来,修改字段后再调用updateSimContact。删除同理,需要把完整对象传进去。示例封装如下:
public static boolean deleteContact(Context context, Object adnRecord) throws Exception { Class<?> simContacts = Class.forName("com.android.internal.telephony.SimContacts"); Method delete = simContacts.getMethod("deleteSimContact", Context.class, adnRecord.getClass()); return (Boolean) delete.invoke(null, context, adnRecord); }注意getMethod的第二个参数必须精确匹配adnRecord.getClass(),否则会在方法查找阶段失败。更新时还有一个边界条件:AdnRecord里的efid字段标识联系人属于哪张卡。双卡手机上,槽位 1 和槽位 2 的efid可能不同,删除前要校验efid是否与当前主卡一致,否则会跨卡串写。部分 ROM 对已删除槽位的处理是标记为空闲,后续写入会复用该槽位,因此连续删除再新增的场景下,回读时要注意顺序变化。
| 操作 | 关键参数 | 失败场景 |
|---|---|---|
| 新增 | name、number | SIM 卡剩余空间不足,返回 false |
| 更新 | 完整 AdnRecord | 传入的 recordNumber 超出 EF 记录上限 |
| 删除 | 完整 AdnRecord | ROM 禁止删除系统写入的固定记录 |
3.4 与数据库增删改查的差异
如果把 SIM 卡联系人类比成一张 MySQL 表,那AdnRecord就是一行记录,但和数据库增删改查最大的不同是:它没有自增主键,也没有事务,每次写入都对应一次真实的 flash 页擦写。频繁增删会加速 SIM 卡磨损,所以批量导入时必须控制节奏,每写一条间隔至少 200ms。后端开发做惯了 CRUD 的同学在这里最容易翻车——把联系人列表循环插入,结果写到第 10 条时直接抛IccException,这就是 SIM 卡的物理写入限制,不是代码逻辑问题。
4. 短信读取与导出:IccSmsInterfaceManager + PDU 解析
4.1 获取 IccSmsInterfaceManager 的反射链
短信管理和联系人不在同一个入口。IccSmsInterfaceManager需要从TelephonyManager.getITelephony()里拿,是一条跨 Binder 的反射链:
TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); Method getITelephony = tm.getClass().getDeclaredMethod("getITelephony"); getITelephony.setAccessible(true); Object iTelephony = getITelephony.invoke(tm); Class<?> iTelephonyClass = iTelephony.getClass(); Method getSmsManager = null; for (Method m : iTelephonyClass.getMethods()) { if (m.getName().equals("getIccSmsInterfaceManager")) { getSmsManager = m; break; } } Object smsManager = getSmsManager.invoke(iTelephony);这里用遍历方法名而不是getMethod精确匹配,是因为 Android 10 之后ITelephony.aidl中该方法增加了String callingPackage参数。遍历拿到Method后,调用时判断参数个数:如果为 1,就传入context.getPackageName()。这个兼容写法同时覆盖了新旧版本,比写死签名更稳。
4.2 SmsRawData 转可读短信
拿到smsManager后调用getAllMessagesFromIccEf(),返回的是一个元素类型为SmsRawData的List,每个元素内部是byte[] pdu。完整解析代码如下:
Method getAll = smsManager.getClass().getMethod("getAllMessagesFromIccEf"); List<?> rawList = (List<?>) getAll.invoke(smsManager); for (Object rawItem : rawList) { if (rawItem == null) { continue; // 空槽位返回 null } Class<?> rawClass = rawItem.getClass(); Method getBytes = rawClass.getMethod("getBytes"); byte[] pdu = (byte[]) getBytes.invoke(rawItem); SmsMessage sms = SmsMessage.createFromPdu(pdu, SmsMessage.FORMAT_3GPP); String address = sms.getOriginatingAddress(); String body = sms.getMessageBody(); long date = sms.getTimestampMillis(); int statusOnIcc = sms.getStatusOnIcc(); }这里强制指定FORMAT_3GPP是因为绝大多数 GSM 双卡手机默认走 GSM 制式。如果设备的 RIL 层配置为 CDMA,需要用SmsMessage.FORMAT_3GPP2重试。getStatusOnIcc返回的是短信在 SIM 卡上的状态值:1 表示已读,3 表示未读,5 已发送,7 未发送。导出时建议把状态一起落盘,方便用户区分。
4.3 标记已读与删除:updateMessageOnIccEf 与 deleteMessageOnIccEf
读取本身不改变短信状态,只有调用updateMessageOnIccEf才会把槽位标记为已读:
Method update = smsManager.getClass().getMethod( "updateMessageOnIccEf", int.class, int.class, byte[].class); boolean updated = (Boolean) update.invoke(smsManager, index, 1, pdu);参数含义如下:
index:短信在 EF-SMS 文件里的槽位,从 0 开始。status:1 表示已读,3 表示未读。pdu:必须传原始 PDU,不能传 null,否则部分 ROM 会直接把短信置为无效。
删除则更简单,直接调deleteMessageOnIccEf:
Method deleteSms = smsManager.getClass().getMethod("deleteMessageOnIccEf", int.class); boolean deleted = (Boolean) deleteSms.invoke(smsManager, index);删除不等同于清空 PDU,多数实现是把槽位标记为空闲,写入新短信时会复用。所以删除后立刻再getAllMessagesFromIccEf,槽位返回的仍然是null,不要期待出现长度为零的空对象。
4.4 导出为 CSV 文件
导出功能在 SIM 卡管家里通常是刚需。CSV 格式通用且不需要第三方库,注意对字段里的逗号和换行做转义:
public static void exportSmsToCsv(List<SimSmsItem> items, File target) throws IOException { try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(target), "UTF-8"))) { writer.write("index,address,body,date,status\n"); for (SimSmsItem item : items) { writer.write(String.format("%d,%s,%s,%d,%d\n", item.index, escapeCsv(item.address), escapeCsv(item.body), item.date, item.status)); } } } private static String escapeCsv(String raw) { if (raw == null) return ""; String escaped = raw.replace("\"", "\"\""); return "\"" + escaped + "\""; }String.format里的%s会被escapeCsv结果替换,整条记录用双引号包围,Excel 和 WPS 都能正确打开。建议导出文件名加上时间戳,比如sim_sms_20250630_1430.csv,避免用户重复导出时覆盖旧文件。
5. 双卡、权限与验证:上线前必须处理的三个细节
5.1 双卡识别与主卡槽判断
项目说明里提到“双卡双待请把 SIM 放在主卡槽”,这是因为很多 ROM 的SimContacts实现默认只操作默认订阅对应的 ADN。应用层可以先通过SubscriptionManager判断当前激活的卡槽:
SubscriptionManager sm = SubscriptionManager.from(context); List<SubscriptionInfo> subs = sm.getActiveSubscriptionInfoList(); for (SubscriptionInfo info : subs) { int slotIndex = info.getSimSlotIndex(); String displayName = info.getDisplayName().toString(); Log.d(TAG, "slot=" + slotIndex + ", name=" + displayName); }当subs.size() > 1时,在界面上提示用户将 SIM 放入主卡槽,否则反射读取返回的可能是空列表或另一张卡的数据。部分机型还要求读出后校验SIM card serial与卡槽一致,这个可以通过TelephonyManager.getSimSerialNumber()拿 ICCID 做匹配。
5.2 权限清单与 Android 13 行为
运行 SIM 卡读写需要申请以下危险权限:
| 权限 | 用途 | 缺失表现 |
|---|---|---|
READ_CONTACTS | 读取 ADN 记录 | 联系人列表为空 |
WRITE_CONTACTS | 写入、删除联系人 | 写入返回 false |
READ_SMS | 读取 EF-SMS 记录 | 短信列表为空 |
WRITE_SMS | 删除短信 | 删除返回 false |
Android 13(API 33)对READ_SMS的权限弹窗改成了按短信类别授权,用户可能只授权“通知类短信”,导致读取不到 SIM 卡短信。处理办法是在权限回调失败后引导用户进入系统设置,把“所有短信”权限打开;targetSdk 33下不要尝试用requestPermissions二次弹窗,系统不会响应。
5.3 adb 对照验证反射结果
上线前最有效的验证方式是通过 adb 的 content 命令对比反射结果。部分开发机上可以直接查询 SIM 卡 provider:
adb shell content query --uri content://icc/adn如果这条命令返回了联系人数据,而 App 里反射读取为空,问题大概率出在权限或 provider 绑定上。短信部分可以对比getAllMessagesFromIccEf()的返回数量与adb shell content query --uri content://sms/icc的数量是否一致。数对不上的时候优先看 logcat 里有没有Accessing hidden method ... blocked的日志,有就说明被 hidden-api 策略拦截,需要走双反射降级;没有再看是否双卡槽选错,用getSimSerialNumber()确认当前拿到的 ICCID 与卡槽位对应。
本文还有配套的精品资源,点击获取