fix(android): 修复媒体权限重请求与下载失败

- 在唤起图片/视频文件选择器前主动请求 READ_MEDIA_IMAGES/VIDEO 权限,
  拒绝后支持再次请求并引导到系统设置页。
- Android 下载桥接补全相对 URL 为绝对地址,并携带 WebView 登录 Cookie。
- Android 10+ 指定公共下载目录失败时回退到 DownloadManager 默认位置。
- 前端 markdown 下载链接与资源下载统一优先走 AndroidDownloadBridge。
This commit is contained in:
JOJO 2026-06-24 16:13:24 +08:00
parent 09a67f20dd
commit 626b7eaa52
5 changed files with 219 additions and 26 deletions

View File

@ -1,5 +1,13 @@
# App 更新说明
# 1.0.39
- 修复 Android App 内图片/视频「从本地发送」无响应的问题:在唤起系统文件选择器前主动请求 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` 权限,拒绝后支持再次请求并引导到系统设置
- 修复 Android App 内所有下载文件卡片、markdown 下载链接)提示「下载启动失败」的问题:
- 前端在 App 内优先调用 `AndroidDownloadBridge`
- Android 端将相对下载地址补全为绝对地址,并携带 WebView 登录 Cookie避免 401 / 无效链接
- Android 10+ 下载目录异常时自动回退到 DownloadManager 默认位置
- Android 9 及以下未授予存储权限时先请求权限再开始下载
# 1.0.38
- PDF 文件卡片改为前端 `vue-pdf-embed` 渲染:在卡片下方直接滚动预览多页,不再调用浏览器原生 PDF 预览或 Android 原生 PDF 预览 Activity
- 与桌面端保持一致的文件卡片交互PDF 和其他类型文件统一头部 + 下方预览布局

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.cyjai.agent"
minSdk = 24
targetSdk = 35
versionCode = 40
versionName = "1.0.38"
versionCode = 41
versionName = "1.0.39"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -11,6 +11,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import android.view.ViewGroup
import android.view.View
@ -45,13 +46,18 @@ class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var voiceBridge: VoiceBridge? = null
private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
private var pendingDownloadUrl: String? = null
private var pendingDownloadFileName: String? = null
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_RECORD_AUDIO = 300
private const val REQUEST_WRITE_STORAGE = 301
private const val REQUEST_READ_MEDIA = 302
private const val REQUEST_DOWNLOAD_STORAGE = 303
private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260624_1"
private const val WEB_ASSET_VERSION = "20260624_2"
}
private val fileChooserLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
@ -199,22 +205,17 @@ class MainActivity : AppCompatActivity() {
this@MainActivity.filePathCallback?.onReceiveValue(null)
this@MainActivity.filePathCallback = filePathCallback
// 使用 ACTION_OPEN_DOCUMENT 走系统文件管理器DocumentsUI
// 避免 ACTION_GET_CONTENT 在某些 ROM 上唤起相册/Photo Picker 导致返回 URI 失效
// 对图片/视频选择,先检查并请求媒体权限;部分国产 ROM 的文件管理器会要求
// 应用持有 READ_MEDIA_IMAGES/READ_MEDIA_VIDEO 才能展示本地媒体
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
?: arrayListOf("*/*")
val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = acceptTypes.first()
if (allowMultiple) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
?: listOf("*/*")
if (needsMediaPermission(acceptTypes) && !hasMediaPermission(acceptTypes)) {
pendingFileChooserParams = fileChooserParams
requestMediaPermissions(acceptTypes)
return true
}
if (acceptTypes.size > 1) {
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
}
}
fileChooserLauncher.launch(intent)
launchFileChooser(fileChooserParams)
return true
}
}
@ -250,6 +251,117 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
private fun needsMediaPermission(acceptTypes: List<String>): Boolean {
// 仅当选择器明确针对图片或视频时才需要媒体权限;通配类型仍走 DocumentsUI 的临时授权
if (acceptTypes.isEmpty() || acceptTypes.any { it == "*/*" }) return false
return acceptTypes.any { it.startsWith("image/") || it.startsWith("video/") }
}
private fun getRequiredMediaPermissions(acceptTypes: List<String>): Array<String> {
val needsImage = acceptTypes.any { it.startsWith("image/") }
val needsVideo = acceptTypes.any { it.startsWith("video/") }
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
val perms = mutableListOf<String>()
if (needsImage) perms.add(Manifest.permission.READ_MEDIA_IMAGES)
if (needsVideo) perms.add(Manifest.permission.READ_MEDIA_VIDEO)
perms.toTypedArray()
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
else -> emptyArray()
}
}
private fun hasMediaPermission(acceptTypes: List<String>): Boolean {
val perms = getRequiredMediaPermissions(acceptTypes)
if (perms.isEmpty()) return true
return perms.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestMediaPermissions(acceptTypes: List<String>) {
val perms = getRequiredMediaPermissions(acceptTypes)
if (perms.isEmpty()) return
ActivityCompat.requestPermissions(this, perms, REQUEST_READ_MEDIA)
}
private fun launchFileChooser(fileChooserParams: WebChromeClient.FileChooserParams?) {
pendingFileChooserParams = null
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
?: listOf("*/*")
val allowMultiple = fileChooserParams?.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = acceptTypes.first()
if (allowMultiple) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
if (acceptTypes.size > 1) {
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
}
}
fileChooserLauncher.launch(intent)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_READ_MEDIA -> {
val params = pendingFileChooserParams
if (params != null) {
if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
launchFileChooser(params)
} else {
filePathCallback?.onReceiveValue(null)
filePathCallback = null
pendingFileChooserParams = null
val permanentlyDenied = grantResults.isNotEmpty() &&
permissions.isNotEmpty() &&
!ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0])
if (permanentlyDenied) {
Toast.makeText(this, "请在系统设置中开启媒体访问权限", Toast.LENGTH_LONG).show()
openAppSettings()
} else {
Toast.makeText(this, "需要媒体访问权限才能选择本地文件", Toast.LENGTH_LONG).show()
}
}
}
}
REQUEST_DOWNLOAD_STORAGE -> {
val url = pendingDownloadUrl
val name = pendingDownloadFileName
pendingDownloadUrl = null
pendingDownloadFileName = null
if (url != null && grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startSystemDownload(url, name ?: suggestFileName(null, url))
} else {
Toast.makeText(this, "需要存储权限才能下载文件", Toast.LENGTH_LONG).show()
}
}
REQUEST_WRITE_STORAGE, REQUEST_RECORD_AUDIO -> {
// 启动时请求的权限,无需额外处理
}
}
}
private fun openAppSettings() {
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
} catch (_: Exception) {
// ignore
}
}
private fun applySystemBarTheme(rawTheme: String?) {
val theme = (rawTheme ?: "").lowercase(Locale.getDefault())
val isDark = theme == "dark"
@ -424,16 +536,30 @@ class MainActivity : AppCompatActivity() {
}
}
private fun startSystemDownload(url: String, fileName: String) {
private fun startSystemDownload(rawUrl: String, fileName: String) {
try {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("正在下载...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
.setAllowedOverRoaming(true)
.setAllowedOverMetered(true)
val absoluteUrl = resolveAbsoluteUrl(rawUrl)
if (absoluteUrl.isBlank()) {
Toast.makeText(this, "下载链接无效", Toast.LENGTH_LONG).show()
return
}
// Android 9 及以下需要 WRITE_EXTERNAL_STORAGE 权限才能写入公共下载目录
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
pendingDownloadUrl = absoluteUrl
pendingDownloadFileName = fileName
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_DOWNLOAD_STORAGE
)
return
}
val request = buildDownloadRequest(absoluteUrl, fileName)
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
@ -442,12 +568,46 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
// 兜底:尝试用浏览器打开
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl)))
startActivity(intent)
} catch (_: Exception) {}
}
}
private fun buildDownloadRequest(url: String, fileName: String): DownloadManager.Request {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("正在下载...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
.setAllowedOverRoaming(true)
.setAllowedOverMetered(true)
// 携带 WebView 中已登录的会话 Cookie避免下载接口因未登录而 401
val cookie = CookieManager.getInstance().getCookie(url)
if (!cookie.isNullOrBlank()) {
request.addRequestHeader("Cookie", cookie)
}
return try {
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
request
} catch (e: Exception) {
Log.w(TAG, "无法指定公共下载目录,使用 DownloadManager 默认位置: ${e.message}")
request
}
}
private fun resolveAbsoluteUrl(rawUrl: String): String {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl
val base = webView.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") } ?: HOME_URL
return if (rawUrl.startsWith("/")) {
base.substringBefore("?").trimEnd('/') + rawUrl
} else {
base.trimEnd('/') + "/" + rawUrl
}
}
private fun suggestFileName(contentDisposition: String?, url: String): String {
contentDisposition?.let {
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)

View File

@ -1351,6 +1351,19 @@ function dispatchShowFileDownload(path: string) {
// 直接 fetch 下载接口,不走 Vue 组件链
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
const name = path.split('/').pop() || 'file';
// Android App 内通过原生桥接下载,避免 WebView 对 a.download / blob URL 支持不佳导致失败
const androidBridge = (window as any).AndroidDownloadBridge;
if (androidBridge && typeof androidBridge.downloadFile === 'function') {
try {
const absoluteUrl = new URL(url, window.location.href).href;
androidBridge.downloadFile(absoluteUrl, name);
return;
} catch (e) {
console.warn('[show_file] Android 桥接下载失败,回退:', e);
}
}
fetch(url)
.then((resp) => {
if (!resp.ok) {

View File

@ -300,6 +300,18 @@ export const resourceMethods = {
},
async downloadResource(url, filename) {
// Android App 内优先走原生下载桥接,解决 WebView 中 a.download / blob URL 无法触发系统下载的问题
const androidBridge = (window as any).AndroidDownloadBridge;
if (androidBridge && typeof androidBridge.downloadFile === 'function') {
try {
const absoluteUrl = new URL(url, window.location.href).href;
androidBridge.downloadFile(absoluteUrl, filename || 'download');
return;
} catch (error) {
console.warn('Android 桥接下载失败,回退:', error);
}
}
try {
const response = await fetch(url);
if (!response.ok) {