feat(filemanager): 👁 Open + ⬇ Download pro Datei in App + Diagnostic

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.
This commit is contained in:
2026-06-02 14:55:24 +02:00
parent 05eb7ed144
commit bcea49365d
3 changed files with 105 additions and 7 deletions
+76 -4
View File
@@ -21,9 +21,37 @@ import {
PermissionsAndroid, PermissionsAndroid,
useWindowDimensions, useWindowDimensions,
DeviceEventEmitter, DeviceEventEmitter,
NativeModules,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
const { FileOpener } = NativeModules as {
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};
// 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 DocumentPicker from 'react-native-document-picker';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs'; import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import { import {
@@ -514,9 +542,11 @@ const SettingsScreen: React.FC = () => {
if (message.type === ('file_response' as any)) { if (message.type === ('file_response' as any)) {
const p: any = message.payload || {}; const p: any = message.payload || {};
const reqId = (p.requestId as string) || ''; 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) { if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG); ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return; return;
} }
const b64 = (p.base64 as string) || ''; const b64 = (p.base64 as string) || '';
@@ -526,10 +556,28 @@ const SettingsScreen: React.FC = () => {
'aria-download'; 'aria-download';
(async () => { (async () => {
try { 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 dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`; const filePath = `${dir}/${fileName}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath; let target = filePath;
let i = 1; let i = 1;
while (await RNFS.exists(target)) { while (await RNFS.exists(target)) {
@@ -1040,6 +1088,30 @@ const SettingsScreen: React.FC = () => {
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text> </Text>
</View> </View>
<TouchableOpacity
onPress={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'open-' + Date.now(),
});
ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>👁</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'single-' + Date.now(),
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#34C759', fontSize:18}}></Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
// path-relativ-zu-uploads = nur der Dateiname, // path-relativ-zu-uploads = nur der Dateiname,
+7
View File
@@ -4038,6 +4038,7 @@
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div> <div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div> <div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
</div> </div>
<button class="btn secondary" onclick="openFileInline('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Öffnen">👁</button>
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button> <button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button>
<button class="btn secondary" onclick="showVersions('${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;" title="Versionen">🕒</button> <button class="btn secondary" onclick="showVersions('${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;" title="Versionen">🕒</button>
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button> <button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
@@ -4174,6 +4175,12 @@
window.location.href = '/api/files-download?path=' + encPath; 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) { async function deleteFile(p, name) {
if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return; if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return;
try { try {
+22 -3
View File
@@ -1622,7 +1622,10 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message })); res.end(JSON.stringify({ ok: false, error: err.message }));
} }
return; 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 u = new URL("http://x" + req.url);
const p = u.searchParams.get("path") || ""; const p = u.searchParams.get("path") || "";
const safe = path.resolve(p); const safe = path.resolve(p);
@@ -1633,10 +1636,26 @@ const server = http.createServer((req, res) => {
} }
const stat = fs.statSync(safe); const stat = fs.statSync(safe);
const fname = path.basename(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, { res.writeHead(200, {
"Content-Type": "application/octet-stream", "Content-Type": mime,
"Content-Length": stat.size, "Content-Length": stat.size,
"Content-Disposition": `attachment; filename="${fname}"`, "Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
}); });
fs.createReadStream(safe).pipe(res); fs.createReadStream(safe).pipe(res);
return; return;