From f7b79418f997e561224a367e7f5440b62d7f5313 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 24 Jun 2026 00:59:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E4=BF=AE=E5=A4=8DApp=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8B=E8=BD=BD=E3=80=81PDF=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android-webview-app/APP_CHANGELOG.md | 8 ++ android-webview-app/app/build.gradle.kts | 4 +- .../app/src/main/AndroidManifest.xml | 1 + .../main/java/com/cyjai/agent/MainActivity.kt | 84 ++++++++++++++++++- server/chat/files.py | 49 ++++++++++- static/src/components/chat/ShowFileCard.vue | 36 +++----- static/src/components/overlay/ImagePicker.vue | 4 +- static/src/components/overlay/VideoPicker.vue | 4 +- static/src/stores/upload.ts | 27 +++++- 9 files changed, 182 insertions(+), 35 deletions(-) diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md index a680ab2..22389fd 100644 --- a/android-webview-app/APP_CHANGELOG.md +++ b/android-webview-app/APP_CHANGELOG.md @@ -1,5 +1,13 @@ # App 更新说明 +# 1.0.36 +- 修复 Android App 内文件卡片点击「下载」无反应的问题:前端优先调用 Android DownloadBridge,WebView 同时注册 `setDownloadListener` 兜底,使用系统 DownloadManager 下载到 Download 目录 +- PDF 文件卡片移除单独的「预览」按钮,改为与桌面端一致的卡片下方 inline 预览(iframe),视觉和交互统一 +- 修复 Android App 内图片/视频「从本地发送」容易失败的问题: + - 延长文件选择器关闭后的 backdrop 点击屏蔽时长,避免 touch 穿透误关选择弹窗导致上传中断 + - Android 端统一使用 `ACTION_GET_CONTENT` 选择器(按 `image/*` / `video/*` 过滤),避免不同 ROM 的相册/Photo Picker 返回临时 URI 导致上传失效 + - 上传链路增强对 Android WebView 返回文件无文件名/无扩展名的兼容:前端根据 MIME 类型补全文件名,后端在 filename 缺失时自动推断兜底文件名 + # 1.0.35 - 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器 diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts index 7a8fb6f..87fe5b0 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 = 37 - versionName = "1.0.35" + versionCode = 38 + versionName = "1.0.36" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android-webview-app/app/src/main/AndroidManifest.xml b/android-webview-app/app/src/main/AndroidManifest.xml index 0ad7cdd..573f09c 100644 --- a/android-webview-app/app/src/main/AndroidManifest.xml +++ b/android-webview-app/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + startSystemDownload(url, suggestFileName(contentDisposition, url)) + }) + webView.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { // 按需放行媒体权限(例如文件上传触发的媒体访问) @@ -181,9 +203,21 @@ class MainActivity : AppCompatActivity() { this@MainActivity.filePathCallback?.onReceiveValue(null) this@MainActivity.filePathCallback = filePathCallback - val intent = fileChooserParams?.createIntent() ?: Intent(Intent.ACTION_GET_CONTENT).apply { + // 统一使用 ACTION_GET_CONTENT,避免不同 ROM 对 Photo Picker / 相册的兼容差异 + // 导致返回的 URI/文件对象在上传时失效。根据 acceptTypes 设置过滤类型, + // 图片选择仍只显示图片文件,但走文件管理器而不是系统相册。 + val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } + ?: arrayListOf("*/*") + val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" + 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) return true @@ -384,6 +418,52 @@ class MainActivity : AppCompatActivity() { fun isPdfPreviewSupported(): Boolean = true } + inner class DownloadBridge { + @JavascriptInterface + fun downloadFile(fileUrl: String?, fileName: String?) { + val url = fileUrl ?: return + val name = fileName ?: suggestFileName(null, url) + runOnUiThread { + startSystemDownload(url, name) + } + } + } + + private fun startSystemDownload(url: 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 downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + downloadManager.enqueue(request) + Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e(TAG, "下载失败: ${e.message}", e) + Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show() + // 兜底:尝试用浏览器打开 + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } catch (_: Exception) {} + } + } + + private fun suggestFileName(contentDisposition: String?, url: String): String { + contentDisposition?.let { + val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE) + regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it } + } + val uri = Uri.parse(url) + uri.lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it } + uri.getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it } + return "download_${System.currentTimeMillis()}" + } + inner class ThemeBridge { @JavascriptInterface fun onThemeChanged(theme: String?) { diff --git a/server/chat/files.py b/server/chat/files.py index b85d7f9..d7faa7c 100644 --- a/server/chat/files.py +++ b/server/chat/files.py @@ -47,6 +47,40 @@ from server.monitor import get_cached_monitor_snapshot from server.files import sanitize_filename_preserve_unicode UPLOAD_FOLDER_NAME = ".agents/user_upload" + + +def _derive_filename_from_upload(uploaded_file) -> str: + """当上传请求未提供文件名时,根据 MIME 类型或文件扩展名推断一个兜底文件名。""" + mime_type = (uploaded_file.content_type or '').strip().lower() + if mime_type: + # 优先使用 mimetypes 库的 guess_extension,它通常比硬编码映射更全面 + ext = mimetypes.guess_extension(mime_type, strict=False) + if ext: + return f"upload_{int(time.time())}{ext}" + # 常见类型的兜底映射 + type_map = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/bmp': '.bmp', + 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/x-matroska': '.mkv', + 'video/avi': '.avi', + 'video/webm': '.webm', + 'application/pdf': '.pdf', + 'text/plain': '.txt', + 'text/markdown': '.md', + } + ext = type_map.get(mime_type) + if ext: + return f"upload_{int(time.time())}{ext}" + return '' + + @chat_bp.route('/api/upload', methods=['POST']) @api_login_required @with_terminal @@ -70,14 +104,25 @@ def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str): uploaded_file = request.files['file'] original_name = (request.form.get('filename') or '').strip() - if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '': + if not uploaded_file: + return jsonify({ + "success": False, + "error": "未找到文件", + "message": "请求中缺少文件内容" + }), 400 + + raw_name = original_name or uploaded_file.filename or '' + if not raw_name.strip(): + # 文件名缺失时,尝试根据 MIME 类型或文件内容推断一个兜底文件名 + raw_name = _derive_filename_from_upload(uploaded_file) + + if not raw_name.strip(): return jsonify({ "success": False, "error": "文件名为空", "message": "请选择要上传的文件" }), 400 - raw_name = original_name or uploaded_file.filename filename = sanitize_filename_preserve_unicode(raw_name) if not filename: filename = secure_filename(raw_name) diff --git a/static/src/components/chat/ShowFileCard.vue b/static/src/components/chat/ShowFileCard.vue index de689da..e9bee6f 100644 --- a/static/src/components/chat/ShowFileCard.vue +++ b/static/src/components/chat/ShowFileCard.vue @@ -4,14 +4,6 @@ {{ displayName }}
-