
1. Android 读取联系人和通话记录为什么总在真机翻车Android 读取联系人和通话记录这件事看起来就是两个 ContentResolver 查询但真正落到真机上十有八九会卡在权限、厂商定制、字段缺失这三道坎上。我见过太多项目在模拟器上跑得飞起装到真机就返回空 Cursor或者直接抛 SecurityException。核心检索词先摆出来Android 读取联系人和通话记录本质是通过 ContentResolver 访问系统提供的 ContactsContract 和 CallLog 两个内容提供者前者管联系人后者管通话记录而它们都受运行时权限和厂商 ROM 的双重约束。适合谁看这篇如果你正在做通讯录备份、来电识别、客服工单自动关联、或者企业内部设备管理这类功能需要把本机联系人和通话记录读出来同时还想用一套统一的 Key 通道去调用后端 AI 能力做号码标注、通话摘要、联系人去重那这篇就是给你写的。我会把权限声明、查询代码、TaoToken 统一 Key 的配置片段、真机验证步骤、以及常见报错排查全部串起来你照着改包名就能跑。先说清楚一个前提读取联系人和通话记录属于敏感权限Google Play 和国内应用市场都要求你说明用途代码层面必须动态申请不能只写 Manifest。很多人第一步就错了只在 AndroidManifest.xml 里声明 READ_CONTACTS 和 READ_CALL_LOG然后直接 query结果 Android 6.0 以上直接崩。正确姿势是声明加运行时申请两步走而且通话记录在部分厂商 ROM 上还需要额外的默认拨号器角色或者用户手动授权这个后面排障章节会细讲。再讲数据链路。联系人不是一张表ContactsContract 把数据拆成了 Contacts、RawContacts、Data 三层你查 Contacts.CONTENT_URI 只能拿到联系人 ID 和显示名电话号码、邮箱、地址都在 Data 表里通过 CONTACT_ID 关联。通话记录相对简单CallLog.Calls.CONTENT_URI 一张表搞定字段有 NUMBER、CACHED_NAME、TYPE、DATE、DURATION。理解了这个结构你才不会写出「查一次联系人拿不到电话」这种低级问题。最后说 TaoToken 的角色。读取本身是本地系统能力不需要联网但读完之后的号码归属查询、通话内容摘要、联系人智能合并这些往往要调大模型。TaoToken 在这里提供的是统一 Key 和统一 Base URL让你不用为每个模型单独管一套鉴权。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。下面进入实操。2. TaoToken 统一 Key 前置准备与 Android 工程接入这一章把 TaoToken 的 Key 拿到手并且把 Android 工程的网络层和权限层搭好。为什么读取联系人和通话记录要配 TaoToken因为纯读取你不需要它但一旦你要把读到的号码送去模型做标注、把通话记录做摘要就需要一个稳定的鉴权通道。TaoToken 的统一 Key 让你在 Android 端只维护一个 API Key切换模型只改 Model ID不用动鉴权代码。第一步注册并创建 Key。打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 登录后进入控制台。控制台地址是 https://taotoken.net/console 在 API Keys 页面创建一个新 Key复制保存。API Keys 直达链接是 https://taotoken.net/api-keys 建议直接收藏。创建时注意权限范围如果你只是做模型调用选默认的调用权限即可不要开管理权限Android 端泄露风险要控制到最小。第二步确认 Base URL 和 Model ID。Base URL 统一用 https://taotoken.net/api 这是 OpenAI 兼容格式的入口Android 端用 OkHttp 或者 Retrofit 都能直接对接。Model ID 根据你的场景选做号码标注和文本摘要用通用对话模型即可具体可用模型列表在文档里查文档地址 https://taotoken.net/doc 。如果你后面要做长期编码或者 Agent 类任务可以了解 Coding Plan入口 https://taotoken.net/coding-plan 。第三步Android 工程加网络权限和依赖。在 AndroidManifest.xml 里加 INTERNET 权限这是调 TaoToken 的前提。然后加 OkHttp 和 Gson 依赖用 Kotlin 的话加协程。这里给一份 build.gradle 片段路径是 app/build.gradledependencies { implementation com.squareup.okhttp3:okhttp:4.12.0 implementation com.google.code.gson:gson:2.10.1 implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 }第四步把 Key 存到安全位置。绝对不要硬编码在代码里也不要用明文 SharedPreferences。推荐用 Android Keystore 加密后存 EncryptedSharedPreferences或者放在 BuildConfig 里通过 gradle 属性注入。这里给一个 gradle 属性注入的写法路径是 app/build.gradleandroid { defaultConfig { buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ buildConfigField String, TAOTOKEN_BASE_URL, \https://taotoken.net/api\ } }然后在 gradle.properties 里写 TAOTOKEN_API_KEY你的Key这个文件不要提交到 git。这样代码里用 BuildConfig.TAOTOKEN_API_KEY 就能取到既方便又相对安全。第五步权限声明。读取联系人和通话记录需要四个权限READ_CONTACTS、READ_CALL_LOG如果要写入还要 WRITE_CONTACTS 和 WRITE_CALL_LOG但本篇只读所以只声明前两个。AndroidManifest.xml 片段uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.READ_CALL_LOG /注意 READ_CALL_LOG 在 Android 9 以上属于危险权限组部分厂商 ROM 会额外弹窗甚至要求应用成为默认电话应用才给读。这个不是代码能绕过的属于系统策略后面排障会讲怎么引导用户。到这里前置就齐了Key 有了Base URL 有了Model ID 知道去哪查工程依赖和权限声明也写好了。下一章进入可复制的配置和查询代码。3. 可复制配置权限申请、ContentResolver 查询与 TaoToken 调用片段这一章是全文最核心的部分所有代码都可以直接复制改包名使用。我按「权限申请 → 联系人查询 → 通话记录查询 → TaoToken 调用」四段来写每段都给完整片段。先看权限申请。用 ActivityResultContracts 的方式Kotlin 写法放在 Activity 或 Fragment 里private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - val contactsGranted result[Manifest.permission.READ_CONTACTS] true val callLogGranted result[Manifest.permission.READ_CALL_LOG] true if (contactsGranted callLogGranted) { loadContactsAndCalls() } else { Toast.makeText(this, 需要联系人和通话记录权限才能继续, Toast.LENGTH_LONG).show() } } private fun requestPermissions() { permissionLauncher.launch(arrayOf( Manifest.permission.READ_CONTACTS, Manifest.permission.READ_CALL_LOG )) }这段的关键是 RequestMultiplePermissions 一次申请两个回调里分别判断。不要用 requestPermissions 老 API回调索引容易错。再看联系人查询。这里要纠正一个常见错误只查 Contacts.CONTENT_URI 拿不到电话号码。正确做法是先查联系人 ID 和显示名再用 CONTACT_ID 去 Phone.CONTENT_URI 查号码。完整片段fun queryContacts(context: Context): ListContactItem { val result mutableListOfContactItem() val cr context.contentResolver val projection arrayOf( ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME ) val cursor cr.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, null ) ?: return result cursor.use { c - while (c.moveToNext()) { val contactId c.getString(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) val name c.getString(c.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME)) ?: val phones queryPhones(cr, contactId) result.add(ContactItem(contactId, name, phones)) } } return result } private fun queryPhones(cr: ContentResolver, contactId: String): ListString { val phones mutableListOfString() val projection arrayOf( ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE ) val cursor cr.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, ContactsContract.CommonDataKinds.Phone.CONTACT_ID ?, arrayOf(contactId), null ) ?: return phones cursor.use { c - while (c.moveToNext()) { val number c.getString(c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) phones.add(number) } } return phones }注意 selection 用 ? 加 selectionArgs不要用字符串拼接否则 contactId 里如果有特殊字符会出问题也容易触发 SQL 注入告警。通话记录查询片段fun queryCallLogs(context: Context): ListCallItem { val result mutableListOfCallItem() val cr context.contentResolver val projection arrayOf( CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION ) val cursor cr.query( CallLog.Calls.CONTENT_URI, projection, null, null, CallLog.Calls.DEFAULT_SORT_ORDER ) ?: return result val sdf SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()) cursor.use { c - while (c.moveToNext()) { val number c.getString(c.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) ?: val name c.getString(c.getColumnIndexOrThrow(CallLog.Calls.CACHED_NAME)) ?: val type c.getInt(c.getColumnIndexOrThrow(CallLog.Calls.TYPE)) val date c.getLong(c.getColumnIndexOrThrow(CallLog.Calls.DATE)) val duration c.getLong(c.getColumnIndexOrThrow(CallLog.Calls.DURATION)) result.add(CallItem(number, name, type, sdf.format(Date(date)), duration)) } } return result }这里 DATE 用 getLong 而不是 getString 再 parseLong少一次转换也更稳。DEFAULT_SORT_ORDER 是按时间倒序最新的在前。最后是 TaoToken 调用片段。把读到的号码送去模型做标注用 OkHttp 发一个 OpenAI 兼容格式的请求suspend fun annotateNumber(number: String): String withContext(Dispatchers.IO) { val client OkHttpClient() val json JSONObject().apply { put(model, 你的ModelID) put(messages, JSONArray().apply { put(JSONObject().apply { put(role, user) put(content, 请判断这个号码 $number 可能的归属类型只回一个词) }) }) } val body json.toString().toRequestBody(application/json.toMediaType()) val request Request.Builder() .url(https://taotoken.net/api/v1/chat/completions) .addHeader(Authorization, Bearer ${BuildConfig.TAOTOKEN_API_KEY}) .addHeader(Content-Type, application/json) .post(body) .build() client.newCall(request).execute().use { resp - resp.body?.string() ?: } }注意 Base URL 是 https://taotoken.net/api 拼接路径是 /v1/chat/completionsAuthorization 用 Bearer 加 Key。Model ID 换成你在文档里查到的实际值。这段跑通说明你的 Key 和网络层都没问题。4. 真机验证读取结果与 TaoToken 请求成功怎么确认代码写完不算完必须真机验证。这一章给你一套可执行的验证步骤从权限到数据到网络逐层确认。第一步验证权限是否真的授予。在 loadContactsAndCalls 开头加日志val contactsOk ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED val callLogOk ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) PackageManager.PERMISSION_GRANTED Log.d(PermCheck, contacts$contactsOk callLog$callLogOk)跑起来看 Logcat两个都 true 才继续。如果 callLog 是 false去系统设置里手动开部分 ROM 的权限开关藏得很深。第二步验证联系人数量。查询完打印val contacts queryContacts(this) Log.d(ContactCheck, count${contacts.size}) contacts.take(3).forEach { Log.d(ContactCheck, name${it.name} phones${it.phones}) }正常真机上应该能打出几十到几百条。如果 count0先确认手机里确实有联系人再检查是不是查错了 URI。第三步验证通话记录。同样打印val calls queryCallLogs(this) Log.d(CallCheck, count${calls.size}) calls.take(3).forEach { Log.d(CallCheck, num${it.number} type${it.type} time${it.time}) }type 的值对照1 是来电2 是去电3 是未接4 是语音信箱5 是拒接6 是拦截。看到这些值说明字段解析正确。第四步验证 TaoToken 请求。用一个真实号码调 annotateNumber看返回。成功的话你会拿到模型返回的文本。如果返回空或者报错看下一章排障。这里建议先用模型对话页面手动测一次同样的请求确认 Key 和 Model ID 没问题模型对话入口 https://taotoken.net/models 在里面发一条消息看能不能通。第五步端到端串起来。把联系人查询结果里的第一个号码送去 annotateNumber把返回结果写回 UI。这一步跑通说明「本地读取 统一 Key 调用」整条链路是通的。验证时有个细节Android 10 以上默认开启分区存储但联系人和通话记录走的是 ContentProvider不受分区存储影响所以不用申请 MANAGE_EXTERNAL_STORAGE别被网上一些文章带偏。5. 常见报错排查401、Cursor 为空、SecurityException 与 OAuth 问题这一章按真实报错来每个都给你现象、原因、解法。报错一401 Unauthorized。现象是 TaoToken 请求返回 401。原因通常是 Key 写错、Key 被删、或者 Authorization 头格式不对。检查三点Key 有没有多余空格头是不是 Bearer 加 KeyBearer 后面有一个空格Base URL 是不是 https://taotoken.net/api 有没有多写或少写路径。如果还不行去 API Keys 页面重新生成一个 Key 试。API Keys 入口 https://taotoken.net/api-keys 。报错二local proxy failed。这个报错一般出现在你本地配了代理工具或者抓包工具的时候请求发不出去。Android 端如果 OkHttp 配了 Proxy或者模拟器走了宿主代理就会报这个。解法是去掉 OkHttp 的 proxy 配置真机测试时关掉系统里的手动代理。注意这里说的是本地网络配置问题不是让你去用什么特殊网络工具正常直连即可。报错三reading choices 相关解析错误。现象是请求返回了内容但解析 JSON 时报找不到 choices 字段。原因通常是返回体不是标准 OpenAI 格式或者你请求的路径不对。确认路径是 /v1/chat/completions确认返回体里有 choices 数组。如果返回的是错误信息先看 message 字段。报错四SecurityException: Permission Denial。现象是 query 时直接抛异常。原因是权限没授予就查询。解法是先检查 checkSelfPermission再 query。另外注意即使 Manifest 声明了Android 6.0 以上不动态申请一样会抛。报错五Cursor 返回 null 或 count0。现象是权限有但查不到数据。可能原因查错了 URI比如用 Contacts.CONTENT_URI 去查电话selection 拼接错误导致条件不匹配厂商 ROM 限制了第三方应用读取。逐个排查先用最简单的 query 不带 selection 试。报错六通话记录读不到提示需要默认拨号器。这是 Android 9 以上部分 ROM 的策略READ_CALL_LOG 对非默认拨号器应用限制。解法是引导用户去设置里把应用设为默认电话应用或者申请 ROLE_DIALER。这个不是代码 bug是系统策略要在 UI 上给用户明确提示。报错七OAuth 相关错误。如果你用的是需要 OAuth 的模型通道可能会遇到 token 过期。TaoToken 统一 Key 的好处就是不用管各家 OAuth一个 Key 走天下。如果确实遇到 OAuth 报错检查是不是误用了需要 OAuth 的直连方式换回统一 Key 即可。排查通用方法先看 Logcat 完整堆栈再看 HTTP 响应码和响应体最后用模型对话页面手动复现。三步定位基本没有解决不了的。6. 把读取链路和统一 Key 沉淀成可复用模块走到这里你已经有了权限申请、联系人查询、通话记录查询、TaoToken 调用四块可复制代码也知道了七类常见报错怎么解。最后说怎么把它沉淀成可复用模块避免每个项目重写一遍。建议抽一个 DataRepository 类把 queryContacts、queryCallLogs、annotateNumber 三个方法收进去权限申请留在 UI 层。网络层单独抽一个 TaoTokenClientBase URL 和 Key 从 BuildConfig 读Model ID 做成可配置参数。这样换模型只改一个参数换 Key 只改 gradle.properties。如果你后面要做更复杂的 Agent 任务比如自动整理通话记录生成周报、联系人智能分组可以了解 Coding Plan入口 https://taotoken.net/coding-plan 它更适合长期编码和 Agent 场景。接入文档在 https://taotoken.net/doc 遇到接口细节以文档为准。实测下来这套组合在主流国产 ROM 和原生 Android 上都能跑通唯一需要用户配合的就是通话记录权限在部分机型上要手动开。把权限引导做友好比什么都强。代码直接拿去改包名就能用Model ID 记得换成你自己的。