ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

获取Android联系人信息:TaoToken统一Key接入与config.toml配置实战

2026/9/26 17:07:50 拓冰建站 浏览量
获取Android联系人信息:TaoToken统一Key接入与config.toml配置实战 1. 从一次联系人读取翻车说起Android 应用读取系统联系人看起来只是ContentResolver.query()一行调用实际落地时坑位相当密集运行时权限没申请导致 Cursor 直接为 null、查询字段映射错位拿到空字符串、多音字排序乱序、批量读取时主线程卡顿。我在一个通讯录备份工具里就遇到过明明READ_CONTACTS写进了 ManifestAndroid 6.0 以上真机跑起来 Cursor 还是空的日志里连个报错都没有。这篇聚焦「Android 应用读取系统联系人」这条完整链路运行时权限申请、ContentResolver 查询、Cursor 字段映射以及把联系人数据交给大模型做清洗/去重/结构化时如何用 TaoToken 统一 Key 接入避免在 Android 工程里散落多家厂商的 API Key。适合正在做通讯录管理、CRM 同步、智能拨号类 App 的 Android 开发者也适合想把联系人数据接进 AI 做语义处理的同学。核心检索词先摆出来Android 获取联系人信息本质是通过ContactsContract契约类 ContentResolver查询系统联系人数据库再逐字段映射到业务模型。下面从配置到验证一步步走通。2. TaoToken 统一 Key 前置准备联系人数据拿到之后很多场景要接大模型比如把备注、公司、昵称拼成一段文本做意图识别或者对重复联系人做语义合并。如果每个模型厂商都单独配 KeyAndroid 工程里的BuildConfig会迅速膨胀还容易把密钥打进 APK。TaoToken 的思路是提供一个统一入口用一把 Key 访问多家模型。对 Android 端来说好处是配置集中、切换模型只改一个字段。你需要先拿到 Key注册并登录后进入控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite在 API Keys 页面创建密钥https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接口基地址统一用https://taotoken.net/api注意Key 只放在服务端或本地local.properties不要硬编码进 Java/Kotlin 源码更不要提交到 Git。Android 端建议走自己的后端转发避免密钥随 APK 分发。如果你只是想先验证模型能不能正常对话可以直接用模型对话页试https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite3. config.toml 配置骨架与权限声明3.1 config.toml 骨架很多同学在 Android 工程里用 TOML 管理构建期配置比如通过 Gradle 插件或自建脚本读取。下面这份骨架把联系人读取和 TaoToken 接入的配置项集中管理可直接复制# config.toml - Android 联系人读取 TaoToken 接入配置 [app] name ContactSync min_sdk 23 # 运行时权限从 23 开始 target_sdk 34 [permission] # 联系人相关权限按需声明 read_contacts true write_contacts false read_phone_numbers true [taotoken] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY # 从环境变量或 local.properties 注入 default_model claude-3-5-sonnet timeout_seconds 30 [contact_query] # 查询排序与投影字段 sort_order DISPLAY_NAME COLLATE LOCALIZED ASC projection [_id, display_name, has_phone_number] batch_size 2003.2 AndroidManifest 权限声明manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.READ_PHONE_NUMBERS / uses-permission android:nameandroid.permission.INTERNET / /manifestManifest 里声明只是「申请资格」Android 6.0API 23以上必须在运行时再次请求否则查询返回空 Cursor。3.3 运行时权限申请private static final int REQ_CONTACTS 1001; private void ensureContactsPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, REQ_CONTACTS); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_CONTACTS) { if (grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { Log.w(ContactSync, 联系人权限被拒绝无法读取); } } }4. ContentResolver 查询与 Cursor 字段映射4.1 主查询拿到联系人列表private void loadContacts() { ContentResolver resolver getContentResolver(); Cursor cur resolver.query( ContactsContract.Contacts.CONTENT_URI, null, null, null, ContactsContract.Contacts.DISPLAY_NAME COLLATE LOCALIZED ASC ); if (cur null) { Log.e(ContactSync, Cursor 为 null通常是权限未授予); return; } try { int idIdx cur.getColumnIndex(ContactsContract.Contacts._ID); int nameIdx cur.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME); int phoneCountIdx cur.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER); while (cur.moveToNext()) { String contactId cur.getString(idIdx); String displayName cur.getString(nameIdx); int phoneCount cur.getInt(phoneCountIdx); Log.i(ContactSync, name displayName , id contactId); if (phoneCount 0) { queryPhones(resolver, contactId); } queryEmails(resolver, contactId); } } finally { cur.close(); } }4.2 子查询电话、邮箱、组织private void queryPhones(ContentResolver resolver, String contactId) { Cursor phones resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.CONTACT_ID ?, new String[]{contactId}, null ); if (phones null) return; try { while (phones.moveToNext()) { String number phones.getString( phones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)); int type phones.getInt( phones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE)); Log.i(ContactSync, phone number , type type); } } finally { phones.close(); } } private void queryEmails(ContentResolver resolver, String contactId) { Cursor emails resolver.query( ContactsContract.CommonDataKinds.Email.CONTENT_URI, null, ContactsContract.CommonDataKinds.Email.CONTACT_ID ?, new String[]{contactId}, null ); if (emails null) return; try { while (emails.moveToNext()) { String email emails.getString( emails.getColumnIndex(ContactsContract.CommonDataKinds.Email.DATA)); Log.i(ContactSync, email email); } } finally { emails.close(); } }4.3 字段映射对照表数据类别CONTENT_URI关键字段说明联系人主表ContactsContract.Contacts.CONTENT_URI_ID, DISPLAY_NAME, HAS_PHONE_NUMBER主查询入口电话CommonDataKinds.Phone.CONTENT_URINUMBER, TYPE, CONTACT_ID用 CONTACT_ID 关联邮箱CommonDataKinds.Email.CONTENT_URIDATA, TYPEDATA 存邮箱值组织Data.CONTENT_URI MIMETYPEOrganization.COMPANY, TITLE需拼 MIMETYPE 条件备注Data.CONTENT_URI MIMETYPENote.NOTE同上昵称Data.CONTENT_URI MIMETYPENickname.NAME同上注意Data.CONTENT_URI查询必须带MIMETYPE条件否则会混入所有数据类型字段映射全乱。4.4 把联系人数据交给 TaoToken 做结构化拿到联系人后如果要做语义去重或标签生成可以拼成 JSON 发给模型。用统一 Key 调用curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, messages: [ {role: user, content: 把以下联系人按公司归类输出 JSON张三/字节跳动/工程师李四/字节跳动/产品} ] }Android 端用 OkHttp 发同样的请求即可Key 从local.properties读取后注入BuildConfig。5. 验证请求与成功结果5.1 验证权限与 Cursor在真机上跑一次观察 Logcatadb logcat -s ContactSync成功时输出类似I/ContactSync: name张三, id1024 I/ContactSync: phone13800000000, type2 I/ContactSync: emailzhangsanexample.com如果只看到Cursor 为 null说明权限没授予回到 3.3 检查。5.2 验证 TaoToken 接入先用模型对话页确认 Key 可用https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite再用 curl 验证接口curl -s -o /dev/null -w %{http_code} \ -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:claude-3-5-sonnet,messages:[{role:user,content:ping}]}返回200即接入正常。接入细节可查文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite5.3 长期编码场景如果你在 Android 工程里长期用 AI 辅助写联系人模块、生成测试用例Coding Plan 更划算配置一次即可在 IDE 里持续调用https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite6. 本篇常见错排查6.1 权限拒绝导致空 Cursor现象query()返回 null或返回 Cursor 但getCount()0。排查动作确认checkSelfPermission返回PERMISSION_GRANTED检查用户是否勾选了「不再询问」此时需引导去设置页部分厂商 ROM 有独立的「联系人权限」开关需在系统设置里单独开if (shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS)) { // 用户拒绝过一次解释为什么需要 }6.2 空 Cursor 与字段错位现象Cursor 不为 null 但getColumnIndex返回 -1取值抛异常或拿到 null。排查动作查询Data.CONTENT_URI时必须带MIMETYPE条件用getColumnIndexOrThrow在调试期暴露问题上线换getColumnIndex加判空投影字段projection传 null 会返回全部列性能差但不易错位生产环境建议显式指定6.3 主线程卡顿联系人数量上千时主线程查询会 ANR。把loadContacts()放到子线程new Thread(this::loadContacts).start();6.4 TaoToken 请求 401现象curl 返回 401。排查Key 是否带Bearer前缀、是否复制完整、环境变量是否生效。用echo $TAOTOKEN_API_KEY确认。6.5 排序乱序COLLATE LOCALIZED ASC对中文按拼音排序但部分 ROM 不支持会退化为 Unicode 排序。可在应用层用Collator.getInstance(Locale.CHINA)二次排序。7. 接入路径与下一步联系人读取这条链路卡点集中在权限和字段映射两处把 3.3 的权限回调和 4.3 的字段对照表吃透基本能跑通。AI 处理环节用 TaoToken 统一 Key省去多厂商配置的麻烦。按你的场景选入口排障/接入问题先看 API Keys 和接入文档https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewritehttps://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite验证模型效果模型对话页https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite长期编码/AgentCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite最后留一个实用技巧联系人查询的 Cursor 一定要在finally里 close否则大量查询会耗尽文件描述符表现为后续查询全部返回 null这个坑比权限问题更隐蔽。