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 更新说明
|
# 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
|
# 1.0.38
|
||||||
- PDF 文件卡片改为前端 `vue-pdf-embed` 渲染:在卡片下方直接滚动预览多页,不再调用浏览器原生 PDF 预览或 Android 原生 PDF 预览 Activity
|
- PDF 文件卡片改为前端 `vue-pdf-embed` 渲染:在卡片下方直接滚动预览多页,不再调用浏览器原生 PDF 预览或 Android 原生 PDF 预览 Activity
|
||||||
- 与桌面端保持一致的文件卡片交互:PDF 和其他类型文件统一头部 + 下方预览布局
|
- 与桌面端保持一致的文件卡片交互:PDF 和其他类型文件统一头部 + 下方预览布局
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.cyjai.agent"
|
applicationId = "com.cyjai.agent"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 40
|
versionCode = 41
|
||||||
versionName = "1.0.38"
|
versionName = "1.0.39"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -45,13 +46,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var webView: WebView
|
private lateinit var webView: WebView
|
||||||
private var filePathCallback: ValueCallback<Array<Uri>>? = null
|
private var filePathCallback: ValueCallback<Array<Uri>>? = null
|
||||||
private var voiceBridge: VoiceBridge? = null
|
private var voiceBridge: VoiceBridge? = null
|
||||||
|
private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
|
||||||
|
private var pendingDownloadUrl: String? = null
|
||||||
|
private var pendingDownloadFileName: String? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val REQUEST_RECORD_AUDIO = 300
|
private const val REQUEST_RECORD_AUDIO = 300
|
||||||
private const val REQUEST_WRITE_STORAGE = 301
|
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 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(
|
private val fileChooserLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||||
@ -199,22 +205,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
||||||
this@MainActivity.filePathCallback = filePathCallback
|
this@MainActivity.filePathCallback = filePathCallback
|
||||||
|
|
||||||
// 使用 ACTION_OPEN_DOCUMENT 走系统文件管理器(DocumentsUI),
|
// 对图片/视频选择,先检查并请求媒体权限;部分国产 ROM 的文件管理器会要求
|
||||||
// 避免 ACTION_GET_CONTENT 在某些 ROM 上唤起相册/Photo Picker 导致返回 URI 失效。
|
// 应用持有 READ_MEDIA_IMAGES/READ_MEDIA_VIDEO 才能展示本地媒体。
|
||||||
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
||||||
?: arrayListOf("*/*")
|
?: listOf("*/*")
|
||||||
val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
|
if (needsMediaPermission(acceptTypes) && !hasMediaPermission(acceptTypes)) {
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
pendingFileChooserParams = fileChooserParams
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
requestMediaPermissions(acceptTypes)
|
||||||
type = acceptTypes.first()
|
return true
|
||||||
if (allowMultiple) {
|
|
||||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
||||||
}
|
|
||||||
if (acceptTypes.size > 1) {
|
|
||||||
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fileChooserLauncher.launch(intent)
|
|
||||||
|
launchFileChooser(fileChooserParams)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,6 +251,117 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onDestroy()
|
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?) {
|
private fun applySystemBarTheme(rawTheme: String?) {
|
||||||
val theme = (rawTheme ?: "").lowercase(Locale.getDefault())
|
val theme = (rawTheme ?: "").lowercase(Locale.getDefault())
|
||||||
val isDark = theme == "dark"
|
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 {
|
try {
|
||||||
val request = DownloadManager.Request(Uri.parse(url))
|
val absoluteUrl = resolveAbsoluteUrl(rawUrl)
|
||||||
.setTitle(fileName)
|
if (absoluteUrl.isBlank()) {
|
||||||
.setDescription("正在下载...")
|
Toast.makeText(this, "下载链接无效", Toast.LENGTH_LONG).show()
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
return
|
||||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
}
|
||||||
.setAllowedOverRoaming(true)
|
|
||||||
.setAllowedOverMetered(true)
|
|
||||||
|
|
||||||
|
// 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
|
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
downloadManager.enqueue(request)
|
downloadManager.enqueue(request)
|
||||||
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
|
||||||
@ -442,12 +568,46 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
|
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
|
||||||
// 兜底:尝试用浏览器打开
|
// 兜底:尝试用浏览器打开
|
||||||
try {
|
try {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl)))
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (_: Exception) {}
|
} 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 {
|
private fun suggestFileName(contentDisposition: String?, url: String): String {
|
||||||
contentDisposition?.let {
|
contentDisposition?.let {
|
||||||
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
||||||
|
|||||||
@ -1351,6 +1351,19 @@ function dispatchShowFileDownload(path: string) {
|
|||||||
// 直接 fetch 下载接口,不走 Vue 组件链
|
// 直接 fetch 下载接口,不走 Vue 组件链
|
||||||
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
|
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
|
||||||
const name = path.split('/').pop() || 'file';
|
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)
|
fetch(url)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@ -300,6 +300,18 @@ export const resourceMethods = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async downloadResource(url, filename) {
|
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 {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user