From 626b7eaa527389f3e2d7c1d68a244c2ab2d0799d Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 24 Jun 2026 16:13:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E4=BF=AE=E5=A4=8D=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E6=9D=83=E9=99=90=E9=87=8D=E8=AF=B7=E6=B1=82=E4=B8=8E?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在唤起图片/视频文件选择器前主动请求 READ_MEDIA_IMAGES/VIDEO 权限, 拒绝后支持再次请求并引导到系统设置页。 - Android 下载桥接补全相对 URL 为绝对地址,并携带 WebView 登录 Cookie。 - Android 10+ 指定公共下载目录失败时回退到 DownloadManager 默认位置。 - 前端 markdown 下载链接与资源下载统一优先走 AndroidDownloadBridge。 --- android-webview-app/APP_CHANGELOG.md | 8 + android-webview-app/app/build.gradle.kts | 4 +- .../main/java/com/cyjai/agent/MainActivity.kt | 208 ++++++++++++++++-- static/src/app/bootstrap.ts | 13 ++ static/src/app/methods/resources.ts | 12 + 5 files changed, 219 insertions(+), 26 deletions(-) diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md index c9863ed..e68340a 100644 --- a/android-webview-app/APP_CHANGELOG.md +++ b/android-webview-app/APP_CHANGELOG.md @@ -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 和其他类型文件统一头部 + 下方预览布局 diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts index 620a15b..c7763d9 100644 --- a/android-webview-app/app/build.gradle.kts +++ b/android-webview-app/app/build.gradle.kts @@ -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" } diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt index 198ee97..57b5b4f 100644 --- a/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt @@ -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>? = 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 = 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): Boolean { + // 仅当选择器明确针对图片或视频时才需要媒体权限;通配类型仍走 DocumentsUI 的临时授权 + if (acceptTypes.isEmpty() || acceptTypes.any { it == "*/*" }) return false + return acceptTypes.any { it.startsWith("image/") || it.startsWith("video/") } + } + + private fun getRequiredMediaPermissions(acceptTypes: List): Array { + 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() + 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): 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) { + 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, + 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) diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index 1618798..98dcfb5 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -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) { diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index d670349..6a2c6e8 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -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) {