
1. 通讯录、通话记录、短信读取为什么总是空数据Android 上读取通讯录、通话记录、短信这三类数据代码本身其实不复杂真正让人头疼的是「权限声明了、代码也写了Cursor 却返回 0 行」。我见过太多项目卡在这一步READ_CONTACTS明明加进清单文件query也不报错但cursor.getCount()就是 0。问题往往不在 SQL而在权限模型和运行时校验。从 Android 6.0API 23开始READ_CONTACTS、READ_CALL_LOG、READ_SMS全部属于危险权限dangerous permission必须在运行时动态申请光在AndroidManifest.xml里声明是不够的。更麻烦的是这三类权限分属不同权限组通讯录属于CONTACTS组通话记录属于CALL_LOG组短信属于SMS组。用户拒绝其中任意一个对应的query就会抛SecurityException或者直接返回空游标。还有一个高频坑CallLog.Calls.CACHED_NAME在部分机型上返回 null因为系统没有缓存到联系人姓名这时候需要自己用号码去PhoneLookup反查。短信的person字段同理很多设备上直接是空字符串必须走ContactsContract.PhoneLookup.CONTENT_FILTER_URI补全姓名。这篇内容面向的是正在做 Android 端数据读取、被权限拒绝或空数据卡住的开发者。我会把三类数据的清单声明、运行时请求、逐项验证步骤完整走一遍并且给出排查空数据的对照方法。如果你在接入大模型能力做智能助手、需要把本地通讯数据做语义处理也可以借助 TaoToken 这类聚合入口统一管理模型调用后面会讲到怎么把配置落到工程里。核心检索词先明确Android 读取通讯录、通话记录、短信的权限配置与运行时校验。适合谁做设备管理、备份工具、智能拨号、消息助手的 Android 开发者以及需要把本地数据喂给模型做二次处理的工程同学。先说结论三类数据要读全必须同时满足三个条件——清单声明齐全、运行时权限逐个授予、查询时用对 URI 和字段。缺一个就是空数据。下面按这个顺序拆开讲。2. TaoToken 前置准备与 Android 权限模型对齐在动手写权限代码之前先把「模型侧」的准备工作理清楚因为很多同学读通讯录不是为了本地展示而是要把数据交给大模型做摘要、分类或意图识别。这时候你需要一个稳定的模型调用入口。TaoToken 提供统一的 API 网关把不同模型的调用收敛到一套 Base URL 和 Key 上省得在 Android 工程里维护多套 SDK。先注册并拿到 API Key。访问控制台创建密钥https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission创建完成后在 API Keys 页面复制密钥注意它只在创建时完整显示一次。密钥管理页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission拿到 Key 之后模型调用的 Base URL 统一用https://taotoken.net/api这里要强调一点TaoToken 是合规的模型聚合服务入口不是任何形式的网络中转工具它的作用是把模型调用标准化方便你在客户端统一管理。Android 工程里如果要调用推荐用 OkHttp 或 Retrofit把 Base URL 和 Key 放在BuildConfig或local.properties里不要硬编码进源码。回到权限模型。Android 的危险权限有三个关键概念你需要对齐第一权限组Permission Group。READ_CONTACTS和WRITE_CONTACTS同属CONTACTS组系统弹窗是按组弹的但授权结果要按单个权限查。也就是说用户可能只给了READ_CONTACTS没给WRITE_CONTACTS。第二checkSelfPermission和requestPermissions的配合。前者查当前状态返回PackageManager.PERMISSION_GRANTED或PERMISSION_DENIED后者发起请求结果在onRequestPermissionsResult回调里。第三shouldShowRequestPermissionRationale。当用户第一次拒绝后这个方法返回 true说明你应该解释为什么需要这个权限如果用户勾选了「不再询问」并拒绝它返回 false此时只能引导用户去设置页手动开启。把这三类权限的清单声明先写全这是基础中的基础uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.READ_CALL_LOG / uses-permission android:nameandroid.permission.READ_SMS / uses-permission android:nameandroid.permission.READ_PHONE_STATE /注意READ_PHONE_STATE在读取通话记录时经常被一起需要尤其是要拿CallLog.Calls里的运营商信息时。CALL_PHONE是拨号权限和读取无关别混进来否则应用商店审核会多问。如果你打算把读到的数据交给模型做处理比如把短信内容做意图分类那么模型调用的配置和权限配置是两条独立的线不要混在一个工具类里。权限负责「能不能读」模型负责「读完怎么用」。TaoToken 的接入文档在这里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission文档里有完整的请求示例和参数说明Android 端照着改 Base URL 和 Header 即可。前置准备做到这一步就够了清单声明齐全、Key 拿到手、Base URL 记牢。接下来进入可复制的配置和代码环节。3. 可复制的权限声明与运行时请求配置这一节给你可以直接粘贴的配置和代码。先看清单文件的完整写法建议把三类权限放在一起方便审核和自查manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.datareader uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.READ_CALL_LOG / uses-permission android:nameandroid.permission.READ_SMS / uses-permission android:nameandroid.permission.READ_PHONE_STATE / application android:allowBackuptrue android:labelDataReader activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest清单只是声明运行时请求才是关键。下面这段代码把三类权限打包成一个数组一次性请求回调里逐个判断public class PermissionHelper { public static final int REQ_CODE 1001; public static final String[] REQUIRED_PERMISSIONS new String[]{ Manifest.permission.READ_CONTACTS, Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_SMS }; public static boolean hasAllPermissions(Context context) { for (String p : REQUIRED_PERMISSIONS) { if (ContextCompat.checkSelfPermission(context, p) ! PackageManager.PERMISSION_GRANTED) { return false; } } return true; } public static void requestAll(Activity activity) { ActivityCompat.requestPermissions(activity, REQUIRED_PERMISSIONS, REQ_CODE); } }在 Activity 里发起请求并处理回调Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (PermissionHelper.hasAllPermissions(this)) { loadAllData(); } else { PermissionHelper.requestAll(this); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode ! PermissionHelper.REQ_CODE) return; MapString, Boolean resultMap new HashMap(); for (int i 0; i permissions.length; i) { resultMap.put(permissions[i], grantResults[i] PackageManager.PERMISSION_GRANTED); } boolean contactsOk Boolean.TRUE.equals(resultMap.get(Manifest.permission.READ_CONTACTS)); boolean callLogOk Boolean.TRUE.equals(resultMap.get(Manifest.permission.READ_CALL_LOG)); boolean smsOk Boolean.TRUE.equals(resultMap.get(Manifest.permission.READ_SMS)); if (contactsOk) readContacts(); if (callLogOk) readCallLog(); if (smsOk) readSms(); if (!contactsOk || !callLogOk || !smsOk) { showDeniedTip(resultMap); } }如果你用 Kotlin逻辑一样只是语法更简洁。这里给一个 Kotlin 的权限检查扩展fun Context.hasPermission(permission: String): Boolean ContextCompat.checkSelfPermission(this, permission) PackageManager.PERMISSION_GRANTED fun Activity.requestPermissionsSafely(permissions: ArrayString, code: Int) { ActivityCompat.requestPermissions(this, permissions, code) }关于模型调用的配置如果你要把读到的数据发给模型建议单独建一个ModelConfig类把 Base URL 和 Key 集中管理public class ModelConfig { public static final String BASE_URL https://taotoken.net/api; public static final String API_KEY BuildConfig.TAOTOKEN_API_KEY; public static final String MODEL_ID claude-3-5-sonnet; }Key 通过local.properties注入BuildConfig不要写死在代码里TAOTOKEN_API_KEYsk-你的密钥然后在build.gradle里读取android { buildTypes { debug { buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ } } }这样配置的好处是权限代码和模型代码解耦权限被拒时不影响模型模块编译模型 Key 泄露风险也降到最低。三件套记牢——Base URL 用https://taotoken.net/apiKey 从控制台拿Model ID 按你实际调用的模型填。配置完成后进入验证环节。4. 逐项验证读取结果是否完整配置写完不代表数据能读全。这一节给你逐项验证的方法每一项都给出查询代码和预期结果方便你对照排查。先看通讯录。查询ContactsContract.Contacts.CONTENT_URI拿到联系人 ID再用CommonDataKinds.Phone.CONTENT_URI按CONTACT_ID查号码和姓名private ListString readContacts(Context context) { ListString list new ArrayList(); ContentResolver cr context.getContentResolver(); Cursor cursor cr.query( ContactsContract.Contacts.CONTENT_URI, null, null, null, ContactsContract.Contacts.SORT_KEY_ALTERNATIVE ASC); if (cursor null) return list; while (cursor.moveToNext()) { String contactId cursor.getString( cursor.getColumnIndex(ContactsContract.Contacts._ID)); Cursor phone cr.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.CONTACT_ID contactId, null, null); while (phone ! null phone.moveToNext()) { String number phone.getString(phone.getColumnIndex( ContactsContract.CommonDataKinds.Phone.NUMBER)); String name phone.getString(phone.getColumnIndex( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); list.add(number -- name); } if (phone ! null) phone.close(); } cursor.close(); return list; }预期结果类似13501018080--赵丽颖。验证要点如果list为空先打印cursor.getCount()如果 count 为 0说明权限没给或设备通讯录本身为空如果 count 大于 0 但 list 为空说明内层 phone 查询的CONTACT_ID匹配有问题。再看通话记录。查询CallLog.Calls.CONTENT_URI按日期倒序private ListString readCallLog(Context context) { ListString list new ArrayList(); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()); ContentResolver cr context.getContentResolver(); Cursor cursor null; try { cursor cr.query(CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE desc); if (cursor null) return list; while (cursor.moveToNext()) { String name cursor.getString(cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)); String number cursor.getString(cursor.getColumnIndex(CallLog.Calls.NUMBER)); int type cursor.getInt(cursor.getColumnIndex(CallLog.Calls.TYPE)); long date cursor.getLong(cursor.getColumnIndex(CallLog.Calls.DATE)); long duration cursor.getLong(cursor.getColumnIndex(CallLog.Calls.DURATION)); String location cursor.getString(cursor.getColumnIndex(CallLog.Calls.GEOCODED_LOCATION)); list.add(name - number - type - sdf.format(new Date(date)) - duration - location); } } catch (SecurityException e) { Log.e(CallLog, 权限被拒: e.getMessage()); } finally { if (cursor ! null) cursor.close(); } return list; }预期结果类似赵丽颖--13501018080--1--2016-08-15 07:45:59--120--北京 移动。type字段1 来电、2 去电、3 未接。验证要点CACHED_NAME为 null 是正常的说明系统没缓存姓名需要自己用号码反查GEOCODED_LOCATION在部分机型上为空不影响主流程。最后是短信。查询content://sms按默认排序private ListString readSms(Context context) { ListString list new ArrayList(); ContentResolver cr context.getContentResolver(); Cursor cursor cr.query(Uri.parse(content://sms), null, null, null, Telephony.Sms.DEFAULT_SORT_ORDER); if (cursor null) return list; int nameCol cursor.getColumnIndex(person); int addrCol cursor.getColumnIndex(address); int bodyCol cursor.getColumnIndex(body); int dateCol cursor.getColumnIndex(date); int typeCol cursor.getColumnIndex(type); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()); while (cursor.moveToNext()) { String address cursor.getString(addrCol); String body cursor.getString(bodyCol); long date cursor.getLong(dateCol); String type cursor.getString(typeCol); String name resolveContactName(cr, address); list.add(name - address - body - sdf.format(new Date(date)) - type); } cursor.close(); return list; } private String resolveContactName(ContentResolver cr, String number) { Uri uri Uri.withAppendedPath( ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); Cursor cur cr.query(uri, new String[]{ContactsContract.PhoneLookup.DISPLAY_NAME}, null, null, null); String name 未命名; if (cur ! null) { if (cur.moveToFirst()) { name cur.getString(cur.getColumnIndex( ContactsContract.PhoneLookup.DISPLAY_NAME)); } cur.close(); } return name; }预期结果类似赵丽颖--86 135 0101 8080--今晚八点万达看电影--2016-08-16 11:52:35--1。type字段1 接收、2 发送。验证要点person字段在很多设备上是空的所以必须用PhoneLookup反查姓名否则你只能拿到号码。逐项验证时建议加日志把每类的cursor.getCount()打出来Log.d(Verify, contacts readContacts(this).size() , callLog readCallLog(this).size() , sms readSms(this).size());如果某一类为 0对照下面的排查表定位。5. 权限被拒与空数据常见报错排查这一节按真实报错来对照。你大概率会遇到下面几种情况我逐个给排查路径。第一种java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts.ContactsProvider2 uri ... requires android.permission.READ_CONTACTS。这个报错说明运行时权限没给但代码直接执行了查询。排查在查询前加checkSelfPermission判断或者用 try-catch 包住。注意READ_CONTACTS被拒时ContactsContract的所有查询都会抛这个异常。第二种local proxy failed或网络请求相关报错。如果你在 Android 端调用模型接口时看到类似failed to connect、local proxy failed先检查 Base URL 是否写成了https://taotoken.net/api不要多加路径或斜杠。再检查 Key 是否从控制台正确复制Header 里是否带了Authorization: Bearer sk-xxx。这类报错和权限无关是模型调用配置问题。第三种401 Unauthorized。模型接口返回 401说明 Key 无效或过期。排查去 API Keys 页面确认密钥状态重新生成一个。注意 Key 只在创建时显示一次如果没保存只能重建。Android 端确认BuildConfig.TAOTOKEN_API_KEY是否真的注入了可以在onCreate里打印前几位确认。第四种reading choices相关报错比如解析响应时choices字段为空。这通常是模型返回格式和你解析的字段不匹配。排查先把原始响应体完整打印出来确认返回结构再调整解析逻辑。TaoToken 的响应格式在接入文档里有说明对照着改。第五种Cursor 返回 0 行但不报错。这是最隐蔽的。排查顺序先确认权限状态用checkSelfPermission打印每个权限的授予情况再确认设备上是否真的有数据比如通讯录里是否存了联系人最后确认 URI 和字段名是否正确CallLog.Calls.CONTENT_URI和content://sms不要写错。第六种OAuth相关报错。如果你用的是需要 OAuth 的模型接入方式报错里出现OAuth token字样说明鉴权流程没走完。TaoToken 的 API Key 方式不需要 OAuth直接用 Bearer Token 即可如果你看到 OAuth 报错检查是不是误用了其他接入方式。第七种READ_CALL_LOG在 Android 9 及以上被限制。从 API 28 开始READ_CALL_LOG属于受限制权限部分应用商店要求你说明用途。排查确认targetSdkVersion如果高于 28需要在应用内提供明确的权限用途说明否则系统可能直接拒绝。对照表帮你快速定位报错/现象可能原因排查动作SecurityException运行时权限未授予查询前 checkSelfPermission401 UnauthorizedKey 无效或未注入检查 BuildConfig 和 Key 状态local proxy failedBase URL 写错确认为 https://taotoken.net/apireading choices 为空响应解析字段不匹配打印原始响应体Cursor 0 行不报错权限或数据为空打印权限状态和 getCountOAuth token 报错鉴权方式用错改用 Bearer TokenREAD_CALL_LOG 被拒targetSdk 过高补充用途说明排查时记住一个原则先确认权限再确认数据最后确认代码。权限是前提数据是基础代码是执行。三者顺序不能乱。如果你在排查过程中需要验证模型返回可以用模型对话页面直接测试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission把读到的数据粘贴进去看模型能否正确解析这样能快速区分是权限问题还是模型调用问题。6. 把通讯数据接入模型能力的落地建议三类数据读全之后真正的价值在于怎么用。通讯录、通话记录、短信本质上是结构化的本地数据把它们交给模型做意图识别、自动分类、摘要生成是很多智能助手类应用的核心链路。这一节给你几条落地建议。第一数据脱敏要在客户端做。手机号、短信内容属于敏感信息发给模型前建议做脱敏处理比如手机号中间四位用星号替代或者只发哈希值。模型侧不需要完整号码也能做分类。第二批量处理要控制频率。短信动辄几百条不要一次性全发给模型建议分批每批 20 到 50 条避免请求体过大导致超时。TaoToken 的接口对请求体大小有上限具体看文档说明。第三模型选择按场景来。做意图分类用轻量模型就够做长文本摘要再用大模型。TaoToken 支持多模型切换你可以在ModelConfig里按场景配置不同的 Model ID。第四长期跑 Agent 类任务建议用 Coding Plan。如果你要做的是持续性的编码辅助或 Agent 调度按量计费不如套餐划算https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission第五权限被拒的兜底体验要做好。用户拒绝通讯录权限时应用不应该崩溃或白屏而是给出明确的引导告诉用户去设置页开启。shouldShowRequestPermissionRationale返回 false 时直接跳设置页Intent intent new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts(package, getPackageName(), null)); startActivity(intent);第六验证环节不要省。每次改完权限代码用第 4 节的日志方法跑一遍确认三类数据的 count 都大于 0。如果某一类始终为 0回到第 5 节的排查表逐项对照。最后给一个完整的调用顺序建议先申请权限权限全给后并行读取三类数据读取结果做脱敏再分批发给模型。整个链路里权限是入口模型是出口中间的数据处理是桥梁。把这三段拆开调试比混在一起写要快得多。如果你在接入过程中需要查具体的接口参数接入文档在这里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission需要管理多个 Key 或查看用量去控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentandroid_contacts_permission实测下来把权限配置和模型配置分成两个独立模块排查效率会高很多。权限问题看 Logcat 的 SecurityException模型问题看 HTTP 状态码两条线互不干扰。这套方法我在多个设备管理类项目里用过通讯录、通话记录、短信三类数据的读取成功率能稳定在预期范围内。