
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 10API 29之后被标记为废弃分区存储Scoped Storage下你不能再靠绝对路径去查库。所以老代码里那种「用DATA ?查_ID」的写法在新系统上会查不到、返回 null然后走到insert分支运气不好还会因为路径不可写而抛异常。正确姿势是按 API 等级分流低版本走 DATA 查询高版本走MediaStore的RELATIVE_PATHIS_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。创建后立刻复制页面刷新就不再完整显示。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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 有完整说明。Android 端我们走标准 HTTP不涉及这套。想先不写代码、直接在网页上验证 Key 和 Model 是否配对可以打开模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一句「你好」看有没有正常返回。这一步能省掉后面大量「到底是网络问题还是 Key 问题」的排查时间。长期做编码或 Agent 场景的可以了解下 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 这里不展开本文聚焦分享链路。把这三件套记在一个地方Base URL https://taotoken.net/apiAPI 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, ArrayListFile files) { ArrayListUri 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 13API 33之后读媒体库要细分权限uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 /运行时请求也要按版本分流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_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一条消息能返回就说明 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_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 看这个 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。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 UnauthorizedKey 错误/缺失/带空格检查 Bearer 格式trim Keylocal proxy failed代理配置残留清空 OkHttp proxy 配置reading choices null返回体无 choices打印完整 body检查 Model IDOAuth invalid_grant误用 CLI 配置改用 API Key查文档FileUriExposedException用了 file://换 getImageContentUri分享空白IS_PENDING 未置 0写完数据 update 置 0排障时优先看logcat的完整堆栈别只看最后一行。HTTP 层的问题一定打印response.code()和response.body().string()这两个信息能解决八成问题。6. 把分享链路接进你的工程从验证到落地跑通一次验证之后落地还有几件事要做。第一是把 Key 从代码里挪出去Android 端最稳的做法是服务端签发短期 tokenApp 拿 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_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 适合需要稳定调用和额度管理的团队。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 参数细节以文档为准。最后留一个实用技巧调试分享时先装一个「文件管理器」类 App 作为接收方它比微信更容易暴露 Uri 权限问题能看到文件真实大小和路径。等文件管理器能正常读取了再测微信、QQ 这些成功率会高很多。