Android SIM卡联系人短信读写:隐藏API反射实现全解析
2026/9/13 22:24:07 网站建设 项目流程

简介:针对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 卡里的联系人和短信导出来。我最初的条件反射是去查ContactsContractTelephony.Sms,结果发现 Android 官方 SDK 根本没有暴露 SIM 卡存储区的读写接口——SIM 卡上的联系人叫 ADN 记录,短信是 EF-SMS 里的固定长度记录,它们在 Framework 层被封装成AdnRecordSmsRawData这类隐藏对象,只留了几个@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:联系人记录,字段包括mNamemNumbermEmailsmEfidmRecordNumber等。
  • com.android.internal.telephony.SmsRawData:短信原始 PDU 的包装,内部就是一个byte[]

操作入口分别对应SimContacts(联系人)和IccSmsInterfaceManager(短信)。理解这层映射关系后,反射的目标就清晰了:先把隐藏对象拿出来,再通过反射读字段或调方法。这里有个常见的错误预期——有人以为拿到List<AdnRecord>后可以直接强转编译期类型,实际上工程代码里根本引用不到隐藏类,只能统一用Object接,再按字段名反射取值。

2.2 关键隐藏方法签名与参数表

以下是 SIM 卡管家中实际会用到的方法清单。这些方法在 AOSP 中真实存在,但不同 ROM 可能改动实现,编译期无法引用:

方法用途参数说明
SimContactsgetSimContacts(Context)读取全部联系人Context传 applicationContext
SimContactsinsertSimContact(Context, String name, String number)新增联系人姓名、号码均为字符串
SimContactsupdateSimContact(Context, AdnRecord)更新指定记录必须传入修改后的完整记录对象
SimContactsdeleteSimContact(Context, AdnRecord)删除指定记录recordNumber定位
IccSmsInterfaceManagergetAllMessagesFromIccEf()读取全部短信返回List<SmsRawData>
IccSmsInterfaceManagerupdateMessageOnIccEf(int index, int status, byte[] pdu)更新短信状态index 为记录槽位,status 见后文
IccSmsInterfaceManagerdeleteMessageOnIccEf(int index)删除短信按槽位删除

注意updateSimContactdeleteSimContact的第二个参数是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 上有差异,常见的有recordNumberindex两种命名,解析时建议两个字段都尝试,取第一个非空值。

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,读不到再读indexgetDeclaredField只能拿当前类声明的字段,不能拿父类字段,所以如果遇到私有父类字段,需要先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,传Contextnamenumber三个参数:

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表示是否写入成功。这里有几个工程坑:

  1. Context必须传ApplicationContext,传Activity实例在某些 ROM 上会导致静态方法内部持有的 context 泄漏。
  2. 姓名和号码内部会做 SIM 卡字符集编码判断,中文名在 GSM 7-bit 字母表里不存在时会自动切换 UCS2。如果号码里带了+86前缀,部分 ROM 会直接拒绝写入,建议先格式化成纯数字。
  3. 新增后立即再调用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、numberSIM 卡剩余空间不足,返回 false
更新完整 AdnRecord传入的 recordNumber 超出 EF 记录上限
删除完整 AdnRecordROM 禁止删除系统写入的固定记录

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(),返回的是一个元素类型为SmsRawDataList,每个元素内部是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 与卡槽位对应。

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

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

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

立即咨询