Skip to content
Merged
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
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<View>(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? {
Expand Down
5 changes: 4 additions & 1 deletion src/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -715,11 +715,14 @@ function Composer() {
>
<ImagePlus className="size-5" />
</button>
{/* 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. */}
<ComposerPrimitive.Input
rows={1}
placeholder={t("chat.inputPlaceholder")}
onPaste={onPaste}
className="max-h-32 min-h-9 flex-1 resize-none bg-transparent py-2 text-[15px] text-ink placeholder:text-faint focus:outline-none"
className="max-h-32 min-h-9 flex-1 resize-none bg-transparent py-2 text-[15px] leading-5 text-ink placeholder:text-faint focus:outline-none"
/>
{/* 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.
Expand Down
24 changes: 23 additions & 1 deletion src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="fixed inset-0 z-50 flex flex-col bg-canvas text-ink">
<header className="flex items-center gap-2 border-b border-hairline px-1.5 pb-2 pt-[max(var(--safe-top),0.5rem)]">
Expand Down Expand Up @@ -447,6 +462,13 @@ export function FileViewer({ file, path, onClose, onExit }: FileViewerProps) {
)}
{url && (
<div className="flex items-center gap-2">
<button
onClick={downloadFile}
className="flex items-center gap-1.5 rounded-full bg-blue-600 px-4 py-2 text-sm text-white active:bg-blue-500"
>
<Download className="size-4" />
{t("viewer.download")}
</button>
{canShareFiles && (
<button
onClick={shareFile}
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"retry": "Retry"
},
"viewer": {
"download": "Download",
"share": "Share / Save",
"copyLink": "Copy link",
"shareFailed": "Share failed",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"retry": "重试"
},
"viewer": {
"download": "下载",
"share": "分享 / 保存",
"copyLink": "复制链接",
"shareFailed": "分享失败",
Expand Down