fix(android): 修复App文件下载、PDF卡片展示与本地媒体发送

This commit is contained in:
JOJO 2026-06-24 00:59:48 +08:00
parent e4a663594d
commit f7b79418f9
9 changed files with 182 additions and 35 deletions

View File

@ -1,5 +1,13 @@
# App 更新说明 # App 更新说明
# 1.0.36
- 修复 Android App 内文件卡片点击「下载」无反应的问题:前端优先调用 Android DownloadBridgeWebView 同时注册 `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 # 1.0.35
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer点击卡片「预览」按钮可在 App 内直接查看 PDF不再依赖浏览器 - 新增 PDF 文件原生预览:集成 AndroidPdfViewer点击卡片「预览」按钮可在 App 内直接查看 PDF不再依赖浏览器

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.cyjai.agent" applicationId = "com.cyjai.agent"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 37 versionCode = 38
versionName = "1.0.35" versionName = "1.0.36"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@ -2,17 +2,20 @@ package com.cyjai.agent
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.view.View import android.view.View
import android.content.Context import android.content.Context
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import android.webkit.ValueCallback import android.webkit.ValueCallback
@ -21,6 +24,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -45,6 +49,7 @@ class MainActivity : AppCompatActivity() {
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 HOME_URL = "https://agent.cyjai.com" private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260406_4" private const val WEB_ASSET_VERSION = "20260406_4"
} }
@ -102,6 +107,7 @@ class MainActivity : AppCompatActivity() {
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge") webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge") webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
webView.addJavascriptInterface(DownloadBridge(), "AndroidDownloadBridge")
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化) // 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
voiceBridge = VoiceBridge(this@MainActivity, webView) voiceBridge = VoiceBridge(this@MainActivity, webView)
@ -148,6 +154,18 @@ class MainActivity : AppCompatActivity() {
) )
} }
// Android 9 及以下需要写外部存储权限才能使用 DownloadManager
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_STORAGE
)
}
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url) super.onPageFinished(view, url)
@ -167,6 +185,10 @@ class MainActivity : AppCompatActivity() {
} }
} }
webView.setDownloadListener(DownloadListener { url, _, contentDisposition, _, _ ->
startSystemDownload(url, suggestFileName(contentDisposition, url))
})
webView.webChromeClient = object : WebChromeClient() { webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) { override fun onPermissionRequest(request: PermissionRequest) {
// 按需放行媒体权限(例如文件上传触发的媒体访问) // 按需放行媒体权限(例如文件上传触发的媒体访问)
@ -181,9 +203,21 @@ class MainActivity : AppCompatActivity() {
this@MainActivity.filePathCallback?.onReceiveValue(null) this@MainActivity.filePathCallback?.onReceiveValue(null)
this@MainActivity.filePathCallback = filePathCallback 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) 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) fileChooserLauncher.launch(intent)
return true return true
@ -384,6 +418,52 @@ class MainActivity : AppCompatActivity() {
fun isPdfPreviewSupported(): Boolean = true 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 { inner class ThemeBridge {
@JavascriptInterface @JavascriptInterface
fun onThemeChanged(theme: String?) { fun onThemeChanged(theme: String?) {

View File

@ -47,6 +47,40 @@ from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".agents/user_upload" 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']) @chat_bp.route('/api/upload', methods=['POST'])
@api_login_required @api_login_required
@with_terminal @with_terminal
@ -70,14 +104,25 @@ def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
uploaded_file = request.files['file'] uploaded_file = request.files['file']
original_name = (request.form.get('filename') or '').strip() 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({ return jsonify({
"success": False, "success": False,
"error": "文件名为空", "error": "文件名为空",
"message": "请选择要上传的文件" "message": "请选择要上传的文件"
}), 400 }), 400
raw_name = original_name or uploaded_file.filename
filename = sanitize_filename_preserve_unicode(raw_name) filename = sanitize_filename_preserve_unicode(raw_name)
if not filename: if not filename:
filename = secure_filename(raw_name) filename = secure_filename(raw_name)

View File

@ -4,14 +4,6 @@
<span class="sfc-file-icon" aria-hidden="true"></span> <span class="sfc-file-icon" aria-hidden="true"></span>
<span class="sfc-name" :title="displayName">{{ displayName }}</span> <span class="sfc-name" :title="displayName">{{ displayName }}</span>
<div class="sfc-actions"> <div class="sfc-actions">
<button
v-if="canAndroidPdfPreview"
type="button"
class="sfc-btn sfc-btn-preview"
@click="previewPdf"
>
预览
</button>
<button <button
v-if="canCopy" v-if="canCopy"
type="button" type="button"
@ -102,9 +94,6 @@ const props = defineProps<{
preview?: string; preview?: string;
}>(); }>();
// ---- ----
const isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
// ---- ---- // ---- ----
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
@ -119,15 +108,9 @@ const displayName = computed(() => props.name || props.path.split('/').pop() ||
const canPreview = computed(() => { const canPreview = computed(() => {
if (props.preview === 'off') return false; if (props.preview === 'off') return false;
// Android App PDF iframe PdfPreviewActivity
if (isAndroidApp && fileType.value === 'pdf') return false;
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value); return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
}); });
const canAndroidPdfPreview = computed(() => {
return isAndroidApp && fileType.value === 'pdf';
});
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value)); const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
const metaText = computed(() => { const metaText = computed(() => {
@ -245,6 +228,18 @@ async function loadContent() {
async function handleDownload() { async function handleDownload() {
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`; const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
const name = props.path.split('/').pop() || 'file'; const name = props.path.split('/').pop() || 'file';
// Android App WebView a.download
const androidBridge = (window as any).AndroidDownloadBridge;
if (androidBridge && typeof androidBridge.downloadFile === 'function') {
try {
androidBridge.downloadFile(url, name);
return;
} catch (e) {
console.warn('[ShowFileCard] Android 桥接下载失败,回退:', e);
}
}
try { try {
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) { if (!resp.ok) {
@ -280,13 +275,6 @@ function openFullImage() {
if (contentUrl.value) window.open(contentUrl.value, '_blank'); if (contentUrl.value) window.open(contentUrl.value, '_blank');
} }
function previewPdf() {
const bridge = (window as any).AndroidPdfBridge;
if (bridge && typeof bridge.previewPdf === 'function') {
bridge.previewPdf(contentUrl.value);
}
}
function onImageError() { function onImageError() {
error.value = '图片加载失败'; error.value = '图片加载失败';
} }

View File

@ -117,8 +117,8 @@ const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => { const triggerLocal = () => {
if (props.uploading) return; if (props.uploading) return;
// 500ms backdrop click touch 穿 overlay // 1500ms backdrop click touch 穿 overlay
ignoreBackdropClickUntil = Date.now() + 500; ignoreBackdropClickUntil = Date.now() + 1500;
localInput.value?.click(); localInput.value?.click();
}; };

View File

@ -119,8 +119,8 @@ const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => { const triggerLocal = () => {
if (props.uploading) return; if (props.uploading) return;
// 500ms backdrop click touch 穿 overlay // 1500ms backdrop click touch 穿 overlay
ignoreBackdropClickUntil = Date.now() + 500; ignoreBackdropClickUntil = Date.now() + 1500;
localInput.value?.click(); localInput.value?.click();
}; };

View File

@ -3,6 +3,30 @@ import { useUiStore } from './ui';
import { useFileStore } from './file'; import { useFileStore } from './file';
import { usePolicyStore } from './policy'; import { usePolicyStore } from './policy';
function deriveFilenameFromType(mimeType: string | undefined): string | null {
if (!mimeType) return null;
const map: Record<string, string> = {
'image/jpeg': 'image.jpg',
'image/jpg': 'image.jpg',
'image/png': 'image.png',
'image/gif': 'image.gif',
'image/webp': 'image.webp',
'image/bmp': 'image.bmp',
'image/svg+xml': 'image.svg',
'video/mp4': 'video.mp4',
'video/quicktime': 'video.mov',
'video/x-matroska': 'video.mkv',
'video/avi': 'video.avi',
'video/webm': 'video.webm',
'application/pdf': 'document.pdf',
'text/plain': 'document.txt',
'text/markdown': 'document.md',
};
// 兼容类似 image/jpeg; charset=utf-8 的情况
const normalized = mimeType.split(';')[0].trim().toLowerCase();
return map[normalized] || null;
}
interface UploadResult { interface UploadResult {
path: string; path: string;
filename: string; filename: string;
@ -135,9 +159,10 @@ export const useUploadStore = defineStore('upload', {
duration: null duration: null
}); });
try { try {
const safeName = file.name || deriveFilenameFromType(file.type) || `upload_${Date.now()}`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('filename', file.name || ''); formData.append('filename', safeName);
const response = await fetch('/api/upload', { const response = await fetch('/api/upload', {
method: 'POST', method: 'POST',
body: formData body: formData