1. Android 10 读取通话记录为什么容易踩坑Android 10 引入分区存储后很多开发者第一反应是「存储权限收紧了通话记录是不是也读不了了」。实际上通话记录属于系统通过 ContentProvider 暴露的数据走的是CallLog.Calls.CONTENT_URI这条通道和外部存储的 scoped storage 是两套机制。真正让旧代码翻车的是运行时权限模型的变化、READ_CALL_LOG被归入危险权限组以及部分厂商 ROM 对后台读取的额外限制。我在迁移一个老项目时遇到过典型症状ContentResolver.query()返回的 Cursor 不为 null但getCount()是 0日志里没有任何异常。排查半天才发现是权限只声明了没动态申请Android 10 上直接静默返回空结果不抛 SecurityException。这个坑非常隐蔽因为代码逻辑看起来完全正常。这篇内容面向两类人一是手里有 Android 9 甚至更早的通话记录读取代码想迁到 Android 10 的二是第一次接入 CallLog 查询不确定权限、投影、游标遍历该怎么写的。核心链路就是CallLogContentResolverCursor三件套我会给出可直接复制的权限声明、查询配置和游标遍历代码并附真机验证步骤。另外调试期如果涉及接口调用管理比如把通话记录上传到自己的服务端做测试可以用 TaoToken 统一管理 Key 和 API 通道避免在多个测试环境里散落硬编码的密钥。这部分我会在第三节给出配置片段。先说结论Android 10 上读取通话记录本身是可行的前提是权限申请到位、查询参数正确、游标用完即关。下面按完整链路拆开讲。2. TaoToken 统一 Key 通道在调试期的接入准备调试通话记录上传功能时通常会遇到一个尴尬测试环境的接口 Key 散落在BuildConfig、local.properties、甚至直接写在代码里。换一个测试同学、换一台机器就要重新配一遍。TaoToken 的思路是把模型调用和接口调用的 Key 统一到一个通道里管理调试期只维护一份配置。你需要先拿到一个可用的 Key。进入控制台创建 API Key地址是https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。创建后复制出来注意只显示一次。如果你还没注册官网入口在https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。Base URL 统一用https://taotoken.net/api这个地址不加 UTM 参数直接作为请求前缀。Model ID 根据你实际调用的模型填写比如做通话记录文本摘要可以用对话模型做结构化解析可以用对应的模型 ID。三件套就是 Base URL Key Model ID缺一不可。在 Android 项目里我建议不要把 Key 写进BuildConfig后提交到仓库。更稳妥的做法是放在local.properties然后在build.gradle里读取// app/build.gradle android { defaultConfig { buildConfigField String, TAOTOKEN_BASE_URL, \https://taotoken.net/api\ buildConfigField String, TAOTOKEN_KEY, \${localProperties.getProperty(taotoken.key)}\ buildConfigField String, TAOTOKEN_MODEL, \your-model-id\ } }local.properties里加一行taotoken.keysk-xxxx这个文件默认在.gitignore里不会误提交。这样调试期换 Key 只改一个文件不用动代码。如果你用的是 Claude Code 做辅助开发可以在项目根目录配settings.json把 Base URL 和 Key 写进去让工具链也走同一个通道。配置片段如下{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-xxxx, ANTHROPIC_MODEL: your-model-id } }注意这里的 Key 和 Android 项目里用的是同一个但用途分开Android 项目用于运行时上传通话记录Claude Code 用于开发期辅助。两者共用一套 Key 管理省去多处维护的麻烦。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite里面有各语言的调用示例。准备工作做完接下来进入通话记录读取的正题。3. CallLog ContentResolver 可复制配置与查询投影权限声明是第一步。在AndroidManifest.xml里加两行uses-permission android:nameandroid.permission.READ_CALL_LOG / uses-permission android:nameandroid.permission.READ_PHONE_STATE /READ_PHONE_STATE在部分场景下用于获取 SIM 卡信息如果你不需要按 SIM 卡筛选可以只保留READ_CALL_LOG。但注意Android 10 上这两个都属于危险权限必须在运行时动态申请光声明不申请等于没声明。动态申请的代码private static final int REQ_CALL_LOG 1001; private void requestCallLogPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CALL_LOG}, REQ_CALL_LOG); } else { queryCallLog(); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_CALL_LOG) { if (grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { queryCallLog(); } else { Toast.makeText(this, 需要通话记录权限才能读取, Toast.LENGTH_SHORT).show(); } } }查询投影建议显式指定列不要传 null。传 null 会返回所有列数据量大时性能差而且不同厂商 ROM 返回的列可能不一致解析时容易出问题。显式投影如下String[] projection new String[]{ CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.TYPE, CallLog.Calls.CACHED_NAME, CallLog.Calls.PHONE_ACCOUNT_ID };查询条件用selection和selectionArgs拼装。按时间范围和通话类型筛选long begin System.currentTimeMillis() - 7 * 24 * 3600 * 1000L; // 最近7天 long end System.currentTimeMillis(); int type -1; // -1 全部1 来电2 拨出3 未接 String selection CallLog.Calls.DATE ? AND CallLog.Calls.DATE ?; ListString args new ArrayList(); args.add(String.valueOf(begin)); args.add(String.valueOf(end)); if (type ! -1) { selection AND CallLog.Calls.TYPE ?; args.add(String.valueOf(type)); } Cursor cursor null; try { cursor getContentResolver().query( CallLog.Calls.CONTENT_URI, projection, selection, args.toArray(new String[0]), CallLog.Calls.DEFAULT_SORT_ORDER ); } catch (Exception e) { Log.e(CallLog, query failed, e); } finally { if (cursor ! null) { cursor.close(); } }注意CallLog.Calls.DEFAULT_SORT_ORDER默认按日期倒序最新的通话在最前面。如果你需要正序改成CallLog.Calls.DATE ASC。游标遍历时用列名取值比用索引更安全因为投影顺序可能变。遍历代码ListCallRecord records new ArrayList(); if (cursor ! null) { int idxNumber cursor.getColumnIndex(CallLog.Calls.NUMBER); int idxDate cursor.getColumnIndex(CallLog.Calls.DATE); int idxDuration cursor.getColumnIndex(CallLog.Calls.DURATION); int idxType cursor.getColumnIndex(CallLog.Calls.TYPE); int idxName cursor.getColumnIndex(CallLog.Calls.CACHED_NAME); while (cursor.moveToNext()) { CallRecord r new CallRecord(); r.number cursor.getString(idxNumber); r.date cursor.getLong(idxDate); r.duration cursor.getLong(idxDuration); r.type cursor.getInt(idxType); r.name cursor.getString(idxName); records.add(r); } }CallRecord就是你的数据类字段和投影对应即可。这里没有用 Gson 反射解析 JSONArray直接游标取值更直接也少一次序列化开销。如果你确实需要 JSON 格式可以在遍历时手动组装JSONObject但没必要先转 JSONArray 再转 List。4. 真机验证请求与成功结果确认代码写完后必须在真机上验证。模拟器上通话记录数据库通常是空的读出来 count 为 0容易误判成代码问题。验证步骤第一步安装 APK 后打开应用触发权限申请弹窗选择「允许」。如果之前拒绝过需要去系统设置里手动开启Android 10 的权限弹窗拒绝两次后不再弹出。第二步在查询代码里加日志打印cursor.getCount()Log.d(CallLog, cursor count (cursor ! null ? cursor.getCount() : -1));第三步用手机拨一个电话挂断后重新触发查询。正常情况下 count 应该大于 0日志里能看到具体的通话记录条数。第四步打印第一条记录的字段值确认数据正确if (cursor ! null cursor.moveToFirst()) { Log.d(CallLog, number cursor.getString(idxNumber) date cursor.getLong(idxDate) type cursor.getInt(idxType)); }实测下来Android 10 真机上只要权限到位查询返回的数据是完整的。CACHED_NAME字段可能为空因为通话记录里缓存的名字依赖通讯录匹配如果联系人没存或者权限没给通讯录这个字段就是 null属于正常现象。如果你同时用 TaoToken 做上传测试可以在拿到 records 后调用接口Request request new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL /v1/chat/completions) .header(Authorization, Bearer BuildConfig.TAOTOKEN_KEY) .header(Content-Type, application/json) .post(RequestBody.create(mediaType, jsonBody)) .build();jsonBody里带上model字段值就是BuildConfig.TAOTOKEN_MODEL。返回结果里choices数组就是模型输出。验证模型是否通可以直接用模型对话页面发一条测试消息地址是https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite确认 Key 和 Model ID 配置正确。成功结果的特征Cursor count 大于 0字段值非空上传接口返回 200响应体里有choices。如果这四步都过了整条链路就通了。5. 常见报错排查401、local proxy failed、reading choices调试期最容易撞上的几个报错我按实际遇到的频率排一下。401 UnauthorizedKey 不对或者没带。检查Authorization头是不是Bearer sk-xxxx格式中间有空格。另外确认 Key 没有过期控制台里可以重新生成。如果 Android 项目里用的是BuildConfig.TAOTOKEN_KEY检查local.properties里的值有没有被引号包住导致多出字符。local proxy failed这个报错通常出现在开发工具链里比如 Claude Code 或 Cline 配置了本地代理但代理没启动。检查settings.json里的ANTHROPIC_BASE_URL是不是写成了本地地址。正确写法是https://taotoken.net/api不要加/v1后缀SDK 会自己拼。如果之前配过其他工具的代理把环境变量里的HTTP_PROXY、HTTPS_PROXY清掉再试。reading choices 报错一般是响应体解析失败。先打印原始响应字符串确认返回的是 JSON 而不是 HTML 错误页。常见原因是 Base URL 拼错比如多了一个斜杠变成//v1或者 Model ID 填了一个不存在的值服务端返回错误结构客户端按choices解析就崩了。检查model字段和实际可用的模型 ID 是否一致。Cursor 返回 null不是权限问题就是 URI 写错。CallLog.Calls.CONTENT_URI是固定值不要自己拼字符串。如果权限已给但 cursor 为 null检查是不是在子线程查询但没处理好异常query()抛异常被 catch 后 cursor 保持 null。count 为 0 但权限已给真机上先确认有没有通话记录。另外检查selection的时间范围如果 begin 和 end 写反了或者单位用成了秒而不是毫秒会筛出空结果。CallLog.Calls.DATE是 13 位毫秒时间戳别传 10 位秒级时间戳。OAuth 相关报错如果你用的是需要 OAuth 的工具链检查 token 有没有过期。TaoToken 的 Key 是直接作为 Bearer token 用的不需要额外的 OAuth 流程。如果工具提示 OAuth 失败大概率是配置里混入了其他认证方式把多余的认证头去掉。排查顺序建议先确认权限再确认 URI 和投影然后确认查询参数最后确认网络请求。每一步都加日志不要靠猜。6. 从调试到长期编码用 Coding Plan 收口通话记录读取本身不复杂复杂的是调试期涉及的多环境、多工具、多 Key 管理。今天调 Android 上传明天调 Claude Code 辅助写代码后天又要验证模型输出如果每个环节都单独配 Key很快就会乱。TaoToken 的 Coding Plan 适合把长期编码和 Agent 场景的调用收口到一个通道里。入口在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。它的价值不是替代编辑器而是让你在 Android Studio、命令行工具、辅助编码工具之间共用一套 Base URL 和 Key减少配置漂移。回到通话记录这条链路我的建议是权限和查询代码按第三节的配置写死不要频繁改调试期的接口调用统一走 TaoToken 的 Key 通道Base URL 固定https://taotoken.net/api真机验证按第四节的四步走每步加日志。遇到报错先看第五节401 查 Keyproxy failed 查 Base URLreading choices 查 Model ID 和响应体。最后留一个实用技巧CallLog.Calls.CACHED_NAME为空时不要急着去查通讯录先确认联系人权限有没有给。很多时候通话记录读到了但名字是空的问题出在READ_CONTACTS没申请而不是 CallLog 本身。这个坑我踩过排查方向容易跑偏。