feat: Datei-Manager Multi-Select + Bulk-Download (ZIP) + Bulk-Delete

Diagnostic + App bekommen Mehrfach-Auswahl im Datei-Manager. Mehr als eine
Datei ausgewaehlt → Download als ZIP. Genau eine ausgewaehlt → einzeln.
Bulk-Delete loescht alle markierten in einem Rutsch.

diagnostic/Dockerfile
  zip via apk add — fuer das ZIP-Streaming im /api/files-download-zip.

diagnostic/server.js
  POST /api/files-download-zip   Body: {paths:[...]} → spawnt 'zip -j -q -',
                                  Pipes stdout in Response. Whitelist auf
                                  /shared/uploads/.
  POST /api/files-delete-batch   Body: {paths:[...]} → loescht alle, broadcastet
                                  file_deleted pro Pfad an Browser + RVS.

diagnostic/index.html
  filesSelected Set + Checkbox-UI pro Datei + "Alle markieren". Wenn 2+
  ausgewaehlt: POST an /api/files-download-zip, Browser saugt das als
  Blob runter. Bei 1: normaler Single-Download.

bridge/aria_bridge.py
  file_delete_batch_request    → ruft Diagnostic /api/files-delete-batch,
                                 antwortet mit file_delete_batch_response.
  file_zip_request {paths,reqId} → ruft Diagnostic /api/files-download-zip,
                                   base64-kodiert, capped auf 30 MB,
                                   sendet file_zip_response.

rvs/server.js
  ALLOWED_TYPES: file_delete_batch_request/response, file_zip_request/response.

android/src/screens/SettingsScreen.tsx
  fileManagerSelected Set + Checkbox-UI pro Datei + "Alle markieren"-Zeile
  oben. Bulk-Bar oben mit count, "⬇ ZIP" / "⬇ Download" (je nach Anzahl),
  und "🗑 Löschen". ZIP-Response landet base64 → RNFS in Downloads-Folder
  (aria-files-<timestamp>.zip), Toast mit Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:30:40 +02:00
parent 0ec4b00879
commit 3ae9e19524
6 changed files with 455 additions and 61 deletions
+193 -54
View File
@@ -155,6 +155,9 @@ const SettingsScreen: React.FC = () => {
const [fileManagerError, setFileManagerError] = useState('');
const [fileManagerSearch, setFileManagerSearch] = useState('');
const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all');
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
const [fileZipBusy, setFileZipBusy] = useState(false);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -395,9 +398,39 @@ const SettingsScreen: React.FC = () => {
const p: any = message.payload || {};
if (p.path) {
setFileManagerFiles(prev => prev.filter(f => f.path !== p.path));
setFileManagerSelected(prev => {
if (!prev.has(p.path)) return prev;
const next = new Set(prev);
next.delete(p.path);
return next;
});
}
}
// Datei-Manager: ZIP-Response (Multi-Download)
if (message.type === ('file_zip_response' as any)) {
const p: any = message.payload || {};
if (p.requestId && p.requestId !== fileZipPending.current) return; // veraltet
fileZipPending.current = null;
setFileZipBusy(false);
if (!p.ok || !p.data) {
ToastAndroid.show('ZIP fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
// base64 → in Downloads-Ordner schreiben
(async () => {
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/aria-files-${ts}.zip`;
await RNFS.writeFile(filePath, p.data, 'base64');
ToastAndroid.show(`ZIP gespeichert: ${filePath} (${Math.round((p.size||0)/1024)} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('ZIP speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
@@ -644,64 +677,170 @@ const SettingsScreen: React.FC = () => {
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
) : fileManagerError ? (
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
) : (
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12}}>
{(() => {
let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q));
}
if (!files.length) {
return <Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>;
}
const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`;
return files.map(f => (
<View key={f.path} style={{
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8,
flexDirection:'row', alignItems:'center', gap:8,
}}>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center'}}>
) : (() => {
// Visible files (Filter+Suche)
let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q));
}
const visiblePaths = files.map(f => f.path);
const selectedHere = visiblePaths.filter(p => fileManagerSelected.has(p));
const allSelected = visiblePaths.length > 0 && selectedHere.length === visiblePaths.length;
const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`;
const toggleSelectAll = () => {
setFileManagerSelected(prev => {
const next = new Set(prev);
if (allSelected) visiblePaths.forEach(p => next.delete(p));
else visiblePaths.forEach(p => next.add(p));
return next;
});
};
const toggleOne = (p: string) => {
setFileManagerSelected(prev => {
const next = new Set(prev);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
};
const bulkDelete = () => {
const paths = [...fileManagerSelected];
if (!paths.length) return;
Alert.alert(
`${paths.length} Dateien löschen?`,
'In allen Chat-Bubbles werden sie als gelöscht markiert.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_batch_request' as any, { paths, requestId: 'batch-' + Date.now() });
setFileManagerSelected(new Set());
ToastAndroid.show(`${paths.length} Lösch-Befehle gesendet…`, ToastAndroid.SHORT);
}},
],
);
};
const bulkDownload = () => {
const paths = [...fileManagerSelected];
if (!paths.length) return;
// 1 Datei: einfach via file_request (existing pattern). ZIP nur bei 2+.
if (paths.length === 1) {
rvs.send('file_request' as any, { serverPath: paths[0], requestId: 'single-' + Date.now() });
ToastAndroid.show('Datei wird heruntergeladen…', ToastAndroid.SHORT);
return;
}
const reqId = 'zip-' + Date.now();
fileZipPending.current = reqId;
setFileZipBusy(true);
rvs.send('file_zip_request' as any, { paths, requestId: reqId });
ToastAndroid.show(`ZIP wird erstellt (${paths.length} Dateien)…`, ToastAndroid.LONG);
};
return (
<>
{/* Bulk-Bar */}
<View style={{paddingHorizontal:12, paddingBottom:8, flexDirection:'row', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<TouchableOpacity onPress={toggleSelectAll} style={{flexDirection:'row', alignItems:'center', gap:6, paddingVertical:4}}>
<View style={{
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: allSelected ? '#0096FF' : '#555570',
backgroundColor: allSelected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
{allSelected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
</View>
<Text style={{color:'#E0E0F0', fontSize:13}}>Alle markieren</Text>
</TouchableOpacity>
{fileManagerSelected.size > 0 && (
<>
<Text style={{color:'#555570', fontSize:13}}>·</Text>
<Text style={{color:'#0096FF', fontSize:13, fontWeight:'600'}}>{fileManagerSelected.size} ausgewählt</Text>
<TouchableOpacity
onPress={bulkDownload}
disabled={fileZipBusy}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22', opacity: fileZipBusy ? 0.5 : 1}}
>
<Text style={{color:'#0096FF', fontSize:12}}>{fileZipBusy ? '⏳ ZIP…' : (fileManagerSelected.size > 1 ? '⬇ ZIP' : '⬇ Download')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={bulkDelete}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#FF6B6B22'}}
>
<Text style={{color:'#FF6B6B', fontSize:12}}>🗑 Löschen</Text>
</TouchableOpacity>
</>
)}
</View>
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12, paddingTop:0}}>
{!files.length ? (
<Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>
) : files.map(f => {
const selected = fileManagerSelected.has(f.path);
return (
<TouchableOpacity
key={f.path}
onPress={() => toggleOne(f.path)}
activeOpacity={0.7}
style={{
backgroundColor: selected ? '#1E2C44' : '#0D0D1A',
padding:12, borderRadius:8, marginBottom:8,
flexDirection:'row', alignItems:'center', gap:8,
borderWidth: selected ? 1 : 0, borderColor:'#0096FF',
}}
>
<View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
backgroundColor: selected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
{f.fromAria ? 'ARIA' : 'USER'}
{selected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
</View>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center'}}>
<View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
}}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
{f.fromAria ? 'ARIA' : 'USER'}
</Text>
</View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
Alert.alert(
'Datei löschen?',
`"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_request' as any, { path: f.path });
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
}},
],
);
}}
style={{padding:8}}
>
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
</TouchableOpacity>
</View>
));
})()}
</ScrollView>
)}
<TouchableOpacity
onPress={() => {
Alert.alert(
'Datei löschen?',
`"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_request' as any, { path: f.path });
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
}},
],
);
}}
style={{padding:8}}
>
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
</TouchableOpacity>
</TouchableOpacity>
);
})}
</ScrollView>
</>
);
})()}
</View>
</Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>