
简介针对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-ADN0x6F3A里短信存放在 EF-SMS0x6F3C里。每条记录长度固定ADN 记录通常是一个姓名加一个号码短信记录则是一段 PDU 字节流。Android 的 telephony 模块在启动时会把这些 EF 文件加载到内存并封装成两个核心隐藏类com.android.internal.telephony.AdnRecord联系人记录字段包括mName、mNumber、mEmails、mEfid、mRecordNumber等。com.android.internal.telephony.SmsRawData短信原始 PDU 的包装内部就是一个byte[]。操作入口分别对应SimContacts联系人和IccSmsInterfaceManager短信。理解这层映射关系后反射的目标就清晰了先把隐藏对象拿出来再通过反射读字段或调方法。这里有个常见的错误预期——有人以为拿到ListAdnRecord后可以直接强转编译期类型实际上工程代码里根本引用不到隐藏类只能统一用Object接再按字段名反射取值。2.2 关键隐藏方法签名与参数表以下是 SIM 卡管家中实际会用到的方法清单。这些方法在 AOSP 中真实存在但不同 ROM 可能改动实现编译期无法引用类方法用途参数说明SimContactsgetSimContacts(Context)读取全部联系人Context传 applicationContextSimContactsinsertSimContact(Context, String name, String number)新增联系人姓名、号码均为字符串SimContactsupdateSimContact(Context, AdnRecord)更新指定记录必须传入修改后的完整记录对象SimContactsdeleteSimContact(Context, AdnRecord)删除指定记录按recordNumber定位IccSmsInterfaceManagergetAllMessagesFromIccEf()读取全部短信返回ListSmsRawDataIccSmsInterfaceManagerupdateMessageOnIccEf(int index, int status, byte[] pdu)更新短信状态index 为记录槽位status 见后文IccSmsInterfaceManagerdeleteMessageOnIccEf(int index)删除短信按槽位删除注意updateSimContact和deleteSimContact的第二个参数是AdnRecord类型反射getMethod时必须用adnRecordClass去精确匹配参数类型不能直接传Object.class否则会报NoSuchMethodException。2.3 targetSdk 与隐藏 API 限制从 targetSdk 28Android 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拿到ListObject然后对每个元素做字段反射。因为编译期拿不到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、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()返回的是一个元素类型为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 开始。status1 表示已读3 表示未读。pdu必须传原始 PDU不能传 null否则部分 ROM 会直接把短信置为无效。删除则更简单直接调deleteMessageOnIccEfMethod 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(ListSimSmsItem 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); ListSubscriptionInfo 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写入、删除联系人写入返回 falseREAD_SMS读取 EF-SMS 记录短信列表为空WRITE_SMS删除短信删除返回 falseAndroid 13API 33对READ_SMS的权限弹窗改成了按短信类别授权用户可能只授权“通知类短信”导致读取不到 SIM 卡短信。处理办法是在权限回调失败后引导用户进入系统设置把“所有短信”权限打开targetSdk 33下不要尝试用requestPermissions二次弹窗系统不会响应。5.3 adb 对照验证反射结果上线前最有效的验证方式是通过 adb 的 content 命令对比反射结果。部分开发机上可以直接查询 SIM 卡 provideradb 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 与卡槽位对应。本文还有配套的精品资源点击获取