Files
ARIA-AGENT/android/src/screens/SettingsScreen.tsx
T
duffyduck 98982fea2f feat(app): App-Logs live im Settings → Protokoll → Live Logs Tab anzeigen
Stefan: "wir haben live log + events tab in protokoll einstellungen, da
ist aber nie was drin".

Bisher hoerten Live Logs / Events nur auf RVS-Messages type='log'/'event'
von der Bridge — die Bridge schickt aktuell aber keine solchen Messages
zurueck zur App. Plus: reportAppDebug/Error ging nur an die Bridge in
/shared/logs/app.log, lokal in der App war nichts sichtbar.

Loesung: lokaler DeviceEventEmitter-Bus.

logger.ts:
- APP_LOG_EVENT Konstante exportiert
- reportAppError + reportAppDebug emittieren ZUSAETZLICH zum
  RVS-Send ein lokales DeviceEventEmitter-Event (errors immer,
  debug nur wenn Toggle AN)

SettingsScreen.tsx:
- DeviceEventEmitter.addListener auf APP_LOG_EVENT
- Mappt Log-Entries 1:1 in den 'logs'-State (max 200)
- Cleanup in useEffect-return

Damit sieht Stefan beim Debuggen (Debug-Toggle AN, Live-Logs-Tab
offen) live in der App was passiert — ohne curl gegen Bridge.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:44:42 +02:00

2563 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SettingsScreen - Einstellungen und Verbindungsverwaltung
*
* QR-Scanner fuer Pairing, Moduswahl, GPS-Toggle, Log-Viewer.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
Switch,
StyleSheet,
Alert,
Platform,
ToastAndroid,
ActivityIndicator,
Modal,
PermissionsAndroid,
useWindowDimensions,
DeviceEventEmitter,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import DocumentPicker from 'react-native-document-picker';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import {
TTS_PREROLL_DEFAULT_SEC,
TTS_PREROLL_MIN_SEC,
TTS_PREROLL_MAX_SEC,
TTS_PREROLL_STORAGE_KEY,
VAD_SILENCE_DEFAULT_SEC,
VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC,
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
VAD_SILENCE_DB_OVERRIDE_KEY,
TTS_SPEED_DEFAULT,
TTS_SPEED_MIN,
TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService, {
isBackgroundGpsEnabled,
setBackgroundGpsEnabled,
ensureBackgroundLocationPermission,
} from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
playWakeReadySound,
} from '../services/wakeReadySound';
import wakeWordService, {
WAKE_KEYWORDS,
KEYWORD_LABELS,
DEFAULT_KEYWORD,
WAKE_KEYWORD_STORAGE,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal';
import updateService from '../services/updater';
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
// --- Typen ---
interface LogEntry {
id: string;
timestamp: number;
source: string;
message: string;
level: 'info' | 'warn' | 'error';
}
interface EventEntry {
id: string;
timestamp: number;
title: string;
description: string;
}
type LogTab = 'live' | 'events';
// Settings-Sub-Screens. Reihenfolge im Hauptmenue.
const SETTINGS_SECTIONS = [
{ id: 'connection', icon: '🔌', label: 'Verbindung', desc: 'Server, Token, Status, Verbindungslog' },
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
// Container-Farben fuer Live-Logs
const SOURCE_COLORS: Record<string, string> = {
'aria-core': '#4A9EFF', // Blau
bridge: '#FFD60A', // Gelb
proxy: '#FFFFFF', // Weiss
rvs: '#34C759', // Gruen
default: '#8888AA', // Grau
};
// --- Komponente ---
const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
const [manualHost, setManualHost] = useState('');
const [manualPort, setManualPort] = useState('8765');
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
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 [autoDownload, setAutoDownload] = useState(true);
const [storageSize, setStorageSize] = useState('...');
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [debugLogsToBridge, setDebugLogsToBridgeState] = useState<boolean>(isDebugLogsToBridge());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
// Datei-Manager
const [fileManagerOpen, setFileManagerOpen] = useState(false);
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean}>>([]);
const [fileManagerLoading, setFileManagerLoading] = useState(false);
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.
// So bleibt aller geteilte State im selben Component-Closure und wir
// brauchen keine react-navigation-Stack-Setup.
const [currentSection, setCurrentSection] = useState<string | null>(null);
let logIdCounter = 0;
// Gespeicherte Config in die Felder laden
useEffect(() => {
const config = rvs.getConfig();
if (config) {
setManualHost(config.host);
setManualPort(String(config.port));
setManualToken(config.token);
}
// Speicherpfad + Auto-Download laden
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
if (saved) setStoragePath(saved);
});
AsyncStorage.getItem('aria_auto_download').then(saved => {
if (saved !== null) setAutoDownload(saved === 'true');
});
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
if (saved !== null) setTtsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_background_mode').then(saved => {
// Default ist an — nur explicit 'false' deaktiviert
setBackgroundMode(saved !== 'false');
});
AsyncStorage.getItem('aria_show_hints').then(saved => {
// Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
gpsTrackingService.restoreFromStorage().catch(() => {});
// Background-GPS-Toggle initial laden
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
setTtsPrerollSec(n);
}
}
});
AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
setVadSilenceSec(n);
}
}
});
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
setConvWindowSec(n);
}
}
});
AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
setMaxRecordingSec(n);
}
}
});
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) {
setVadSilenceDb(n);
}
}
});
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n);
}
});
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
});
isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved);
});
// Voice-Liste vom XTTS-Server holen (via RVS)
rvs.send('xtts_list_voices' as any, {});
return () => {
// gpsTrackingService-Listener abmelden (Variable offGps oben definiert)
try { offGps(); } catch {}
};
}, []);
// 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 showPathPicker = useCallback(() => {
Alert.alert(
'Speicherort waehlen',
'Wo sollen Anhaenge gespeichert werden?',
[
{
text: 'Ordner auswaehlen...',
onPress: async () => {
try {
const result = await DocumentPicker.pickDirectory();
if (result?.uri) {
// SAF URI decodieren (content://com.android.externalstorage...)
const decoded = decodeURIComponent(result.uri);
// Versuche einen lesbaren Pfad zu extrahieren
const match = decoded.match(/primary[:%]3A(.+)/);
const readablePath = match
? `/storage/emulated/0/${match[1].replace(/%2F|%3A/g, '/')}`
: decoded;
saveStoragePath(readablePath);
}
} catch (e: any) {
if (!DocumentPicker.isCancel(e)) {
Alert.alert('Fehler', 'Ordnerauswahl fehlgeschlagen');
}
}
},
},
{
text: 'App-intern (Standard)',
onPress: () => saveStoragePath(DEFAULT_STORAGE_PATH),
},
{
text: 'Pfad manuell eingeben',
onPress: () => { setTempPath(storagePath); setEditingPath(true); },
},
{ text: 'Abbrechen', style: 'cancel' as const },
],
);
}, [storagePath]);
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);
setConnectionState(rvs.getState());
const unsubLog = rvs.onLog((entry) => {
setConnLog(prev => [...prev.slice(-99), entry]);
});
// Lokale App-Logs (reportAppDebug/Error) im Live-Logs-Tab anzeigen
// — damit Stefan ohne curl direkt in der App sieht was passiert.
const localLogSub = DeviceEventEmitter.addListener(APP_LOG_EVENT, (e: any) => {
const entry: LogEntry = {
id: `applog_${e.ts || Date.now()}_${logIdCounter++}`,
timestamp: e.ts || Date.now(),
source: e.scope || 'app',
message: e.message || '',
level: e.level || 'info',
};
setLogs(prev => [...prev.slice(-200), entry]);
});
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
if (message.type === 'log') {
const entry: LogEntry = {
id: `log_${Date.now()}_${logIdCounter++}`,
timestamp: message.timestamp,
source: (message.payload.source as string) || 'default',
message: (message.payload.message as string) || '',
level: (message.payload.level as 'info' | 'warn' | 'error') || 'info',
};
setLogs(prev => [...prev.slice(-200), entry]); // Max 200 Eintraege behalten
}
if (message.type === 'event') {
const entry: EventEntry = {
id: `evt_${Date.now()}_${logIdCounter++}`,
timestamp: message.timestamp,
title: (message.payload.title as string) || '',
description: (message.payload.description as string) || '',
};
setEvents(prev => [...prev.slice(-100), entry]);
}
// Modus-Bestaetigung
if (message.type === 'mode') {
const mode = message.payload.mode as string;
if (mode) setCurrentMode(mode);
}
// XTTS-Voice-Liste
if (message.type === ('xtts_voices_list' as any)) {
const voices = ((message.payload as any).voices || []) as Array<{name: string, size: number}>;
setAvailableVoices(voices);
}
// Datei-Manager: Liste empfangen
if (message.type === ('file_list_response' as any)) {
const p: any = message.payload || {};
if (p.ok) {
setFileManagerFiles(p.files || []);
} else {
setFileManagerError(p.error || 'Unbekannter Fehler');
}
setFileManagerLoading(false);
}
// Datei-Manager: Datei wurde geloescht (vom Diagnostic oder dieser App)
if (message.type === ('file_deleted' as any)) {
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;
});
}
}
// ARIA bittet um GPS-Tracking An/Aus (Tool request_location_tracking)
if (message.type === ('location_tracking' as any)) {
const p: any = message.payload || {};
const on = !!p.on;
const reason = (p.reason as string) || 'ARIA';
if (on) {
gpsTrackingService.start(reason).catch(() => {});
} else {
gpsTrackingService.stop(reason);
}
}
// 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;
if (name) {
setXttsVoice(name);
AsyncStorage.setItem('aria_xtts_voice', name);
}
rvs.send('xtts_list_voices' as any, {});
}
// Diagnostic-Voice-Wechsel → lokale App-Stimme auf den neuen Default zuruecksetzen.
// Zusaetzlich Preload triggern, damit der User weiss wann's geladen ist.
if (message.type === ('config' as any)) {
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
setXttsVoice(newVoice);
AsyncStorage.setItem('aria_xtts_voice', newVoice);
if (newVoice) {
setLoadingVoice(newVoice);
}
}
// XTTS-Bridge meldet: Stimme fertig geladen
if (message.type === ('voice_ready' as any)) {
const v = ((message.payload as any).voice as string) ?? '';
const err = (message.payload as any).error as string | undefined;
const ms = (message.payload as any).loadMs as number | undefined;
setLoadingVoice(null);
if (err) {
ToastAndroid.show(`Stimme "${v}" konnte nicht geladen werden: ${err}`, ToastAndroid.LONG);
} else {
const suffix = ms ? ` (${(ms / 1000).toFixed(1)}s)` : '';
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit${suffix}`, ToastAndroid.SHORT);
}
}
});
return () => {
unsubState();
unsubMessage();
unsubLog();
localLogSub.remove();
};
}, []);
// --- QR-Code scannen ---
const openQRScanner = useCallback(() => {
setScannerVisible(true);
}, []);
const handleQRScan = useCallback((config: ConnectionConfig) => {
setScannerVisible(false);
// Felder im UI aktualisieren
setManualHost(config.host);
setManualPort(String(config.port));
setManualToken(config.token);
// Direkt verbinden
rvs.setConfig(config);
rvs.connect();
Alert.alert(
'Pairing erfolgreich',
`Verbinde mit ${config.host}:${config.port}...`,
);
}, []);
// --- Manuelle Verbindung ---
const connectManually = useCallback(() => {
if (!manualHost.trim() || !manualToken.trim()) {
Alert.alert('Fehler', 'Host und Token muessen angegeben werden.');
return;
}
const config: ConnectionConfig = {
host: manualHost.trim(),
port: parseInt(manualPort, 10) || 8765,
token: manualToken.trim(),
useTLS: true,
};
rvs.setConfig(config);
rvs.connect();
}, [manualHost, manualPort, manualToken]);
const disconnectRVS = useCallback(() => {
rvs.disconnect();
}, []);
// --- GPS Toggle ---
const handleGPSToggle = useCallback(async (value: boolean) => {
if (value && Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
{
title: 'ARIA — Standort an Anfragen anhaengen',
message: 'Damit ARIA bei Anfragen wie "Wo ist der naechste...?" den '
+ 'Standort kennt, darf die App den ungefaehren Standort lesen. '
+ 'Wird nur bei jeder Anfrage einmal abgerufen, nicht im Hintergrund.',
buttonPositive: 'Erlauben',
buttonNegative: 'Abbrechen',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
ToastAndroid.show('Standort-Berechtigung abgelehnt', ToastAndroid.SHORT);
return;
}
} catch (err) {
console.warn('[Settings] GPS-Permission Request gescheitert:', err);
return;
}
}
setGpsEnabled(value);
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []);
// --- Hintergrund-Modus Toggle ---
const handleBackgroundModeToggle = useCallback(async (value: boolean) => {
setBackgroundMode(value);
AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {});
try {
if (value) {
// Permission fuer Notification (Android 13+) — sonst sieht der User
// den Hintergrund-Modus nicht und wundert sich
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
}
await acquireBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT);
} else {
await releaseBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT);
}
} catch (err: any) {
console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err);
}
}, []);
// --- System-Hints Toggle ---
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
setShowSystemHints(value);
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
setXttsVoice(voiceName);
AsyncStorage.setItem('aria_xtts_voice', voiceName);
// Preload nur fuer Custom-Voices — "Standard" braucht keinen Ladevorgang
if (voiceName) {
setLoadingVoice(voiceName);
rvs.send('voice_preload' as any, { voice: voiceName, source: 'app' });
} else {
setLoadingVoice(null);
}
}, []);
const deleteVoice = useCallback((name: string) => {
Alert.alert(
'Stimme loeschen',
`Stimme "${name}" vom Server endgueltig loeschen?\nAlle Apps verlieren sie.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: () => {
rvs.send('xtts_delete_voice' as any, { name });
if (xttsVoice === name) {
setXttsVoice('');
AsyncStorage.setItem('aria_xtts_voice', '');
}
// Liste nach kurzer Wartezeit neu laden (XTTS-Bridge schickt eh neue Liste)
setTimeout(() => rvs.send('xtts_list_voices' as any, {}), 500);
},
},
],
);
}, [xttsVoice]);
// --- Modus aendern ---
const handleModeChange = useCallback((modeId: string) => {
setCurrentMode(modeId);
}, []);
// --- Zeitformat ---
const formatTime = (ts: number): string => {
return new Date(ts).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
// --- Verbindungsstatus ---
const connectionDotColor =
connectionState === 'connected' ? '#34C759' :
connectionState === 'connecting' ? '#FFD60A' : '#FF3B30';
const connectionLabel =
connectionState === 'connected' ? 'Verbunden' :
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt';
return (
<>
<QRScanner
visible={scannerVisible}
onScan={handleQRScan}
onClose={() => setScannerVisible(false)}
/>
<VoiceCloneModal
visible={voiceCloneVisible}
onClose={() => setVoiceCloneVisible(false)}
/>
{/* Datei-Manager Modal */}
<Modal
visible={fileManagerOpen}
animationType="slide"
onRequestClose={() => setFileManagerOpen(false)}
>
<View style={{flex:1, backgroundColor:'#080810', paddingTop:24}}>
<View style={{flexDirection:'row', alignItems:'center', padding:12, borderBottomWidth:1, borderColor:'#1E1E2E'}}>
<TouchableOpacity onPress={() => setFileManagerOpen(false)} style={{padding:8}}>
<Text style={{color:'#0096FF', fontSize:24}}></Text>
</TouchableOpacity>
<Text style={{color:'#E0E0F0', fontSize:18, fontWeight:'600', flex:1, marginLeft:8}}>Dateien</Text>
<TouchableOpacity
onPress={() => {
setFileManagerError('');
setFileManagerLoading(true);
rvs.send('file_list_request' as any, {});
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:14}}>🔄</Text>
</TouchableOpacity>
</View>
<View style={{padding:12}}>
<TextInput
style={{backgroundColor:'#1E1E2E', borderRadius:8, padding:10, color:'#E0E0F0', fontSize:14}}
placeholder="Suche..."
placeholderTextColor="#555570"
value={fileManagerSearch}
onChangeText={setFileManagerSearch}
autoCapitalize="none"
/>
<View style={{flexDirection:'row', marginTop:8, gap:6}}>
{(['all','aria','user'] as const).map(f => (
<TouchableOpacity
key={f}
onPress={() => setFileManagerFilter(f)}
style={{
paddingVertical:6, paddingHorizontal:12, borderRadius:14,
backgroundColor: fileManagerFilter === f ? '#0096FF' : '#1E1E2E',
}}
>
<Text style={{color: fileManagerFilter === f ? '#fff' : '#8888AA', fontSize:12}}>
{f === 'all' ? 'Alle' : f === 'aria' ? 'Von ARIA' : 'Von dir'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{fileManagerLoading ? (
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
) : fileManagerError ? (
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
) : (() => {
// 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={{
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
backgroundColor: selected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
{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>
<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}
nestedScrollEnabled={true}
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
// scrolling laesst sonst nur in eine Richtung scrollen.
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
>
{currentSection === null && (
<>
{SETTINGS_SECTIONS.map(s => (
<TouchableOpacity
key={s.id}
style={styles.menuItem}
onPress={() => setCurrentSection(s.id)}
>
<Text style={styles.menuItemIcon}>{s.icon}</Text>
<View style={styles.menuItemTextWrap}>
<Text style={styles.menuItemLabel}>{s.label}</Text>
<Text style={styles.menuItemDesc}>{s.desc}</Text>
</View>
<Text style={styles.menuItemChevron}></Text>
</TouchableOpacity>
))}
</>
)}
{currentSection !== null && (
<TouchableOpacity
style={styles.subScreenHeader}
onPress={() => setCurrentSection(null)}
>
<Text style={styles.subScreenBack}></Text>
<Text style={styles.subScreenTitle}>
{SETTINGS_SECTIONS.find(s => s.id === currentSection)?.label || ''}
</Text>
</TouchableOpacity>
)}
{/* === Verbindung === */}
{currentSection === 'connection' && (<>
<Text style={styles.sectionTitle}>Verbindung</Text>
<View style={styles.card}>
{/* Status-Anzeige */}
<View style={styles.statusRow}>
<View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
<Text style={styles.statusLabel}>{connectionLabel}</Text>
{connectionState === 'connected' && (
<TouchableOpacity style={styles.disconnectButton} onPress={disconnectRVS}>
<Text style={styles.disconnectText}>Trennen</Text>
</TouchableOpacity>
)}
</View>
{/* QR-Scanner */}
<TouchableOpacity style={styles.qrButton} onPress={openQRScanner}>
<Text style={styles.qrIcon}>{'\uD83D\uDCF1'}</Text>
<Text style={styles.qrText}>QR-Code scannen (Pairing)</Text>
</TouchableOpacity>
{/* Manuelle Eingabe */}
<Text style={styles.inputLabel}>Host</Text>
<TextInput
style={styles.input}
value={manualHost}
onChangeText={setManualHost}
placeholder="z.B. aria.example.com"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={styles.inputLabel}>Port</Text>
<TextInput
style={styles.input}
value={manualPort}
onChangeText={setManualPort}
placeholder="8765"
placeholderTextColor="#555570"
keyboardType="numeric"
/>
<Text style={styles.inputLabel}>Token</Text>
<TextInput
style={styles.input}
value={manualToken}
onChangeText={setManualToken}
placeholder="Verbindungs-Token"
placeholderTextColor="#555570"
autoCapitalize="none"
secureTextEntry
/>
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
<Text style={styles.connectButtonText}>Verbinden</Text>
</TouchableOpacity>
</View>
{/* === Verbindungslog === */}
<Text style={styles.sectionTitle}>Verbindungslog</Text>
<View style={styles.card}>
<ScrollView
style={styles.connLogScroll}
nestedScrollEnabled={true}
ref={(ref) => {
// Auto-Scroll nach unten bei neuen Eintraegen
if (ref && connLog.length > 0) {
setTimeout(() => ref.scrollToEnd({ animated: false }), 50);
}
}}
>
{connLog.length > 0 ? (
connLog.slice(-50).map((entry, idx) => (
<View key={`cl_${idx}`} style={styles.logEntry}>
<Text style={styles.logTime}>{formatTime(entry.timestamp)}</Text>
<Text
style={[
styles.logMessage,
entry.level === 'error' && styles.logError,
entry.level === 'warn' && styles.logWarn,
]}
numberOfLines={3}
>
{entry.message}
</Text>
</View>
))
) : (
<Text style={styles.emptyLog}>Noch keine Verbindungsversuche</Text>
)}
</ScrollView>
<TouchableOpacity
style={styles.clearButton}
onPress={() => setConnLog([])}
>
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Modus === */}
{currentSection === 'general' && (<>
<Text style={styles.sectionTitle}>Betriebsmodus</Text>
<View style={styles.card}>
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
</View>
{/* === GPS === */}
<Text style={styles.sectionTitle}>Standort</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
<Text style={styles.toggleHint}>
Position (lat/lon) wird mit jeder Nachricht an ARIA mitgeschickt.
Sie sieht's nur intern und nutzt es bei standortbezogenen Fragen
("wo bin ich?", "Wetter hier?"), erwaehnt es sonst nicht.
Im Chat-Verlauf bleibt die Bubble unveraendert — nur ARIAs
Antwort kann darauf eingehen.
</Text>
</View>
<Switch
value={gpsEnabled}
onValueChange={handleGPSToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={gpsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
{/* GPS-Tracking (kontinuierlich) — fuer near()-Watcher */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS-Tracking (kontinuierlich)</Text>
<Text style={styles.toggleHint}>
Sendet alle ~15s deine Position an ARIA (wenn du dich {'>'}30m bewegt
hast). Nur noetig fuer GPS-basierte Trigger wie Blitzer-Warner
(near()-Conditions). ARIA kann das auch selbst an-/abschalten wenn
sie einen GPS-Watcher anlegt. Akku-Verbrauch erhoeht — bei langer
Fahrt einplanen.
</Text>
</View>
<Switch
value={gpsTracking}
onValueChange={(v) => {
if (v) gpsTrackingService.start('manuell').catch(() => {});
else gpsTrackingService.stop('manuell');
}}
trackColor={{ false: '#2A2A3E', true: '#FF9500' }}
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
/>
</View>
{/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION
(User muss in Android-Settings 'Immer erlauben' aktivieren). */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS auch im Hintergrund</Text>
<Text style={styles.toggleHint}>
Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die
App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus.
{'\n\n'}
Android verlangt fuer Background-GPS, dass du in den
System-Einstellungen unter Standort "Immer erlauben" auswaehlst.
Beim Aktivieren wird Android-Settings geoeffnet falls noetig.
{'\n\n'}
Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling.
</Text>
</View>
<Switch
value={bgGpsEnabled}
onValueChange={async (v) => {
if (v) {
const ok = await ensureBackgroundLocationPermission();
if (!ok) {
// User muss in Android-Settings auf "Immer erlauben" — Toggle
// bleibt aus bis er zurueckkommt und nochmal tippt.
return;
}
await setBackgroundGpsEnabled(true);
setBgGpsEnabled(true);
// Wenn Tracking bereits laeuft: neu starten damit der
// Foreground-Service jetzt mit location-Slot kommt
if (gpsTrackingService.isActive()) {
gpsTrackingService.stop('bg-toggle');
gpsTrackingService.start('bg-aktiviert').catch(() => {});
}
ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT);
} else {
await setBackgroundGpsEnabled(false);
setBgGpsEnabled(false);
ToastAndroid.show('Background-GPS aus nur noch Foreground', ToastAndroid.SHORT);
}
}}
trackColor={{ false: '#2A2A3E', true: '#FF3B30' }}
thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Bubble-Anzeige === */}
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
<Text style={styles.toggleHint}>
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
trotzdem, sie sind nur fuer dich nicht relevant.
</Text>
</View>
<Switch
value={showSystemHints}
onValueChange={handleShowSystemHintsToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Hintergrund-Modus === */}
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>App im Hintergrund weiterlaufen</Text>
<Text style={styles.toggleHint}>
Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert
ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher-
Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar
waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground-
Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut.
{'\n\n'}
Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen:
Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt"
setzen.
</Text>
</View>
<Switch
value={backgroundMode}
onValueChange={handleBackgroundModeToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={backgroundMode ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
{/* === Spracheingabe (geraetelokal) === */}
{currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
<Text style={styles.toggleHint}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
Nachdenken; niedriger = schnelleres Senden.
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10);
setVadSilenceSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
}}
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
>
<Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10);
setVadSilenceSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
}}
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
auch wenn keine Stille erkannt wurde. Nuetzlich fuer lange Erklaerungen
oder Diktate. Default: {Math.round(MAX_RECORDING_DEFAULT_SEC / 60)} Min, max {Math.round(MAX_RECORDING_MAX_SEC / 60)} Min.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(MAX_RECORDING_MIN_SEC, maxRecordingSec - 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec <= MAX_RECORDING_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1m</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{Math.round(maxRecordingSec / 60)} min</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(MAX_RECORDING_MAX_SEC, maxRecordingSec + 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec >= MAX_RECORDING_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity>
</View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
<Text style={styles.infoBtnText}>i</Text>
</TouchableOpacity>
</View>
<Text style={styles.toggleHint}>
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT - 1
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT + 1
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
{vadSilenceDb != null && (
<TouchableOpacity
onPress={() => {
setVadSilenceDb(null);
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
}}
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
>
<Text style={{color: '#0096FF', fontSize: 13}}> Auf automatisch zuruecksetzen</Text>
</TouchableOpacity>
)}
</View>
<Modal
visible={showVadInfo}
transparent
animationType="fade"
onRequestClose={() => setShowVadInfo(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Stille-Pegel (dB)</Text>
<Text style={styles.modalText}>
Lautstaerken werden in Dezibel (dB) gemessen negative Werte, je
hoeher (naeher an 0), desto lauter.{'\n\n'}
<Text style={{fontWeight: '700'}}>Standard:</Text> automatisch.
Die App misst die ersten 500ms Hintergrundpegel und setzt die
Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten
Umgebungen.{'\n\n'}
<Text style={{fontWeight: '700'}}>Manuell:</Text> Pegel unter dem
eingestellten Wert gilt als "Stille" Aufnahme stoppt.{'\n\n'}
<Text style={{fontWeight: '700'}}>Faustregel:</Text>{'\n'}
<Text style={{color: '#FFD60A'}}>45 dB</Text> sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'}
<Text style={{color: '#34C759'}}>38 dB</Text> ausgewogen (typische Bueroumgebung){'\n'}
<Text style={{color: '#FF6B6B'}}>25 dB</Text> unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'}
<Text style={{color: '#8888AA'}}>Niedrigere Zahl (z.B. 50) = sensibler.{'\n'}
Hoehere Zahl (z.B. 20) = robuster gegen Hintergrundlaerm,
braucht aber lautere Sprache.</Text>
</Text>
<TouchableOpacity
style={[styles.connectButton, {marginTop: 16, alignSelf: 'stretch'}]}
onPress={() => setShowVadInfo(false)}
>
<Text style={styles.connectButtonText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</>)}
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
{currentSection === 'wake_word' && (<>
<Text style={styles.sectionTitle}>Wake-Word</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key,
kein Cloud-Roundtrip Audio verlaesst das Geraet nicht. Wenn das Ohr
aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine
Konversation mit ARIA.
</Text>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
<Text style={styles.toggleHint}>
Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis).
Custom-Upload ueber Diagnostic kommt in einer spaeteren Version.
</Text>
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
{WAKE_KEYWORDS.map(kw => (
<TouchableOpacity
key={kw}
style={[
styles.keywordChip,
wakeKeyword === kw && styles.keywordChipActive,
]}
onPress={() => setWakeKeyword(kw)}
>
<Text style={[
styles.keywordChipText,
wakeKeyword === kw && styles.keywordChipTextActive,
]}>
{KEYWORD_LABELS[kw]}
</Text>
</TouchableOpacity>
))}
</View>
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
onPress={async () => {
setWakeStatus('Initialisiere...');
try {
const ok = await wakeWordService.configure(wakeKeyword);
setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen');
} catch (err: any) {
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
}
setTimeout(() => setWakeStatus(''), 5000);
}}
>
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
</TouchableOpacity>
</View>
{!!wakeStatus && (
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
)}
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Bereit-Sound abspielen</Text>
<Text style={styles.toggleHint}>
Kurzer Ding-Dong wenn das Mikro nach Wake-Word offen ist
akustische Bestaetigung dass du jetzt sprechen darfst.
</Text>
</View>
<Switch
value={wakeReadySound}
onValueChange={async (val) => {
setWakeReadySound(val);
await setWakeReadySoundEnabled(val);
if (val) {
// Direkt eine Vorschau abspielen damit der User weiss wie's klingt.
// playWakeReadySound checked das gerade gesetzte Flag — wenn val=true,
// wird abgespielt; bei false bleibt es still.
setTimeout(() => playWakeReadySound().catch(() => {}), 150);
}
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
{/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Sprachausgabe auf diesem Geraet</Text>
<Text style={styles.toggleHint}>
Nur lokal andere Geraete sind unabhaengig.
Wenn aus, erscheint im Chat auch kein Mund-Button.
</Text>
</View>
<Switch
value={ttsEnabled}
onValueChange={(val) => {
setTtsEnabled(val);
AsyncStorage.setItem('aria_tts_enabled', String(val));
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Puffer vor Wiedergabestart</Text>
<Text style={styles.toggleHint}>
Wie viel Audio gesammelt wird bevor die Wiedergabe startet.
Hoeher = robuster gegen Render-Pausen, aber mehr Startverzoegerung.
Default: {TTS_PREROLL_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(TTS_PREROLL_MIN_SEC, Math.round((ttsPrerollSec - 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec <= TTS_PREROLL_MIN_SEC}
>
<Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{ttsPrerollSec.toFixed(1)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(TTS_PREROLL_MAX_SEC, Math.round((ttsPrerollSec + 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec >= TTS_PREROLL_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Sprechgeschwindigkeit</Text>
<Text style={styles.toggleHint}>
Wie schnell ARIA spricht. 1.0 = Normal. Niedriger = langsamer, hoeher = schneller.
Wird an F5-TTS als speed-Param uebergeben und pro Geraet gespeichert.
Default: {TTS_SPEED_DEFAULT.toFixed(1)}x.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(TTS_SPEED_MIN, Math.round((ttsSpeed - 0.1) * 10) / 10);
setTtsSpeed(next);
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
}}
disabled={ttsSpeed <= TTS_SPEED_MIN}
>
<Text style={styles.prerollButtonText}>0.1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{ttsSpeed.toFixed(1)} x</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(TTS_SPEED_MAX, Math.round((ttsSpeed + 0.1) * 10) / 10);
setTtsSpeed(next);
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
}}
disabled={ttsSpeed >= TTS_SPEED_MAX}
>
<Text style={styles.prerollButtonText}>+0.1</Text>
</TouchableOpacity>
</View>
</View>
)}
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
<Text style={styles.toggleHint}>
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
</Text>
{availableVoices.length === 0 ? (
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
Keine geklonten Stimmen vorhanden unten "Eigene Stimme aufnehmen".
</Text>
) : (
availableVoices.map(v => (
<View key={v.name} style={[styles.voiceRow, xttsVoice === v.name && styles.voiceRowActive]}>
<TouchableOpacity
style={{flex: 1}}
onPress={() => selectVoice(v.name)}
>
<Text style={[styles.voiceRowName, xttsVoice === v.name && styles.voiceRowNameActive]}>
{v.name}
</Text>
<Text style={styles.voiceRowMeta}>{(v.size / 1024).toFixed(0)} KB</Text>
</TouchableOpacity>
{loadingVoice === v.name && (
<ActivityIndicator size="small" color="#0096FF" style={{marginRight: 8}} />
)}
{xttsVoice === v.name && loadingVoice !== v.name && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
<TouchableOpacity onPress={() => deleteVoice(v.name)} style={styles.voiceRowDelete}>
<Text style={styles.voiceRowDeleteIcon}>X</Text>
</TouchableOpacity>
</View>
))
)}
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
onPress={() => setVoiceCloneVisible(true)}
>
<Text style={styles.connectButtonText}>{'\uD83C\uDFA4'} Eigene Stimme aufnehmen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.clearButton, {flex: 0.4, marginTop: 0}]}
onPress={() => rvs.send('xtts_list_voices' as any, {})}
>
<Text style={styles.clearButtonText}>Aktualisieren</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</>)}
{/* === Speicher === */}
{currentSection === 'storage' && (<>
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Auto-Download</Text>
<Text style={styles.toggleHint}>
Fehlende Anhaenge beim App-Start automatisch vom Server laden
</Text>
</View>
<Switch
value={autoDownload}
onValueChange={(val) => {
setAutoDownload(val);
AsyncStorage.setItem('aria_auto_download', String(val));
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={autoDownload ? '#FFFFFF' : '#666680'}
/>
</View>
<View style={{height: 16}} />
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
<Text style={styles.toggleHint}>
Hier werden Bilder und Dateien aus dem Chat gespeichert.
{autoDownload ? ' Fehlende Dateien werden automatisch nachgeladen.' : ' Fehlende Dateien koennen per Tippen geladen werden.'}
</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={showPathPicker}
>
<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>
{/* === Update-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Update-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Heruntergeladene APK-Dateien fuer App-Updates. Werden automatisch
beim App-Start und vor jedem neuen Download geloescht der Button
ist fuer den Notfall (z.B. wenn ein Download haengen geblieben ist).
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{apkCacheInfo === null ? '...' :
apkCacheInfo.count === 0 ? 'leer' :
`${apkCacheInfo.count} APK${apkCacheInfo.count === 1 ? '' : 's'} · ${apkCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await updateService.cleanupOldApks();
ToastAndroid.show(
res.removed === 0
? 'Update-Cache war schon leer'
: `${res.removed} APK${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await updateService.getApkCacheSize();
setApkCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Update-Cache leeren</Text>
</TouchableOpacity>
</View>
{/* === TTS-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>TTS-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Gespeicherte Sprachausgaben (WAV pro Antwort) werden fuer den
Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen
unterbricht keine laufende Wiedergabe, alte Antworten lassen sich
danach nur nicht mehr abspielen.
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{ttsCacheInfo === null ? '...' :
ttsCacheInfo.count === 0 ? 'leer' :
`${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await audioService.clearTtsCache();
ToastAndroid.show(
res.removed === 0
? 'TTS-Cache war schon leer'
: `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await audioService.getTtsCacheSize();
setTtsCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>TTS-Cache leeren</Text>
</TouchableOpacity>
</View>
{/* === Reparatur === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Reparatur</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Container gezielt neu starten wenn die Voice-Bridge, das Gehirn
oder die Vector-DB haengt. Restart dauert wenige Sekunden,
laufende Anfragen gehen verloren.
</Text>
{[
{ name: 'aria-bridge', label: '🚨 aria-bridge neu (Voice + RVS)' },
{ name: 'aria-brain', label: '🚨 aria-brain neu (Agent + Memory)' },
{ name: 'aria-qdrant', label: '🚨 aria-qdrant neu (Vector-DB)' },
].map(c => (
<TouchableOpacity
key={c.name}
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.10)'}]}
onPress={() => {
Alert.alert(
`${c.name} neu starten?`,
'Restart in wenigen Sekunden. Laufende Anfragen gehen verloren.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Neu starten', style: 'destructive', onPress: () => {
rvs.send('container_restart' as any, { name: c.name });
ToastAndroid.show(`${c.name} wird neu gestartet…`, ToastAndroid.LONG);
}},
],
);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>{c.label}</Text>
</TouchableOpacity>
))}
</View>
</>)}
{/* === Datei-Manager === */}
{currentSection === 'files' && (<>
<Text style={styles.sectionTitle}>Dateien</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Alle Dateien aus <Text style={{fontFamily:'monospace'}}>/shared/uploads/</Text>
was ARIA generiert hat und was du hochgeladen hast.
Beim Löschen wird die Bubble in App + Diagnostic als gelöscht markiert.
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(0,150,255,0.15)'}]}
onPress={() => {
setFileManagerError('');
setFileManagerLoading(true);
setFileManagerOpen(true);
rvs.send('file_list_request' as any, {});
}}
>
<Text style={[styles.clearButtonText, {color: '#0096FF'}]}>{'📂 Datei-Manager öffnen'}</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Gedaechtnis === */}
{currentSection === 'memory' && (<>
<Text style={styles.sectionTitle}>Gedächtnis</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten mit Anhängen, pinned-Status,
Tags. Neue Einträge anlegen via "+ Neu".
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<MemoryBrowser />
</View>
</>)}
{/* === Trigger === */}
{currentSection === 'triggers' && (<>
<Text style={styles.sectionTitle}>Trigger</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<TriggerBrowser />
</View>
</>)}
{/* === Skills === */}
{currentSection === 'skills' && (<>
<Text style={styles.sectionTitle}>Skills</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast.
Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via
ihr skill_update Brain-Tool hier nur Manifest-Felder + Run + Cleanup.
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<SkillBrowser />
</View>
</>)}
{/* === OAuth-Apps === */}
{currentSection === 'oauth' && (<>
<Text style={styles.sectionTitle}>OAuth-Apps</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...).
Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein,
dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen
ARIA kann das auch selbst per Chat ("verbinde mich mit X").
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<OAuthBrowser />
</View>
</>)}
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
{/* Verbose-Logging-Toggle */}
<View style={styles.card}>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Verbose Logging</Text>
<Switch
value={verboseLogging}
onValueChange={(v) => {
setVerboseLogging(v);
setVerboseLoggingState(v);
}}
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
{/* Debug-Logs an Bridge: scharf nur wenn aktiv gebraucht */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<Text style={styles.toggleLabel}>Debug-Logs an Bridge</Text>
<Switch
value={debugLogsToBridge}
onValueChange={(v) => {
setDebugLogsToBridge(v);
setDebugLogsToBridgeState(v);
}}
trackColor={{ false: '#3A3A52', true: '#FF9500' }}
thumbColor={debugLogsToBridge ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Schickt detaillierte Diagnose-Logs (Wake-Word-Pipeline, Audio-Focus,
Background-Service) per RVS an die Bridge abrufbar via
`curl /api/app-log?lines=N` ohne ADB. Default AUS damit kein
unnoetiger Traffic + Disk-Schreiben. Crash-Reports (Errors) gehen
IMMER, dieser Toggle betrifft nur Info-Logs.
</Text>
</View>
<View style={styles.card}>
{/* Tab-Umschalter */}
<View style={styles.tabRow}>
<TouchableOpacity
style={[styles.tab, logTab === 'live' && styles.tabActive]}
onPress={() => setLogTab('live')}
>
<Text style={[styles.tabText, logTab === 'live' && styles.tabTextActive]}>
Live Logs
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, logTab === 'events' && styles.tabActive]}
onPress={() => setLogTab('events')}
>
<Text style={[styles.tabText, logTab === 'events' && styles.tabTextActive]}>
Events
</Text>
</TouchableOpacity>
</View>
{/* Log-Inhalt */}
<View style={styles.logContainer}>
{logTab === 'live' ? (
logs.length > 0 ? (
logs.slice(-50).map(log => (
<View key={log.id} style={styles.logEntry}>
<Text style={styles.logTime}>{formatTime(log.timestamp)}</Text>
<Text
style={[
styles.logSource,
{ color: SOURCE_COLORS[log.source] || SOURCE_COLORS.default },
]}
>
[{log.source}]
</Text>
<Text
style={[
styles.logMessage,
log.level === 'error' && styles.logError,
log.level === 'warn' && styles.logWarn,
]}
numberOfLines={2}
>
{log.message}
</Text>
</View>
))
) : (
<Text style={styles.emptyLog}>Noch keine Logs empfangen</Text>
)
) : (
events.length > 0 ? (
events.slice(-30).map(event => (
<View key={event.id} style={styles.eventEntry}>
<Text style={styles.eventTime}>{formatTime(event.timestamp)}</Text>
<Text style={styles.eventTitle}>{event.title}</Text>
<Text style={styles.eventDescription}>{event.description}</Text>
</View>
))
) : (
<Text style={styles.emptyLog}>Noch keine Events empfangen</Text>
)
)}
</View>
{/* Log-Aktionen */}
<TouchableOpacity
style={styles.clearButton}
onPress={() => {
if (logTab === 'live') setLogs([]);
else setEvents([]);
}}
>
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === About === */}
{currentSection === 'about' && (<>
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
<Text style={styles.aboutInfo}>
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
Stefans Kommandozentrale.{'\n'}
Gebaut mit React Native + TypeScript.
</Text>
<TouchableOpacity
style={[styles.connectButton, {marginTop: 12}]}
onPress={() => {
const updateService = require('../services/updater').default;
updateService.checkForUpdate();
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
}}
>
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
</TouchableOpacity>
</View>
</>)}
{/* Platz am Ende */}
<View style={styles.bottomSpacer} />
</ScrollView>
</>
);
};
// --- Styles ---
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0D0D1A',
},
content: {
padding: 16,
},
sectionTitle: {
color: '#8888AA',
fontSize: 13,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
marginTop: 20,
marginBottom: 8,
marginLeft: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 14,
marginBottom: 8,
},
menuItemIcon: {
fontSize: 22,
marginRight: 14,
width: 28,
textAlign: 'center',
},
menuItemTextWrap: {
flex: 1,
},
menuItemLabel: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
menuItemDesc: {
color: '#8888AA',
fontSize: 12,
marginTop: 2,
},
menuItemChevron: {
color: '#8888AA',
fontSize: 24,
fontWeight: '300',
marginLeft: 8,
},
subScreenHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
marginBottom: 8,
},
subScreenBack: {
color: '#0096FF',
fontSize: 32,
fontWeight: '300',
marginRight: 12,
lineHeight: 36,
},
subScreenTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
card: {
backgroundColor: '#12122A',
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: '#1E1E2E',
},
// Verbindungsstatus
statusRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statusDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 10,
},
statusLabel: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
flex: 1,
},
disconnectButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: 'rgba(255, 59, 48, 0.2)',
},
disconnectText: {
color: '#FF3B30',
fontSize: 13,
fontWeight: '600',
},
// QR-Button
qrButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 10,
padding: 14,
marginBottom: 16,
},
qrIcon: {
fontSize: 22,
marginRight: 10,
},
qrText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '500',
},
// Eingabefelder
inputLabel: {
color: '#8888AA',
fontSize: 12,
marginBottom: 4,
marginLeft: 2,
},
input: {
backgroundColor: '#1E1E2E',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 10,
color: '#FFFFFF',
fontSize: 15,
marginBottom: 12,
borderWidth: 1,
borderColor: '#2A2A3E',
},
connectButton: {
backgroundColor: '#0096FF',
borderRadius: 10,
padding: 14,
alignItems: 'center',
marginTop: 4,
},
connectButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
// Toggle
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
toggleInfo: {
flex: 1,
marginRight: 12,
},
toggleLabel: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '500',
},
toggleHint: {
color: '#666680',
fontSize: 12,
marginTop: 2,
},
// XTTS Voice List
voiceRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 8,
padding: 10,
marginTop: 6,
borderWidth: 1,
borderColor: 'transparent',
},
voiceRowActive: {
borderColor: '#0096FF',
backgroundColor: '#0D1A2E',
},
voiceRowName: {
color: '#CCCCDD',
fontSize: 14,
fontWeight: '500',
},
voiceRowNameActive: {
color: '#FFFFFF',
},
voiceRowMeta: {
color: '#666680',
fontSize: 11,
marginTop: 2,
},
voiceRowCheck: {
color: '#34C759',
fontSize: 16,
fontWeight: '700',
marginHorizontal: 6,
},
voiceRowDelete: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(255,59,48,0.2)',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 4,
},
voiceRowDeleteIcon: {
color: '#FF3B30',
fontSize: 12,
fontWeight: '700',
},
// Stimmen
voiceBtn: {
flex: 1,
padding: 12,
borderRadius: 10,
backgroundColor: '#1E1E2E',
alignItems: 'center',
borderWidth: 2,
borderColor: 'transparent',
},
voiceBtnActive: {
borderColor: '#0096FF',
backgroundColor: '#0D1A2E',
},
voiceBtnIcon: {
fontSize: 28,
marginBottom: 4,
},
voiceBtnText: {
color: '#8888AA',
fontSize: 14,
fontWeight: '600',
},
voiceBtnTextActive: {
color: '#FFFFFF',
},
voiceBtnHint: {
color: '#555570',
fontSize: 11,
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',
marginBottom: 12,
borderRadius: 8,
backgroundColor: '#1E1E2E',
overflow: 'hidden',
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
},
tabActive: {
backgroundColor: '#0096FF',
},
tabText: {
color: '#666680',
fontSize: 13,
fontWeight: '600',
},
tabTextActive: {
color: '#FFFFFF',
},
connLogScroll: {
maxHeight: 200,
backgroundColor: '#0A0A18',
borderRadius: 8,
padding: 10,
},
logContainer: {
maxHeight: 300,
backgroundColor: '#0A0A18',
borderRadius: 8,
padding: 10,
},
logEntry: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 4,
},
logTime: {
color: '#555570',
fontSize: 11,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
marginRight: 6,
},
logSource: {
fontSize: 11,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontWeight: '700',
marginRight: 6,
},
logMessage: {
color: '#CCCCDD',
fontSize: 11,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
flex: 1,
},
logError: {
color: '#FF3B30',
},
logWarn: {
color: '#FFD60A',
},
emptyLog: {
color: '#555570',
fontSize: 13,
textAlign: 'center',
padding: 20,
},
eventEntry: {
marginBottom: 10,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
eventTime: {
color: '#555570',
fontSize: 11,
},
eventTitle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
marginTop: 2,
},
eventDescription: {
color: '#8888AA',
fontSize: 13,
marginTop: 2,
},
clearButton: {
marginTop: 10,
padding: 10,
alignItems: 'center',
borderRadius: 8,
backgroundColor: '#1E1E2E',
},
clearButtonText: {
color: '#666680',
fontSize: 13,
fontWeight: '500',
},
// About
aboutTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
},
aboutVersion: {
color: '#0096FF',
fontSize: 13,
marginTop: 2,
},
aboutInfo: {
color: '#666680',
fontSize: 13,
marginTop: 8,
lineHeight: 20,
},
bottomSpacer: {
height: 40,
},
prerollRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
gap: 16,
},
prerollButton: {
backgroundColor: '#2A2A3E',
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 8,
minWidth: 72,
alignItems: 'center',
},
prerollButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
prerollValue: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
minWidth: 80,
textAlign: 'center',
},
infoBtn: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
},
infoBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '700',
fontStyle: 'italic',
lineHeight: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#1E1E2E',
borderRadius: 14,
padding: 20,
maxWidth: 460,
width: '100%',
},
modalTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
modalText: {
color: '#E0E0F0',
fontSize: 14,
lineHeight: 20,
},
keywordChip: {
backgroundColor: '#1E1E2E',
borderWidth: 1,
borderColor: '#2A2A3E',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
},
keywordChipActive: {
backgroundColor: '#0096FF',
borderColor: '#0096FF',
},
keywordChipText: {
color: '#8888AA',
fontSize: 13,
fontWeight: '500',
},
keywordChipTextActive: {
color: '#FFFFFF',
fontWeight: '700',
},
});
export default SettingsScreen;