fix(android): 修复媒体权限重请求与下载失败
- 在唤起图片/视频文件选择器前主动请求 READ_MEDIA_IMAGES/VIDEO 权限, 拒绝后支持再次请求并引导到系统设置页。 - Android 下载桥接补全相对 URL 为绝对地址,并携带 WebView 登录 Cookie。 - Android 10+ 指定公共下载目录失败时回退到 DownloadManager 默认位置。 - 前端 markdown 下载链接与资源下载统一优先走 AndroidDownloadBridge。
This commit is contained in:
parent
09a67f20dd
commit
626b7eaa52
@ -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 和其他类型文件统一头部 + 下方预览布局
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
if (acceptTypes.size > 1) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||||
}
|
||||
?: listOf("*/*")
|
||||
if (needsMediaPermission(acceptTypes) && !hasMediaPermission(acceptTypes)) {
|
||||
pendingFileChooserParams = fileChooserParams
|
||||
requestMediaPermissions(acceptTypes)
|
||||
return true
|
||||
}
|
||||
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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user