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 更新说明
# 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
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer点击卡片「预览」按钮可在 App 内直接查看 PDF不再依赖浏览器

View File

@ -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"
}

View File

@ -6,6 +6,7 @@
<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_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<application
android:allowBackup="true"

View File

@ -2,17 +2,20 @@ package com.cyjai.agent
import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.view.ViewGroup
import android.view.View
import android.content.Context
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
@ -21,6 +24,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@ -45,6 +49,7 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
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 WEB_ASSET_VERSION = "20260406_4"
}
@ -102,6 +107,7 @@ class MainActivity : AppCompatActivity() {
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
webView.addJavascriptInterface(DownloadBridge(), "AndroidDownloadBridge")
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
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() {
override fun onPageFinished(view: WebView?, url: String?) {
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() {
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?) {

View File

@ -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)

View File

@ -4,14 +4,6 @@
<span class="sfc-file-icon" aria-hidden="true"></span>
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
<div class="sfc-actions">
<button
v-if="canAndroidPdfPreview"
type="button"
class="sfc-btn sfc-btn-preview"
@click="previewPdf"
>
预览
</button>
<button
v-if="canCopy"
type="button"
@ -102,9 +94,6 @@ const props = defineProps<{
preview?: string;
}>();
// ---- ----
const isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
// ---- ----
const loading = ref(false);
const error = ref('');
@ -119,15 +108,9 @@ const displayName = computed(() => props.name || props.path.split('/').pop() ||
const canPreview = computed(() => {
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);
});
const canAndroidPdfPreview = computed(() => {
return isAndroidApp && fileType.value === 'pdf';
});
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
const metaText = computed(() => {
@ -245,6 +228,18 @@ async function loadContent() {
async function handleDownload() {
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
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 {
const resp = await fetch(url);
if (!resp.ok) {
@ -280,13 +275,6 @@ function openFullImage() {
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() {
error.value = '图片加载失败';
}

View File

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

View File

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

View File

@ -3,6 +3,30 @@ import { useUiStore } from './ui';
import { useFileStore } from './file';
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 {
path: string;
filename: string;
@ -135,9 +159,10 @@ export const useUploadStore = defineStore('upload', {
duration: null
});
try {
const safeName = file.name || deriveFilenameFromType(file.type) || `upload_${Date.now()}`;
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name || '');
formData.append('filename', safeName);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData