From 18594ca4fe12f505bd42bf6cd92aa5ddd89b1789 Mon Sep 17 00:00:00 2001 From: xiaomakuaiz Date: Sun, 31 May 2026 11:27:21 +0000 Subject: [PATCH] fix: support Android native downloads Co-authored-by: monkeycode-ai --- .../console/task/file-actions-dropdown.tsx | 8 +- frontend/src/utils/android-downloader.ts | 35 +++++ frontend/src/utils/common.tsx | 13 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../mobile/AndroidDownloaderPlugin.java | 138 ++++++++++++++++++ .../com/monkeycode/mobile/MainActivity.java | 10 +- 6 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 frontend/src/utils/android-downloader.ts create mode 100644 mobile/android/app/src/main/java/com/monkeycode/mobile/AndroidDownloaderPlugin.java diff --git a/frontend/src/components/console/task/file-actions-dropdown.tsx b/frontend/src/components/console/task/file-actions-dropdown.tsx index 88612a8f..95a19d34 100644 --- a/frontend/src/components/console/task/file-actions-dropdown.tsx +++ b/frontend/src/components/console/task/file-actions-dropdown.tsx @@ -101,7 +101,13 @@ export function FileActionsDropdown({ file, envid, onRefresh, onSuccess, alwaysV if (!nextFileHandle) { if (!envid) return - nativeDownloadFile(envid, filePath, downloadFilename) + + try { + await nativeDownloadFile(envid, filePath, downloadFilename) + } catch (error) { + toast.error('下载失败:' + (error instanceof Error ? error.message : '未知错误')) + } + return } diff --git a/frontend/src/utils/android-downloader.ts b/frontend/src/utils/android-downloader.ts new file mode 100644 index 00000000..dfd02c7c --- /dev/null +++ b/frontend/src/utils/android-downloader.ts @@ -0,0 +1,35 @@ +type AndroidDownloaderPlugin = { + downloadFile(options: { url: string; filename: string }): Promise<{ downloadId?: number; filename?: string }> +} + +type WindowWithCapacitor = Window & { + Capacitor?: { + getPlatform?: () => string + Plugins?: { + AndroidDownloader?: AndroidDownloaderPlugin + } + isNativePlatform?: () => boolean + } +} + +export function isAndroidNativePlatform(): boolean { + const capacitor = (window as WindowWithCapacitor).Capacitor + if (!capacitor) { + return false + } + + const platform = capacitor.getPlatform?.() + return Boolean(capacitor.isNativePlatform?.() && platform === 'android') +} + +export async function downloadFileWithAndroidPlugin(url: string, filename: string): Promise { + const capacitor = (window as WindowWithCapacitor).Capacitor + const plugin = capacitor?.Plugins?.AndroidDownloader + + if (!plugin || !isAndroidNativePlatform()) { + return false + } + + await plugin.downloadFile({ url, filename }) + return true +} diff --git a/frontend/src/utils/common.tsx b/frontend/src/utils/common.tsx index 841b0c73..98db5596 100644 --- a/frontend/src/utils/common.tsx +++ b/frontend/src/utils/common.tsx @@ -8,6 +8,7 @@ import { ConstsHostStatus, ConstsInterfaceType, ConstsOwnerType, ConstsProjectIs import { apiRequest } from "./requestUtils" import { remark } from "remark" import strip from "strip-markdown" +import { downloadFileWithAndroidPlugin } from "@/utils/android-downloader" /** GitHub App 安装地址:https://monkeycode-ai.com 用正式环境,其他域名用开发环境 */ export function getGithubAppInstallUrl(): string { @@ -812,11 +813,17 @@ export function getDownloadFileUrl(envid: string, path: string, filename?: strin return `/api/v1/users/files/download?${params.toString()}` } -export function nativeDownloadFile(envid: string, path: string, filename?: string): void { +export async function nativeDownloadFile(envid: string, path: string, filename?: string): Promise { const downloadFilename = filename || getFileName(path) + const url = new URL(getDownloadFileUrl(envid, path, downloadFilename), window.location.origin).toString() + + if (await downloadFileWithAndroidPlugin(url, downloadFilename)) { + return + } + const link = document.createElement('a') - link.href = getDownloadFileUrl(envid, path, downloadFilename) + link.href = url link.download = downloadFilename link.style.display = 'none' @@ -842,7 +849,7 @@ export async function downloadFile( throw new DOMException('Aborted', 'AbortError') } - nativeDownloadFile(envid, path, downloadFilename) + await nativeDownloadFile(envid, path, downloadFilename) return } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 340e7df8..d2b3fccc 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -38,4 +38,5 @@ + diff --git a/mobile/android/app/src/main/java/com/monkeycode/mobile/AndroidDownloaderPlugin.java b/mobile/android/app/src/main/java/com/monkeycode/mobile/AndroidDownloaderPlugin.java new file mode 100644 index 00000000..88e74bed --- /dev/null +++ b/mobile/android/app/src/main/java/com/monkeycode/mobile/AndroidDownloaderPlugin.java @@ -0,0 +1,138 @@ +package com.monkeycode.mobile; + +import android.Manifest; +import android.app.DownloadManager; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.webkit.CookieManager; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import com.getcapacitor.JSObject; +import com.getcapacitor.PermissionState; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin( + name = "AndroidDownloader", + permissions = { + @Permission( + alias = "storage", + strings = { + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + } + ) + } +) +public class AndroidDownloaderPlugin extends Plugin { + @PluginMethod + public void downloadFile(PluginCall call) { + if (requiresLegacyStoragePermission() && getPermissionState("storage") != PermissionState.GRANTED) { + requestPermissionForAlias("storage", call, "storagePermissionCallback"); + return; + } + + startDownload(call); + } + + @PermissionCallback + private void storagePermissionCallback(PluginCall call) { + if (!requiresLegacyStoragePermission() || getPermissionState("storage") == PermissionState.GRANTED) { + startDownload(call); + return; + } + + call.reject("Storage permission is required to download files"); + } + + private void startDownload(PluginCall call) { + String url = call.getString("url"); + String filename = call.getString("filename"); + + if (url == null || url.trim().isEmpty()) { + call.reject("url is required"); + return; + } + + Uri downloadUri = Uri.parse(url); + String resolvedFilename = filename; + if (resolvedFilename == null || resolvedFilename.trim().isEmpty()) { + resolvedFilename = URLUtil.guessFileName(url, null, null); + } + + DownloadManager.Request request = new DownloadManager.Request(downloadUri) + .setTitle(resolvedFilename) + .setDescription("Downloading file") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, resolvedFilename); + + String mimeType = resolveMimeType(url, resolvedFilename); + if (mimeType != null && !mimeType.isEmpty()) { + request.setMimeType(mimeType); + } + + String cookies = CookieManager.getInstance().getCookie(url); + if (cookies != null && !cookies.isEmpty()) { + request.addRequestHeader("Cookie", cookies); + } + + String userAgent = System.getProperty("http.agent"); + if (userAgent != null && !userAgent.isEmpty()) { + request.addRequestHeader("User-Agent", userAgent); + } + + Context context = getContext(); + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + if (downloadManager == null) { + call.reject("DownloadManager unavailable"); + return; + } + + long downloadId; + try { + downloadId = downloadManager.enqueue(request); + } catch (SecurityException exception) { + call.reject("Storage permission is required to download files", exception); + return; + } catch (RuntimeException exception) { + call.reject("Failed to enqueue Android download", exception); + return; + } + + JSObject result = new JSObject(); + result.put("downloadId", downloadId); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + result.put("filename", resolvedFilename); + } + call.resolve(result); + } + + private String resolveMimeType(String url, String filename) { + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + if (extension == null || extension.isEmpty()) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex < filename.length() - 1) { + extension = filename.substring(dotIndex + 1); + } + } + + if (extension == null || extension.isEmpty()) { + return null; + } + + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + } + + private boolean requiresLegacyStoragePermission() { + return Build.VERSION.SDK_INT <= Build.VERSION_CODES.P; + } +} diff --git a/mobile/android/app/src/main/java/com/monkeycode/mobile/MainActivity.java b/mobile/android/app/src/main/java/com/monkeycode/mobile/MainActivity.java index e02331a2..ee13e753 100644 --- a/mobile/android/app/src/main/java/com/monkeycode/mobile/MainActivity.java +++ b/mobile/android/app/src/main/java/com/monkeycode/mobile/MainActivity.java @@ -1,5 +1,13 @@ package com.monkeycode.mobile; +import android.os.Bundle; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(AndroidDownloaderPlugin.class); + super.onCreate(savedInstanceState); + } +}