fixed, long chats not loading to end, saved attachments in local folder on android., if file missing redownload over shared folde via rvs server, andord app added settingss for local storage path, updated readme
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
@@ -34,8 +35,9 @@ interface Attachment {
|
||||
type: 'image' | 'file' | 'audio';
|
||||
name: string;
|
||||
size?: number;
|
||||
uri?: string; // Lokaler Pfad oder data URI fuer Anzeige
|
||||
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
|
||||
mimeType?: string;
|
||||
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -50,6 +52,33 @@ interface ChatMessage {
|
||||
|
||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||
const MAX_STORED_MESSAGES = 500;
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
async function getAttachmentDir(): Promise<string> {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
|
||||
return saved || DEFAULT_ATTACHMENT_DIR;
|
||||
} catch { return DEFAULT_ATTACHMENT_DIR; }
|
||||
}
|
||||
|
||||
/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
|
||||
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
|
||||
const cacheDir = await getAttachmentDir();
|
||||
await RNFS.mkdir(cacheDir);
|
||||
// Dateiendung aus originalem Dateinamen oder Fallback
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||
const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = `${cacheDir}/${safeName}`;
|
||||
await RNFS.writeFile(filePath, base64Data, 'base64');
|
||||
return `file://${filePath}`;
|
||||
}
|
||||
|
||||
/** Prueft ob eine lokale Datei noch existiert */
|
||||
async function checkFileExists(uri: string): Promise<boolean> {
|
||||
if (!uri || !uri.startsWith('file://')) return false;
|
||||
return RNFS.exists(uri.replace('file://', ''));
|
||||
}
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
@@ -96,6 +125,40 @@ const ChatScreen: React.FC = () => {
|
||||
// RVS-Nachrichten abonnieren
|
||||
useEffect(() => {
|
||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
|
||||
if (message.type === 'file_saved') {
|
||||
const serverPath = (message.payload.serverPath as string) || '';
|
||||
const name = (message.payload.name as string) || '';
|
||||
if (serverPath) {
|
||||
setMessages(prev => prev.map(m => ({
|
||||
...m,
|
||||
attachments: m.attachments?.map(a =>
|
||||
a.name === name && !a.serverPath ? { ...a, serverPath } : a
|
||||
),
|
||||
})));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// file_response: Re-Download von Server — lokal speichern
|
||||
if (message.type === 'file_response') {
|
||||
const reqId = (message.payload.requestId as string) || '';
|
||||
const b64 = (message.payload.base64 as string) || '';
|
||||
const serverPath = (message.payload.serverPath as string) || '';
|
||||
if (b64 && reqId) {
|
||||
const fileName = (message.payload.name as string) || 'download';
|
||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||
setMessages(prev => prev.map(m => ({
|
||||
...m,
|
||||
attachments: m.attachments?.map(a =>
|
||||
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
||||
),
|
||||
})));
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// STT-Ergebnis: Spracheingabe-Placeholder mit transkribiertem Text ersetzen
|
||||
if (message.type === 'stt_result') {
|
||||
const sttText = (message.payload.text as string) || '';
|
||||
@@ -216,7 +279,14 @@ const ChatScreen: React.FC = () => {
|
||||
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
const toStore = messages.slice(-MAX_STORED_MESSAGES);
|
||||
// Nur file:// URIs speichern, data: URIs rausfiltern (zu gross)
|
||||
const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
|
||||
...msg,
|
||||
attachments: msg.attachments?.map(att => ({
|
||||
...att,
|
||||
uri: att.uri?.startsWith('file://') ? att.uri : undefined,
|
||||
})),
|
||||
}));
|
||||
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err =>
|
||||
console.error('[Chat] Fehler beim Speichern:', err),
|
||||
);
|
||||
@@ -310,8 +380,11 @@ const ChatScreen: React.FC = () => {
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const msgId = nextId();
|
||||
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: 'Anhang empfangen',
|
||||
timestamp: Date.now(),
|
||||
@@ -319,12 +392,21 @@ const ChatScreen: React.FC = () => {
|
||||
type: isImage ? 'image' : 'file',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uri: isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri,
|
||||
uri: imageUri,
|
||||
mimeType: file.type,
|
||||
}],
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
// Anhang auf Disk speichern fuer Persistenz
|
||||
if (file.base64) {
|
||||
persistAttachment(file.base64, msgId, file.name).then(filePath => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
||||
));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
rvs.send('file', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
@@ -339,20 +421,32 @@ const ChatScreen: React.FC = () => {
|
||||
setShowCameraUpload(false);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const msgId = nextId();
|
||||
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: 'Anhang empfangen',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
name: photo.fileName,
|
||||
uri: photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined,
|
||||
uri: dataUri,
|
||||
mimeType: photo.type,
|
||||
}],
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
// Foto auf Disk speichern fuer Persistenz
|
||||
if (photo.base64) {
|
||||
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
||||
));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
rvs.send('file', {
|
||||
name: photo.fileName,
|
||||
type: photo.type,
|
||||
@@ -382,7 +476,30 @@ const ChatScreen: React.FC = () => {
|
||||
source={{ uri: att.uri }}
|
||||
style={styles.attachmentImage}
|
||||
resizeMode="contain"
|
||||
onError={() => {
|
||||
// Bild nicht mehr verfuegbar — Placeholder setzen
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
|
||||
i === idx ? { ...a, uri: undefined } : a
|
||||
)} : m
|
||||
));
|
||||
}}
|
||||
/>
|
||||
) : att.type === 'image' && !att.uri ? (
|
||||
<TouchableOpacity
|
||||
style={styles.attachmentFile}
|
||||
onPress={() => {
|
||||
if (att.serverPath) {
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||
<Text style={styles.attachmentFileSize}>
|
||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.attachmentFile}>
|
||||
<Text style={styles.attachmentFileIcon}>
|
||||
@@ -393,6 +510,12 @@ const ChatScreen: React.FC = () => {
|
||||
</Text>
|
||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
|
||||
{!att.uri && att.serverPath && (
|
||||
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
|
||||
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -16,10 +16,15 @@ import {
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
interface LogEntry {
|
||||
@@ -62,6 +67,10 @@ const SettingsScreen: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [events, setEvents] = useState<EventEntry[]>([]);
|
||||
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog());
|
||||
const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH);
|
||||
const [storageSize, setStorageSize] = useState('...');
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [tempPath, setTempPath] = useState('');
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
||||
@@ -73,8 +82,64 @@ const SettingsScreen: React.FC = () => {
|
||||
setManualPort(String(config.port));
|
||||
setManualToken(config.token);
|
||||
}
|
||||
// Speicherpfad laden
|
||||
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
|
||||
if (saved) setStoragePath(saved);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Speichergroesse berechnen
|
||||
useEffect(() => {
|
||||
const calcSize = async () => {
|
||||
try {
|
||||
const exists = await RNFS.exists(storagePath);
|
||||
if (!exists) { setStorageSize('0 KB'); return; }
|
||||
const items = await RNFS.readDir(storagePath);
|
||||
const totalBytes = items.reduce((sum, f) => sum + (f.size || 0), 0);
|
||||
if (totalBytes > 1024 * 1024) {
|
||||
setStorageSize(`${(totalBytes / 1024 / 1024).toFixed(1)} MB (${items.length} Dateien)`);
|
||||
} else {
|
||||
setStorageSize(`${Math.round(totalBytes / 1024)} KB (${items.length} Dateien)`);
|
||||
}
|
||||
} catch { setStorageSize('nicht verfuegbar'); }
|
||||
};
|
||||
calcSize();
|
||||
}, [storagePath]);
|
||||
|
||||
const saveStoragePath = useCallback(async (newPath: string) => {
|
||||
const clean = newPath.trim();
|
||||
if (!clean) return;
|
||||
await AsyncStorage.setItem(STORAGE_PATH_KEY, clean);
|
||||
setStoragePath(clean);
|
||||
setEditingPath(false);
|
||||
Alert.alert('Gespeichert', `Neuer Speicherort:\n${clean}\n\nWird ab der naechsten Nachricht verwendet.`);
|
||||
}, []);
|
||||
|
||||
const clearStorageCache = useCallback(async () => {
|
||||
Alert.alert(
|
||||
'Cache loeschen',
|
||||
`Alle lokalen Anhaenge in\n${storagePath}\nloeschen?\n\nDateien koennen ueber RVS erneut heruntergeladen werden.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const exists = await RNFS.exists(storagePath);
|
||||
if (exists) await RNFS.unlink(storagePath);
|
||||
await RNFS.mkdir(storagePath);
|
||||
setStorageSize('0 KB (0 Dateien)');
|
||||
Alert.alert('Erledigt', 'Cache geleert. Anhaenge werden bei Bedarf neu geladen.');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Fehler', e.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [storagePath]);
|
||||
|
||||
// RVS-Nachrichten und Verbindungslog abonnieren
|
||||
useEffect(() => {
|
||||
const unsubState = rvs.onStateChange(setConnectionState);
|
||||
@@ -332,6 +397,62 @@ const SettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Speicher === */}
|
||||
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Hier werden Bilder und Dateien aus dem Chat gespeichert.
|
||||
Bei geloeschtem Cache werden Anhaenge automatisch ueber RVS neu geladen.
|
||||
</Text>
|
||||
|
||||
{editingPath ? (
|
||||
<View style={{marginTop: 10}}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={tempPath}
|
||||
onChangeText={setTempPath}
|
||||
placeholder="z.B. /storage/emulated/0/ARIA/attachments"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View style={{flexDirection: 'row', gap: 8}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
onPress={() => saveStoragePath(tempPath)}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Speichern</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||||
onPress={() => setEditingPath(false)}
|
||||
>
|
||||
<Text style={styles.clearButtonText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{marginTop: 10}}>
|
||||
<Text style={styles.storagePathText} numberOfLines={2}>{storagePath}</Text>
|
||||
<Text style={styles.storageSizeText}>{storageSize}</Text>
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||||
onPress={() => { setTempPath(storagePath); setEditingPath(true); }}
|
||||
>
|
||||
<Text style={styles.clearButtonText}>Pfad aendern</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, {flex: 1, marginTop: 0, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
||||
onPress={clearStorageCache}
|
||||
>
|
||||
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Cache leeren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* === Logs === */}
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
<View style={styles.card}>
|
||||
@@ -559,6 +680,18 @@ const styles = StyleSheet.create({
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Speicher
|
||||
storagePathText: {
|
||||
color: '#0096FF',
|
||||
fontSize: 12,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
storageSizeText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
// Logs
|
||||
tabRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user