Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions frontend/src/utils/android-downloader.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const capacitor = (window as WindowWithCapacitor).Capacitor
const plugin = capacitor?.Plugins?.AndroidDownloader

if (!plugin || !isAndroidNativePlatform()) {
return false
}

await plugin.downloadFile({ url, filename })
return true
}
13 changes: 10 additions & 3 deletions frontend/src/utils/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
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'

Expand All @@ -842,7 +849,7 @@ export async function downloadFile(
throw new DOMException('Aborted', 'AbortError')
}

nativeDownloadFile(envid, path, downloadFilename)
await nativeDownloadFile(envid, path, downloadFilename)
return
}

Expand Down
1 change: 1 addition & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@
<!-- Permissions -->

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
</manifest>
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}