
1. 从一次真机调试说起ContentProvider 到底能拿到什么Android 里的 ContentProvider 是四大组件之一它把数据包装成一张表让别的应用通过content://开头的 Uri 去查询。你可以把它理解成手机内部的一个只读数据库接口系统把音频、视频、联系人、短信、日历这些数据都注册成了 Provider你的 App 只要拿到ContentResolver就能像查 SQLite 一样query出结果。这个能力在两类场景里特别有用。第一类是媒体类 App需要扫描本机所有音频、视频并展示列表第二类是工具类 App需要读取系统数据做二次加工。但真正做起来麻烦往往不在query本身而在后面你想给这些数据接一个 AI 能力比如自动给音频生成标题、给联系人做智能分组就得处理模型调用的鉴权、Key 管理、多工具配置。每个工具一套 Key、一套环境变量改起来很烦。这篇就按ContentProvider 读取 TaoToken 统一 Key 接入这条线走一遍。前半段交付可复制的查询代码和权限配置后半段交付settings.json配置骨架最后用 adb 和日志验证读取结果与鉴权是否生效。适合已经会写基础 Android 页面、想把手上的数据接上 AI 工具链的开发者。2. TaoToken 前置统一 Key 与 API 通道准备TaoToken 在这里扮演的角色是统一入口你不需要为每个 AI 工具单独申请一套凭证而是用同一个 Key 走同一个 API 通道。对 Android 项目来说好处是配置集中——settings.json里写一次本地调试、CI、真机都能复用。先做两件事。第一拿到 Key。打开控制台创建 API Key地址是https://taotoken.net/api-keys创建后复制保存它只显示一次。控制台入口在https://taotoken.net/console。第二确认 API 基地址。所有请求走https://taotoken.net/api注意这个地址不带任何查询参数直接作为 base URL 使用。注意Key 不要硬编码进MainActivity或提交到 Git。Android 项目里推荐放进local.properties或settings.json这类不纳入版本控制的文件构建时再注入。如果你后续要做长期编码或 Agent 类任务可以了解 Coding Plan入口是https://taotoken.net/coding-plan只是想先验证模型通不通用模型对话页面https://taotoken.net/models更快。官网总入口是https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。3. 可复制配置settings.json 骨架与 ContentProvider 查询代码3.1 settings.json 配置骨架这个文件放在项目根目录用来集中管理 API 通道和工具参数。字段名按你的工具链习惯调整结构保持分层即可。{ taotoken: { baseUrl: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY, timeoutMs: 30000, defaultModel: claude-sonnet }, android: { minSdk: 23, targetSdk: 34, permissions: [ android.permission.READ_EXTERNAL_STORAGE, android.permission.READ_MEDIA_AUDIO ] }, contentProvider: { audioUri: content://media/external/audio/media, projection: [_id, display_name, artist, album, duration, data] } }apiKeyEnv指向环境变量名而不是明文 Key。运行时从环境变量读取这样同一份配置可以在不同机器上跑。3.2 权限声明Android 13API 33之后读音频要用READ_MEDIA_AUDIO旧版本仍用READ_EXTERNAL_STORAGE。两个都写上运行时按版本判断。uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO /3.3 ContentProvider 查询代码下面这段是核心拿到ContentResolver用query查出音频列表再逐条读取字段。相比原示例里用两个 Cursor 的做法这里用一个 Cursor 同时取展示字段和路径避免位置错位。public class AudioQueryHelper { public static final Uri AUDIO_URI MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; public static final String[] PROJECTION { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA }; public static ListAudioItem queryAll(Context context) { ListAudioItem result new ArrayList(); ContentResolver cr context.getContentResolver(); try (Cursor cursor cr.query( AUDIO_URI, PROJECTION, null, null, MediaStore.Audio.Media.DISPLAY_NAME ASC)) { if (cursor null) { Log.e(AudioQuery, cursor is null, provider not ready); return result; } int idIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID); int nameIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME); int artistIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST); int albumIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM); int durIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION); int dataIdx cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA); while (cursor.moveToNext()) { AudioItem item new AudioItem(); item.id cursor.getLong(idIdx); item.name cursor.getString(nameIdx); item.artist cursor.getString(artistIdx); item.album cursor.getString(albumIdx); item.duration cursor.getLong(durIdx); item.path cursor.getString(dataIdx); result.add(item); } } catch (SecurityException e) { Log.e(AudioQuery, permission denied: e.getMessage()); } return result; } }几个关键点getColumnIndexOrThrow比getColumnIndex更安全字段不存在会直接抛异常而不是返回 -1try-with-resources保证 Cursor 关闭排序字段用DISPLAY_NAME避免默认顺序在不同机型上不一致。3.4 运行时权限请求private void ensurePermission() { String perm Build.VERSION.SDK_INT 33 ? Manifest.permission.READ_MEDIA_AUDIO : Manifest.permission.READ_EXTERNAL_STORAGE; if (ContextCompat.checkSelfPermission(this, perm) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{perm}, 1001); } else { loadAudio(); } }4. 验证请求adb 与日志确认读取和鉴权都生效4.1 用 adb 验证 ContentProvider 读取不用装 App 也能先验证 Provider 通不通。连上真机后执行adb shell content query --uri content://media/external/audio/media \ --projection _id:display_name:artist:duration如果返回若干行Row: 0 _id..., display_name...说明系统 Provider 正常你的 Uri 和字段名没写错。返回No result found通常是设备里确实没有音频或者权限没给。再验证权限是否真的生效adb shell dumpsys package com.example.audiotest | grep -i READ_MEDIA_AUDIO看到grantedtrue才算通过。4.2 用日志确认查询结果在loadAudio()里加一行统计日志ListAudioItem list AudioQueryHelper.queryAll(this); Log.i(AudioQuery, loaded list.size() audio items); if (!list.isEmpty()) { Log.i(AudioQuery, first: list.get(0).name | list.get(0).path); }过滤日志adb logcat -s AudioQuery预期输出类似I/AudioQuery: loaded 37 audio items I/AudioQuery: first: demo_track.mp3 | /storage/emulated/0/Music/demo_track.mp34.3 验证 TaoToken 鉴权是否生效Key 配好后先用一条最小请求确认通道可用。把 Key 放进环境变量export TAOTOKEN_API_KEY你的Key然后发一条测试请求curl -s https://taotoken.net/api/v1/messages \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:claude-sonnet,max_tokens:64,messages:[{role:user,content:ping}]}返回里带content字段说明鉴权通过。如果返回 401检查 Key 是否复制完整、环境变量是否在当前 shell 生效echo $TAOTOKEN_API_KEY确认非空。返回 404 一般是路径拼错确认 base URL 是https://taotoken.net/api不要多加斜杠或后缀。在 Android 端把同样的请求封装进网络层日志里打印状态码即可Log.i(TaoToken, auth status response.code());状态码 200 表示读取链路和鉴权链路都通了。5. 本篇常见错排查查询返回空 Cursor。先跑adb shell content query确认系统层有数据再检查 App 权限是否grantedtrue。Android 13 上只声明READ_EXTERNAL_STORAGE是不够的必须加READ_MEDIA_AUDIO。getColumnIndexOrThrow抛异常。说明PROJECTION里有字段在当前系统版本不存在。DATA字段在新版本上逐渐被弃用可以改用MediaStore.Audio.Media._ID配合ContentUris.withAppendedId构造 Uri兼容性更好。Cursor 没关闭导致内存泄漏。用try-with-resources或finally里cursor.close()。查询量大时泄漏会很明显。鉴权 401。三种可能Key 复制时带了空格环境变量没导出到运行进程请求头写成Authorization: $KEY少了Bearer前缀。逐个排查。鉴权 404。base URL 写成了https://taotoken.net/api/或https://taotoken.net/api/v1/多加了路径。统一用https://taotoken.net/api具体路径由工具自己拼。真机连不上 adb。先adb devices确认设备在线离线就adb kill-server adb start-server重来。6. 把两条链路接起来到这里ContentProvider 的读取链路和 TaoToken 的鉴权链路各自都验证过了。接下来要做的是把queryAll拿到的音频列表喂给模型做二次处理比如批量生成摘要或分类标签。这一步的接入细节可以对照接入文档https://taotoken.net/doc里的请求格式来写Key 管理仍在 API Keys 页面https://taotoken.net/api-keys。如果你打算把这个能力做成长期跑的编码或 Agent 任务Coding Plan 的入口在https://taotoken.net/coding-plan配置方式和上面settings.json骨架一致把baseUrl和apiKeyEnv指过去就行。Claude Code 相关的接入说明在https://taotoken.net/claudecode。实测下来最容易踩的坑不是代码本身而是权限版本差异和 base URL 多写一个斜杠。把这两处固定住剩下的就是按字段名取数据、按状态码判断鉴权链路很直。