☰
Android 读取联系人和通话记录:TaoToken 统一 Key 接入实战大纲
2026/10/8 6:31:26 网站建设 项目流程

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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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.gradle:

dependencies { 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.gradle:

android { 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:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.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): List<ContactItem> { val result = mutableListOf<ContactItem>() 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): List<String> { val phones = mutableListOf<String>() 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): List<CallItem> { val result = mutableListOf<CallItem>() 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/completions,Authorization 用 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}") }

正常真机上应该能打出几十到几百条。如果 count=0,先确认手机里确实有联系人,再检查是不是查错了 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 " 加 Key,Bearer 后面有一个空格;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 或 count=0。现象是权限有,但查不到数据。可能原因:查错了 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 层。网络层单独抽一个 TaoTokenClient,Base 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 记得换成你自己的。

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

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

立即咨询