From bcea49365d0dc5bb48c654bd81e0f87d08047215 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 2 Jun 2026 14:55:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(filemanager):=20=F0=9F=91=81=20Open=20+=20?= =?UTF-8?q?=E2=AC=87=20Download=20pro=20Datei=20in=20App=20+=20Diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan's UX-Wunsch: Datei direkt oeffnen ohne Umweg ueber Download. Plus: in der App fehlte komplett der Per-Row-Download-Button (nur via Checkbox + Bulk-Download). Beides jetzt gefixt. App (SettingsScreen.tsx): - Neue per-Row-Buttons: 👁 Open + ⬇ Download + 🕒 Versionen + 🗑 Loeschen - Open-Pfad nutzt requestId-Praefix 'open-' im file_response-Handler → Datei wird nach CachesDirectory geschrieben (kein Storage-Bloat) → FileOpener-Native-Module (Intent.ACTION_VIEW mit MIME) oeffnet mit dem System-Picker → User waehlt PDF-Viewer / Galerie / Player - guessMimeFromName-Helper fuer den Intent damit Android die passende App findet - Download-Pfad unveraendert ('single-' Praefix), schreibt nach DownloadDirectory mit Suffix-Inkrement bei Namens-Konflikt Diagnostic (server.js + index.html): - Neue Route /api/files-view (gleicher Code-Pfad wie files-download, aber Content-Disposition:inline + echter MIME-Type statt octet-stream) - Browser zeigt PDF / Bilder / Text im neuen Tab statt forcierten Download - 👁-Button in jeder File-Row neben ⬇/🕒/🗑 - Fallback fuer unbekannte MIMEs: octet-stream → Browser bietet Download Bei beiden Pfaden bleibt der Cache nutzbar: nach App-Open kann der User die Datei im jeweiligen Viewer behalten; im Browser bleibt sie im Tab. --- android/src/screens/SettingsScreen.tsx | 80 ++++++++++++++++++++++++-- diagnostic/index.html | 7 +++ diagnostic/server.js | 25 +++++++- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 7fdf66e..2e9a612 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -21,9 +21,37 @@ import { PermissionsAndroid, useWindowDimensions, DeviceEventEmitter, + NativeModules, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; + +const { FileOpener } = NativeModules as { + FileOpener?: { open: (filePath: string, mimeType: string) => Promise }; +}; + +// MIME-Type aus Dateinamen schaetzen — fuer den FileOpener-Intent. Android +// nutzt den MIME-Type um die passende App zu finden. Unknown → octet-stream. +function guessMimeFromName(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith('.pdf')) return 'application/pdf'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.gif')) return 'image/gif'; + if (lower.endsWith('.webp')) return 'image/webp'; + if (lower.endsWith('.mp3')) return 'audio/mpeg'; + if (lower.endsWith('.wav')) return 'audio/wav'; + if (lower.endsWith('.ogg') || lower.endsWith('.opus')) return 'audio/ogg'; + if (lower.endsWith('.mp4') || lower.endsWith('.m4a')) return 'audio/mp4'; + if (lower.endsWith('.webm')) return 'video/webm'; + if (lower.endsWith('.txt')) return 'text/plain'; + if (lower.endsWith('.md')) return 'text/markdown'; + if (lower.endsWith('.json')) return 'application/json'; + if (lower.endsWith('.csv')) return 'text/csv'; + if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html'; + if (lower.endsWith('.zip')) return 'application/zip'; + return 'application/octet-stream'; +} import DocumentPicker from 'react-native-document-picker'; import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs'; import { @@ -514,9 +542,11 @@ const SettingsScreen: React.FC = () => { if (message.type === ('file_response' as any)) { const p: any = message.payload || {}; const reqId = (p.requestId as string) || ''; - if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage + const isDownload = reqId.startsWith('single-'); + const isOpen = reqId.startsWith('open-'); + if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.) if (p.error) { - ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG); + ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG); return; } const b64 = (p.base64 as string) || ''; @@ -526,10 +556,28 @@ const SettingsScreen: React.FC = () => { 'aria-download'; (async () => { try { + if (isOpen) { + // Open-Pfad: nach Caches schreiben + per FileOpener mit System- + // Viewer oeffnen. Caches damit der Speicher kein Dauer-Muell wird. + const dir = RNFS.CachesDirectoryPath; + const target = `${dir}/${fileName}`; + await RNFS.writeFile(target, b64, 'base64'); + const mime = (p.mimeType as string) || guessMimeFromName(fileName); + if (FileOpener?.open) { + try { + await FileOpener.open(target, mime); + } catch (e: any) { + ToastAndroid.show('Öffnen fehlgeschlagen: ' + (e?.message || e), ToastAndroid.LONG); + } + } else { + ToastAndroid.show('FileOpener-Modul nicht verfügbar — APK neu bauen', ToastAndroid.LONG); + } + return; + } + // Download-Pfad: nach Downloads-Ordner schreiben, mit Suffix bei + // Namens-Konflikt damit nichts ueberschrieben wird. const dir = RNFS.DownloadDirectoryPath; const filePath = `${dir}/${fileName}`; - // Falls Datei schon existiert: Suffix anhaengen damit nichts - // ueberschrieben wird. let target = filePath; let i = 1; while (await RNFS.exists(target)) { @@ -1040,6 +1088,30 @@ const SettingsScreen: React.FC = () => { {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} + { + rvs.send('file_request' as any, { + serverPath: f.path, + requestId: 'open-' + Date.now(), + }); + ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT); + }} + style={{padding:8}} + > + 👁 + + { + rvs.send('file_request' as any, { + serverPath: f.path, + requestId: 'single-' + Date.now(), + }); + ToastAndroid.show('Download läuft…', ToastAndroid.SHORT); + }} + style={{padding:8}} + > + + { // path-relativ-zu-uploads = nur der Dateiname, diff --git a/diagnostic/index.html b/diagnostic/index.html index 4f7f1d1..95078a0 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -4038,6 +4038,7 @@
${badge}${escapeHtml(f.name)}
${fmtSize(f.size)} · ${fmtDate(f.mtime)}
+ @@ -4174,6 +4175,12 @@ window.location.href = '/api/files-download?path=' + encPath; } + function openFileInline(encPath) { + // Inline-View — Browser zeigt PDF / Bild / Text im neuen Tab, + // bei unbekanntem MIME landet's als Download-Fallback. + window.open('/api/files-view?path=' + encPath, '_blank', 'noopener'); + } + async function deleteFile(p, name) { if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return; try { diff --git a/diagnostic/server.js b/diagnostic/server.js index 843c806..1a62eb4 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1622,7 +1622,10 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ ok: false, error: err.message })); } return; - } else if (req.url.startsWith("/api/files-download?") && req.method === "GET") { + } else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") { + // /api/files-download → mit Content-Disposition:attachment (Browser downloaded) + // /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab) + const isInline = req.url.startsWith("/api/files-view?"); const u = new URL("http://x" + req.url); const p = u.searchParams.get("path") || ""; const safe = path.resolve(p); @@ -1633,10 +1636,26 @@ const server = http.createServer((req, res) => { } const stat = fs.statSync(safe); const fname = path.basename(safe); + // Beim View-Modus echten MIME-Type setzen damit Browser inline rendert. + // Bei Download-Modus weiter octet-stream + attachment-Disposition. + const ext = path.extname(fname).toLowerCase(); + const mimeMap = { + ".pdf": "application/pdf", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", + ".mp4": "video/mp4", ".webm": "video/webm", + ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", + ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8", + ".zip": "application/zip", + }; + const mime = isInline ? (mimeMap[ext] || "application/octet-stream") + : "application/octet-stream"; res.writeHead(200, { - "Content-Type": "application/octet-stream", + "Content-Type": mime, "Content-Length": stat.size, - "Content-Disposition": `attachment; filename="${fname}"`, + "Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`, }); fs.createReadStream(safe).pipe(res); return;