☰
安卓手机获取通话记录信息:基于 ContentResolver 与 CallLog 的权限配置与读取验证
2026/9/29 20:29:38 网站建设 项目流程

1. 安卓读取通话记录到底难在哪

很多刚接触安卓系统数据读取的朋友,第一反应是「不就是查个数据库吗」,结果真机一跑,要么 Cursor 返回 null,要么直接抛 SecurityException,要么在 Android 10 以上机型上被系统静默拦截。安卓的通话记录读取,本质上是三件事叠在一起:权限声明、运行时授权、以及通过 ContentResolver 去查 CallLog.Calls 这张系统表。任何一环没对齐,代码就白写。

这篇内容面向的是需要在安卓 App 里读取本机通话记录的开发者,包括做通话管理工具、客服回访辅助、设备数据备份类功能的同学。核心链路是:在 AndroidManifest 里声明 READ_CALL_LOG,运行时动态申请,拿到授权后用 ContentResolver 查询 CallLog.Calls,解析出号码、通话时长、通话类型、时间戳等字段。我会把可复制的权限片段、查询代码骨架、真机验证步骤,以及我实际踩过的坑都摊开讲。

需要提前说清楚一点:通话记录属于敏感数据,Google Play 对 READ_CALL_LOG 的审核非常严格,只有默认拨号器或符合特定政策的应用才能上架。如果你只是本地调试或企业内部使用,问题不大;如果要上架,务必先确认自己的应用类别是否被允许。这个前提想明白了,后面的代码才有意义。

2. 用 TaoToken 辅助生成权限与查询代码

写这类系统 API 代码时,最容易卡住的不是逻辑,而是字段名记不全、权限组合拿不准、不同 Android 版本行为差异大。我自己的做法是先用 TaoToken 的模型对话把代码骨架和版本差异问清楚,再落到工程里改。它的模型对话入口在 https://taotoken.net/api 对应的控制台里,打开模型对话页面就能直接问,比如「Android 14 读取 CallLog 需要哪些权限,运行时申请怎么写」,它会给出带版本判断的完整片段,省去反复翻文档的时间。

如果你是在做长期的安卓工具类项目,反复要生成权限模板、查询封装、字段解析代码,可以考虑 Coding Plan,把常用的系统数据读取场景沉淀成可复用的对话上下文,后面每次新建模块直接调用,不用从零描述需求。接入文档在 https://taotoken.net/api 的 doc 路径下,API Keys 在 console 的 api-keys 页面生成,整个流程不复杂。

这里要强调:TaoToken 只是帮你生成和梳理代码的辅助工具,它不替代 Android Studio,也不替代真机调试。权限能不能拿到、Cursor 能不能返回数据,最终还是要靠设备验证。下面进入正题。

3. 权限声明与运行时申请的可复制配置

3.1 AndroidManifest 声明

先在AndroidManifest.xml的<manifest>节点下加入权限声明。读取通话记录至少需要 READ_CALL_LOG,如果你还要写入或删除记录,才需要 WRITE_CALL_LOG:

<uses-permission android:name="android.permission.READ_CALL_LOG" /> <uses-permission android:name="android.permission.WRITE_CALL_LOG" />

注意:只读场景不要顺手把 WRITE_CALL_LOG 也加上,权限越多,审核和用户信任成本越高。

3.2 运行时动态申请

Android 6.0 以后,危险权限必须运行时申请。下面是一个可以直接用的申请片段,用 ActivityResultLauncher 的写法,避免旧版 onRequestPermissionsResult 的碎片化:

private final ActivityResultLauncher<String> callLogPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { if (granted) { Log.i("CallLog", "READ_CALL_LOG 已授权,可以开始查询"); queryCallLog(); } else { Log.w("CallLog", "用户拒绝了通话记录权限"); } }); private void requestCallLogPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { queryCallLog(); } else { callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG); } }

这段代码的关键点是先检查再申请,已授权就直接查询,没授权才弹窗。很多新手直接调 launch,结果在已授权机型上反复弹窗,体验很差。

3.3 查询 CallLog.Calls 的代码骨架

拿到权限后,通过 ContentResolver 查询。下面这段是完整可跑的骨架,包含字段解析和资源释放:

private List<RecordEntity> queryCallLog() { ContentResolver resolver = getContentResolver(); Cursor cursor = null; List<RecordEntity> recordList = new ArrayList<>(); try { cursor = resolver.query( CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " desc"); if (cursor == null) { Log.e("CallLog", "cursor 为 null,检查权限或 URI"); return recordList; } int nameIdx = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME); int numberIdx = cursor.getColumnIndex(CallLog.Calls.NUMBER); int typeIdx = cursor.getColumnIndex(CallLog.Calls.TYPE); int dateIdx = cursor.getColumnIndex(CallLog.Calls.DATE); int durationIdx = cursor.getColumnIndex(CallLog.Calls.DURATION); int newIdx = cursor.getColumnIndex(CallLog.Calls.NEW); while (cursor.moveToNext()) { RecordEntity record = new RecordEntity(); record.name = nameIdx >= 0 ? cursor.getString(nameIdx) : ""; record.number = numberIdx >= 0 ? cursor.getString(numberIdx) : ""; record.type = typeIdx >= 0 ? cursor.getInt(typeIdx) : 0; record.lDate = dateIdx >= 0 ? cursor.getLong(dateIdx) : 0L; record.duration = durationIdx >= 0 ? cursor.getLong(durationIdx) : 0L; record.isNew = newIdx >= 0 ? cursor.getInt(newIdx) : 0; recordList.add(record); } } catch (SecurityException e) { Log.e("CallLog", "权限异常:" + e.getMessage()); } finally { if (cursor != null) { cursor.close(); } } return recordList; }

实体类保持简单即可:

private static class RecordEntity { String name; String number; int type; long lDate; long duration; int isNew; @Override public String toString() { return "RecordEntity{name=" + name + ", number=" + number + ", type=" + type + ", date=" + lDate + ", duration=" + duration + ", isNew=" + isNew + "}"; } }

字段含义对照表如下,方便你按需取用:

字段含义类型
CACHED_NAME联系人缓存名称,可能为空String
NUMBER通话号码String
TYPE通话类型,1 来电 2 去电 3 未接int
DATE通话时间戳,毫秒long
DURATION通话时长,秒long
NEW是否是新记录,1 为新int

4. 真机验证与成功结果确认

代码写完后,别急着上模拟器。通话记录依赖真实通话数据,模拟器里通常是空的,建议用真机验证。步骤是:安装 App,进入触发查询的页面,系统弹出权限对话框,点允许,然后看 Logcat 输出。

我实测下来,一条正常的日志大概长这样:

CallLog: RecordEntity{name=张三, number=13800000000, type=2, date=1710000000000, duration=45, isNew=0} CallLog: RecordEntity{name=, number=10086, type=1, date=1709990000000, duration=12, isNew=1}

如果能看到号码、时长、类型都正确解析,说明整条链路跑通了。这里有个细节:CACHED_NAME 对陌生号码会是空字符串,这是正常的,不要以为是解析失败。另外 DATE 是毫秒时间戳,展示时用 SimpleDateFormat 转一下即可。

验证时建议按这个顺序排查:先确认权限对话框是否弹出,再确认 Logcat 里有没有 SecurityException,最后看 cursor 是否为 null。三步定位,基本能覆盖九成问题。

5. 本篇常见错误排查

5.1 SecurityException: Permission Denial

最常见的就是没申请权限或用户拒绝后仍强行查询。表现是直接抛异常,日志里带requires android.permission.READ_CALL_LOG。解决方式是确保运行时申请走完,并且在查询前用 checkSelfPermission 再确认一次。如果用户勾选了「不再询问」,需要引导到系统设置页手动开启。

5.2 cursor 返回 null

权限没问题但 cursor 为 null,通常是 URI 写错或设备本身没有通话记录。确认用的是CallLog.Calls.CONTENT_URI,不要手写成字符串。另外部分定制 ROM 对系统表做了限制,这种情况换原生或主流机型复测。

5.3 字段索引返回 -1

用 getColumnIndex 拿到 -1,说明该字段在当前系统版本不存在。比如某些低版本没有 CACHED_PHOTO_ID。稳妥做法是像上面代码那样,索引小于 0 时给默认值,避免 getString(-1) 直接崩溃。

5.4 Android 10 以上查询受限

Android 10 起对通话记录访问收紧了,部分场景需要配合默认拨号器角色。如果你的应用不是拨号器,可能拿不到完整数据。这个属于系统策略,不是代码问题,需要从产品定位上规避。

5.5 忘记关闭 Cursor

Cursor 不关闭会导致资源泄漏,长时间运行后查询变慢甚至失败。务必在 finally 里 close,这是硬性要求。

6. 继续把链路跑稳的建议

权限和查询跑通只是第一步。真正上线前,你还要处理用户拒绝权限后的降级逻辑、通话记录的分页加载、以及不同 ROM 的兼容性。我自己的习惯是把权限申请、查询、字段解析拆成独立方法,方便单测和复用。

如果你在接入过程中遇到报错,或者想快速生成适配不同 Android 版本的权限模板,可以直接用 TaoToken 的 API Keys 配合接入文档来调试,文档里有完整的调用示例。需要验证模型对某段代码的理解是否准确时,打开模型对话贴进去问就行。长期做安卓系统数据类项目的,用 Coding Plan 把常用场景沉淀下来,后面新建模块会省很多重复描述的时间。链路跑通之后,剩下的就是把它做稳。

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

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

立即咨询