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 查询,不确定权限、投影、游标遍历该怎么写的。核心链路就是CallLog+ContentResolver+Cursor三件套,我会给出可直接复制的权限声明、查询配置和游标遍历代码,并附真机验证步骤。
另外,调试期如果涉及接口调用管理,比如把通话记录上传到自己的服务端做测试,可以用 TaoToken 统一管理 Key 和 API 通道,避免在多个测试环境里散落硬编码的密钥。这部分我会在第三节给出配置片段。
先说结论:Android 10 上读取通话记录本身是可行的,前提是权限申请到位、查询参数正确、游标用完即关。下面按完整链路拆开讲。
2. TaoToken 统一 Key 通道在调试期的接入准备
调试通话记录上传功能时,通常会遇到一个尴尬:测试环境的接口 Key 散落在BuildConfig、local.properties、甚至直接写在代码里。换一个测试同学、换一台机器,就要重新配一遍。TaoToken 的思路是把模型调用和接口调用的 Key 统一到一个通道里管理,调试期只维护一份配置。
你需要先拿到一个可用的 Key。进入控制台创建 API Key,地址是https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。创建后复制出来,注意只显示一次。如果你还没注册,官网入口在https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
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.key=sk-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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,里面有各语言的调用示例。
准备工作做完,接下来进入通话记录读取的正题。
3. CallLog + ContentResolver 可复制配置与查询投影
权限声明是第一步。在AndroidManifest.xml里加两行:
<uses-permission android:name="android.permission.READ_CALL_LOG" /> <uses-permission android:name="android.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 + " <= ?"; List<String> 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"。
游标遍历时,用列名取值比用索引更安全,因为投影顺序可能变。遍历代码:
List<CallRecord> 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,确认 Key 和 Model ID 配置正确。
成功结果的特征:Cursor count 大于 0,字段值非空,上传接口返回 200,响应体里有choices。如果这四步都过了,整条链路就通了。
5. 常见报错排查:401、local proxy failed、reading choices
调试期最容易撞上的几个报错,我按实际遇到的频率排一下。
401 Unauthorized:Key 不对或者没带。检查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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。它的价值不是替代编辑器,而是让你在 Android Studio、命令行工具、辅助编码工具之间共用一套 Base URL 和 Key,减少配置漂移。
回到通话记录这条链路,我的建议是:权限和查询代码按第三节的配置写死,不要频繁改;调试期的接口调用统一走 TaoToken 的 Key 通道,Base URL 固定https://taotoken.net/api;真机验证按第四节的四步走,每步加日志。遇到报错先看第五节,401 查 Key,proxy failed 查 Base URL,reading choices 查 Model ID 和响应体。
最后留一个实用技巧:CallLog.Calls.CACHED_NAME为空时,不要急着去查通讯录,先确认联系人权限有没有给。很多时候通话记录读到了,但名字是空的,问题出在READ_CONTACTS没申请,而不是 CallLog 本身。这个坑我踩过,排查方向容易跑偏。