From 0899a0bd0fa5a1a065f08552edb5a852b78c6b13 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 23 Aug 2026 18:04:16 +0800 Subject: [PATCH] feat: native file downloads via DownloadManager --- .../main/java/app/zcode/acp/MainActivity.kt | 46 +++++++++++++++++++ src/chat/ChatView.tsx | 5 +- src/components/FileViewer.tsx | 24 +++++++++- src/i18n/en.json | 1 + src/i18n/zh-CN.json | 1 + 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/app/zcode/acp/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/app/zcode/acp/MainActivity.kt index 45ef71c..602afa6 100644 --- a/src-tauri/gen/android/app/src/main/java/app/zcode/acp/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/app/zcode/acp/MainActivity.kt @@ -1,8 +1,14 @@ package app.zcode.acp +import android.app.DownloadManager +import android.content.Context +import android.net.Uri import android.os.Bundle +import android.os.Environment +import android.util.Log import android.view.View import android.view.ViewGroup +import android.webkit.URLUtil import android.webkit.WebView import androidx.activity.enableEdgeToEdge import androidx.core.view.ViewCompat @@ -12,6 +18,7 @@ class MainActivity : TauriActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + attachDownloadHandler(retriesLeft = 3) // Edge-to-edge draws the WebView under the system bars, but Android // WebView reports env(safe-area-inset-*) as 0. Forward the real insets @@ -39,6 +46,45 @@ class MainActivity : TauriActivity() { } } + // wry registers no DownloadListener: without one the WebView silently + // drops every download (fs file URLs with ?dl=1 answer with + // Content-Disposition: attachment). Route those into the system + // DownloadManager — native notification, lands in public Downloads. + private fun attachDownloadHandler(retriesLeft: Int) { + val content = findViewById(android.R.id.content) + val webView = findWebView(content) + if (webView == null) { + if (retriesLeft > 0) content.post { attachDownloadHandler(retriesLeft - 1) } + return + } + webView.setDownloadListener { url, _, contentDisposition, mimeType, _ -> + val name = fileNameFrom(url, contentDisposition, mimeType) + try { + val request = DownloadManager.Request(Uri.parse(url)) + .setTitle(name) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name) + (getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).enqueue(request) + } catch (e: Exception) { + Log.w("MainActivity", "download enqueue failed: ${e.message}") + } + } + } + + // Prefer the RFC 5987 filename* form (Android's URLUtil only reads the + // ASCII fallback), so non-ASCII filenames survive the download. + private fun fileNameFrom(url: String, contentDisposition: String?, mimeType: String?): String { + if (contentDisposition != null) { + val star = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE) + .find(contentDisposition)?.groupValues?.getOrNull(1) + if (!star.isNullOrBlank()) return Uri.decode(star) + val plain = Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE) + .find(contentDisposition)?.groupValues?.getOrNull(1) + if (!plain.isNullOrBlank()) return plain + } + return URLUtil.guessFileName(url, contentDisposition, mimeType) + } + // The Tauri WebView is not exposed directly; it is the only WebView in the // hierarchy, so a lazy walk is safe (insets dispatch happens after layout). private fun findWebView(view: View): WebView? { diff --git a/src/chat/ChatView.tsx b/src/chat/ChatView.tsx index 4767053..493e50c 100644 --- a/src/chat/ChatView.tsx +++ b/src/chat/ChatView.tsx @@ -715,11 +715,14 @@ function Composer() { > + {/* leading-5 + py-2 = 36px: the single-line input matches the size-9 + buttons exactly, so items-end aligns them instead of dropping + them below the inherited-1.5 text line. */} {/* While a turn runs the Send stays a Send: a draft queues as pending (sendPrompt routes it), never a dead button. Cancel stops the turn. diff --git a/src/components/FileViewer.tsx b/src/components/FileViewer.tsx index 7a36019..a429e7c 100644 --- a/src/components/FileViewer.tsx +++ b/src/components/FileViewer.tsx @@ -35,7 +35,7 @@ import yaml from "highlight.js/lib/languages/yaml"; import Lightbox from "yet-another-react-lightbox"; import Zoom from "yet-another-react-lightbox/plugins/zoom"; import "yet-another-react-lightbox/styles.css"; -import { ArrowLeft, FileText, Link2, Share2, X } from "lucide-react"; +import { ArrowLeft, Download, FileText, Link2, Share2, X } from "lucide-react"; import type { FsEntry } from "../lib/types"; import { useAppStore } from "../store/appStore"; import { Spinner } from "./Spinner"; @@ -399,6 +399,21 @@ export function FileViewer({ file, path, onClose, onExit }: FileViewerProps) { } }; + // dl=1 makes the bridge answer with Content-Disposition: attachment, so + // the WebView fires its DownloadListener (MainActivity routes it into the + // system DownloadManager) instead of navigating to the file. Needs bridge + // 0.11.8+; an older bridge ignores the flag and the WebView renders the + // file inline — the system back button returns to the app. + const downloadFile = () => { + if (!url) return; + const a = document.createElement("a"); + a.href = `${url}&dl=1`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + }; + return (
@@ -447,6 +462,13 @@ export function FileViewer({ file, path, onClose, onExit }: FileViewerProps) { )} {url && (
+ {canShareFiles && (