fix(android): 修复App文件下载、PDF卡片展示与本地媒体发送
This commit is contained in:
parent
e4a663594d
commit
f7b79418f9
@ -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,不再依赖浏览器
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = '图片加载失败';
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user