1. Android 原生分享踩坑现场:Intent 与 MediaStore Uri 到底怎么配合
原生分享在 Android 上看起来只是几行startActivity,真到线上却经常翻车:微信里点开是空白、系统相册能预览但第三方 App 报「无法读取文件」、Android 10 之后FileUriExposedException直接崩。核心矛盾就一个——你传给别的 App 的 Uri,对方有没有权限读。file://在 Android 7.0 之后被StrictMode拦截,content://才是正路,而content://又分两种来源:FileProvider 映射的私有目录,和 MediaStore 索引的公共媒体库。这篇就围绕后者,把「原生分享 + MediaStore Uri」这条链路拆开讲清楚,顺带把 endpoint 切到 TaoToken 后做一次分享回调验证,确认整条链路是通的。
先说清楚这篇适合谁:正在写 Android 分享功能、被FileUriExposedException或SecurityException卡住的开发者;想把图片/文件通过系统分享面板发给微信、QQ、钉钉、邮件的人;以及已经接了模型能力、想把「生成结果 → 原生分享」串成闭环的团队。你会拿到可直接复制的 Intent 配置、MediaStore Uri 获取步骤、权限声明,以及一套排障对照表。
为什么 MediaStore 比 FileProvider 更适合分享场景?FileProvider 的content://授权是「点对点」的,你得在AndroidManifest.xml里声明provider和file_paths,还要手动grantUriPermission给目标包名,一旦目标 App 没在授权列表里就失败。MediaStore 不一样,它索引的是公共媒体库,content://media/external/images/media/12345这种 Uri 天然带FLAG_GRANT_READ_URI_PERMISSION就能被系统分享面板转发给任意接收方,不需要你预先知道对方包名。代价是:文件得先「进库」,也就是让 MediaStore 知道这个文件存在。
这里有个很多人忽略的点:MediaStore.Images.Media.DATA这个列在 Android 10(API 29)之后被标记为废弃,分区存储(Scoped Storage)下你不能再靠绝对路径去查库。所以老代码里那种「用DATA + "=?"查_ID」的写法,在新系统上会查不到、返回 null,然后走到insert分支,运气不好还会因为路径不可写而抛异常。正确姿势是按 API 等级分流:低版本走 DATA 查询,高版本走MediaStore的RELATIVE_PATH+IS_PENDING插入流程。
再补一个真实场景:分享的不只是本地已有图片,还可能是 App 刚下载或刚生成的临时文件。临时文件在getExternalFilesDir或cacheDir里,这些目录不在 MediaStore 索引范围内,直接构造content://media/...是无效的。要么先把它写进公共媒体库(Pictures/或Download/),要么退回 FileProvider。判断标准很简单:文件要不要长期留在系统相册/下载目录里给用户看?要,就走 MediaStore;只是临时中转,走 FileProvider 更干净。
理解了这层,后面的配置就有方向了:先解决「文件怎么进 MediaStore」,再解决「Intent 怎么带权限」,最后解决「endpoint 切到 TaoToken 后回调怎么验证」。
2. TaoToken 前置准备:Base URL、API Key 与 Model ID 三件套
在动手改分享回调之前,先把 TaoToken 的接入信息备齐。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 根地址是 https://taotoken.net/api (这个地址不加 UTM 参数,直接用于代码里的 Base URL)。你需要准备三样东西,缺一不可:
第一是Base URL。所有 OpenAI 兼容的 SDK 或 HTTP 请求,都把base_url指向https://taotoken.net/api。注意结尾不要多加/v1之外的斜杠,很多 SDK 会自己拼/chat/completions,多一层斜杠会 404。
第二是API Key。到控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建后立刻复制,页面刷新就不再完整显示。Key 的形态通常是sk-开头的一串字符,别把它硬编码进 APK,放local.properties或服务端下发。
第三是Model ID。这个必须和你账号里可用的模型名完全一致,大小写敏感。常见的有claude-sonnet-4-5、gpt-4o这类,具体以控制台模型列表为准。写错 Model ID 的典型报错是model not found或invalid model,不是 401,别搞混。
如果你用的是 Claude Code 这类命令行工具,配置方式略有不同,需要设置ANTHROPIC_BASE_URL和ANTHROPIC_AUTH_TOKEN两个环境变量,文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 有完整说明。Android 端我们走标准 HTTP,不涉及这套。
想先不写代码、直接在网页上验证 Key 和 Model 是否配对,可以打开模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,发一句「你好」看有没有正常返回。这一步能省掉后面大量「到底是网络问题还是 Key 问题」的排查时间。
长期做编码或 Agent 场景的,可以了解下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,这里不展开,本文聚焦分享链路。
把这三件套记在一个地方:Base URL =https://taotoken.net/api,API Key = 控制台创建的那串,Model ID = 控制台模型列表里的准确名称。后面第 3 节的配置片段会直接引用它们。
3. 可复制配置:Intent 片段、MediaStore Uri 获取与 endpoint 设置
这一节是全文的核心,所有代码都可以直接抄。先给 MediaStore Uri 的获取方法,按 API 等级分流,这是整条链路的地基。
public static Uri getImageContentUri(Context context, File imageFile) { String filePath = imageFile.getAbsolutePath(); // Android 10 以下:DATA 列可用,直接查库 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { Cursor cursor = context.getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Images.Media._ID}, MediaStore.Images.Media.DATA + "=? ", new String[]{filePath}, null); if (cursor != null && cursor.moveToFirst()) { int id = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); cursor.close(); return Uri.withAppendedPath( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id)); } if (cursor != null) cursor.close(); } // Android 10+ 或查不到:插入媒体库 if (imageFile.exists()) { ContentValues values = new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.getName()); values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/ShareDemo"); values.put(MediaStore.Images.Media.IS_PENDING, 1); } else { values.put(MediaStore.Images.Media.DATA, filePath); } Uri uri = context.getContentResolver().insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); if (uri == null) return null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // 写入真实数据后解除 pending try (OutputStream os = context.getContentResolver().openOutputStream(uri)) { try (InputStream is = new FileInputStream(imageFile)) { byte[] buf = new byte[8192]; int len; while ((len = is.read(buf)) != -1) os.write(buf, 0, len); } } catch (IOException e) { return null; } values.clear(); values.put(MediaStore.Images.Media.IS_PENDING, 0); context.getContentResolver().update(uri, values, null, null); } return uri; } return null; }注意IS_PENDING这个机制:插入时置 1,其他 App 看不到这条记录;数据写完置 0,才对外可见。如果你忘了置 0,分享出去对方打开就是空文件,这是 Android 10+ 上最隐蔽的坑之一。
接着是 Intent 配置片段,单图和多图分开写:
// 单图分享 public static void shareSingleImage(Context context, File file) { Uri uri = getImageContentUri(context, file); if (uri == null) return; Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("image/png"); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); context.startActivity(Intent.createChooser(intent, "分享到")); } // 多图分享 public static void shareMultipleImages(Context context, ArrayList<File> files) { ArrayList<Uri> uris = new ArrayList<>(); for (File f : files) { Uri uri = getImageContentUri(context, f); if (uri != null) uris.add(uri); } if (uris.isEmpty()) return; Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); intent.setType("image/*"); intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); context.startActivity(Intent.createChooser(intent, "分享到")); }两个细节:setType用image/*比image/png兼容性更好,微信对具体 MIME 有时挑食;FLAG_GRANT_WRITE_URI_PERMISSION分享场景基本不需要,加了反而可能让某些接收方误判,只留 READ 就够。
权限声明别忘了,Android 13(API 33)之后读媒体库要细分权限:
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />运行时请求也要按版本分流,READ_MEDIA_IMAGES在 33+ 才存在,低版本请求它会直接返回 denied。
最后是 endpoint 配置。把模型请求的 Base URL 指向 TaoToken,用一份可复制的 JSON 配置(比如放在assets/ai_config.json):
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model_id": "claude-sonnet-4-5", "timeout_seconds": 60, "share_after_generate": true }如果你用 Gradle 的BuildConfig注入,可以在build.gradle里这样写:
android { defaultConfig { buildConfigField "String", "AI_BASE_URL", "\"https://taotoken.net/api\"" buildConfigField "String", "AI_MODEL_ID", "\"claude-sonnet-4-5\"" } }Key 不要进BuildConfig,从服务端下发或本地加密存储。三件套齐了:Base URL、Key、Model ID,缺任何一个都会在下一节验证时暴露。
4. 验证请求与成功结果:从生成到分享回调跑通一次
配置写完,得真跑一次才算数。验证分两段:先确认模型请求通,再确认分享回调通。
第一段,用 OkHttp 发一个最小请求,确认 Base URL 和 Key 没问题:
OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build(); String json = "{\"model\":\"" + BuildConfig.AI_MODEL_ID + "\"," + "\"messages\":[{\"role\":\"user\",\"content\":\"生成一句分享文案\"}]}"; Request request = new Request.Builder() .url(BuildConfig.AI_BASE_URL + "/v1/chat/completions") .addHeader("Authorization", "Bearer " + apiKey) .addHeader("Content-Type", "application/json") .post(RequestBody.create(json, MediaType.parse("application/json"))) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { Log.e("ShareDemo", "请求失败: " + e.getMessage()); } @Override public void onResponse(Call call, Response response) throws IOException { if (response.isSuccessful()) { String body = response.body().string(); Log.i("ShareDemo", "返回: " + body); // 解析出文案后写入文件,再触发分享 } else { Log.e("ShareDemo", "HTTP " + response.code() + " " + response.body().string()); } } });成功时你会看到类似这样的返回结构,choices[0].message.content就是文案:
{ "id": "chatcmpl-xxx", "object": "chat.completion", "model": "claude-sonnet-4-5", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "今天也要好好生活呀" }, "finish_reason": "stop" } ] }第二段,把返回的文案写进一张图片(或直接分享文本文件),走第 3 节的shareSingleImage,观察系统分享面板是否弹出、目标 App 能否正常读取。判断成功的标准有三个:分享面板正常弹出且列出微信/QQ/邮件等目标;选中目标后对方能预览到内容,不是空白;logcat里没有SecurityException或FileUriExposedException。
我试过在 Android 13 真机上跑这套流程,第一次分享出去微信显示「图片已过期」,排查发现是IS_PENDING没置 0,改成写完数据后update一次就正常了。这个现象很有代表性:系统相册能看(因为相册有特殊权限),第三方 App 看不到,问题一定出在 Uri 授权或 pending 状态上。
如果你只想快速验证 endpoint 通不通,不想写 Android 代码,直接打开模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 发一条消息,能返回就说明 Key 和 Model ID 是对的,剩下的就是 Android 侧的问题。
验证通过后,整条链路就是:请求 TaoToken 生成内容 → 写入文件 → 进 MediaStore 拿 Uri → Intent 带 READ 权限 → 系统分享面板 → 目标 App 读取。任何一环断了,下一节的排障表能帮你定位。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
排障这块按报错原文对照,遇到哪个查哪个。
401 Unauthorized。两种可能:Key 写错或没带上。检查Authorization头是不是Bearer sk-xxx格式,中间一个空格,别写成Bearer: sk-xxx。还有一种隐蔽情况:Key 复制时带了首尾空格或换行,trim()一下。如果确认 Key 没问题还是 401,去控制台 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 看这个 Key 是不是被禁用或删除了。
local proxy failed。这个报错通常出现在你本地配了 HTTP 代理,但代理进程没起来或端口不对。Android 模拟器里10.0.2.2是宿主机,如果你在模拟器里访问本机代理,地址要写对。更常见的是 OkHttp 的Proxy配置残留,检查OkHttpClient.Builder().proxy(...)有没有设了失效的代理。清掉代理配置,直连https://taotoken.net/api即可。
reading choices 相关报错,比如Cannot read field "choices" because "response" is null或IndexOutOfBoundsException: Index 0 out of bounds for length 0。这说明返回体里没有choices数组,通常是上游返回了错误 JSON 但 HTTP 状态码是 200。打印完整response.body().string()看内容,多半是{"error":{"message":"..."}}结构。常见原因:Model ID 写错、请求体 JSON 格式错误(比如少了引号)、messages数组为空。解析前先判空:
JSONObject obj = new JSONObject(body); if (!obj.has("choices") || obj.getJSONArray("choices").length() == 0) { Log.e("ShareDemo", "无 choices: " + body); return; }OAuth 相关报错,比如invalid_grant、OAuth token expired。如果你用的是 Claude Code 或某些 CLI 工具,它们可能走 OAuth 流程而非 API Key。Android 端标准 HTTP 不走 OAuth,遇到这类报错说明你误用了 CLI 的配置。检查是不是把ANTHROPIC_AUTH_TOKEN和 API Key 搞混了,两者不通用。CLI 的配置看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
FileUriExposedException。这是分享侧最常见的崩溃,堆栈里会明确写file://Uri 暴露。根因是你用了Uri.fromFile(file)。全部替换成第 3 节的getImageContentUri,确保拿到的是content://开头。
SecurityException: Permission Denial。Intent 带了 Uri 但没加FLAG_GRANT_READ_URI_PERMISSION,或者加了但目标 App 不支持。检查addFlags那行有没有漏。多图分享时每个 Uri 都要能被授权,ACTION_SEND_MULTIPLE会自动处理,不用逐个 grant。
分享出去是空白/0 字节。Android 10+ 的IS_PENDING没置 0,或者数据没真正写入。按第 3 节的写法,写完OutputStream再update置 0。
微信提示「不支持此文件类型」。setType写太具体,比如image/png但文件实际是 jpg。统一用image/*,或根据文件后缀动态判断。
对照表方便速查:
| 报错/现象 | 根因 | 修复 |
|---|---|---|
| 401 Unauthorized | Key 错误/缺失/带空格 | 检查 Bearer 格式,trim Key |
| local proxy failed | 代理配置残留 | 清空 OkHttp proxy 配置 |
| reading choices null | 返回体无 choices | 打印完整 body,检查 Model ID |
| OAuth invalid_grant | 误用 CLI 配置 | 改用 API Key,查文档 |
| FileUriExposedException | 用了 file:// | 换 getImageContentUri |
| 分享空白 | IS_PENDING 未置 0 | 写完数据 update 置 0 |
排障时优先看logcat的完整堆栈,别只看最后一行。HTTP 层的问题一定打印response.code()和response.body().string(),这两个信息能解决八成问题。
6. 把分享链路接进你的工程:从验证到落地
跑通一次验证之后,落地还有几件事要做。第一是把 Key 从代码里挪出去,Android 端最稳的做法是服务端签发短期 token,App 拿 token 换一次请求,避免 Key 被打包进 APK 被反编译提取。第二是给分享加个「生成中」的 loading 态,模型请求有延迟,用户点了分享按钮没反应会以为卡死。第三是处理分享失败的回调,createChooser本身不返回结果,想知道用户选了哪个 App 得用Intent.createChooser配合BroadcastReceiver监听Intent.EXTRA_CHOSEN_COMPONENT。
如果你的场景是「生成内容 → 分享」的闭环,建议把生成和分享解耦:先生成、落盘、拿到 Uri,再触发分享。不要在onResponse回调里直接startActivity,网络回调线程和 UI 线程的切换容易出问题,用runOnUiThread包一层。
长期做这类集成的,Coding Plan 那边有更完整的工程化方案,地址 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,适合需要稳定调用和额度管理的团队。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,参数细节以文档为准。
最后留一个实用技巧:调试分享时,先装一个「文件管理器」类 App 作为接收方,它比微信更容易暴露 Uri 权限问题,能看到文件真实大小和路径。等文件管理器能正常读取了,再测微信、QQ 这些,成功率会高很多。