Files
ARIA-AGENT/android/src/screens/SettingsScreen.tsx
T
duffyduck fc0f91d1e6 feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.

Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
  (/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
  _summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
  Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
  den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
  last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
  {id}/end,{id}/archive, PATCH /{id}}.

Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
  damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.

App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
  Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
  zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
  oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
  Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.

Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
  pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
  project_changed-Event-Broadcast.

Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
  zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 13:51:26 +02:00

2908 lines
110 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,
NativeModules,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
const { FileOpener } = NativeModules as {
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};
// MIME-Type aus Dateinamen schaetzen — fuer den FileOpener-Intent. Android
// nutzt den MIME-Type um die passende App zu finden. Unknown → octet-stream.
function guessMimeFromName(name: string): string {
const lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.mp3')) return 'audio/mpeg';
if (lower.endsWith('.wav')) return 'audio/wav';
if (lower.endsWith('.ogg') || lower.endsWith('.opus')) return 'audio/ogg';
if (lower.endsWith('.mp4') || lower.endsWith('.m4a')) return 'audio/mp4';
if (lower.endsWith('.webm')) return 'video/webm';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.md')) return 'text/markdown';
if (lower.endsWith('.json')) return 'application/json';
if (lower.endsWith('.csv')) return 'text/csv';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
if (lower.endsWith('.zip')) return 'application/zip';
return 'application/octet-stream';
}
import DocumentPicker from 'react-native-document-picker';
import 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 VoiceIdEnrollment from '../components/VoiceIdEnrollment';
import ProjectsBrowser from '../components/ProjectsBrowser';
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_id', icon: '🎤', label: 'Stimme einrichten', desc: 'Sprecher-Erkennung — nur deine Stimme triggert ARIA' },
{ 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: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' },
{ 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);
// Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git
// im diagnostic-Container. Browser-Variante davon laeuft schon, hier App-
// Side via RVS-Messages (file_version_list_request/...).
const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null);
const [versionsList, setVersionsList] = useState<Array<{hash: string; ts: number; subject: string; isCurrent?: boolean}>>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsError, setVersionsError] = useState('');
const versionDlPending = useRef<string | null>(null); // requestId beim Versions-Download
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);
}
})();
}
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
// passt. Hier behandeln wir die Manager-initiierten Downloads
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
// ~/Download/ wie der ZIP-Pfad.
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
const isDownload = reqId.startsWith('single-');
const isOpen = reqId.startsWith('open-');
if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.)
if (p.error) {
ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
if (!b64) return;
const fileName = (p.name as string) ||
(p.serverPath as string || '').split('/').pop() ||
'aria-download';
(async () => {
try {
if (isOpen) {
// Open-Pfad: nach Caches schreiben + per FileOpener mit System-
// Viewer oeffnen. Caches damit der Speicher kein Dauer-Muell wird.
const dir = RNFS.CachesDirectoryPath;
const target = `${dir}/${fileName}`;
await RNFS.writeFile(target, b64, 'base64');
const mime = (p.mimeType as string) || guessMimeFromName(fileName);
if (FileOpener?.open) {
try {
await FileOpener.open(target, mime);
} catch (e: any) {
ToastAndroid.show('Öffnen fehlgeschlagen: ' + (e?.message || e), ToastAndroid.LONG);
}
} else {
ToastAndroid.show('FileOpener-Modul nicht verfügbar — APK neu bauen', ToastAndroid.LONG);
}
return;
}
// Download-Pfad: nach Downloads-Ordner schreiben, mit Suffix bei
// Namens-Konflikt damit nichts ueberschrieben wird.
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`;
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
const dot = fileName.lastIndexOf('.');
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
const ext = dot > 0 ? fileName.slice(dot) : '';
target = `${dir}/${base} (${i})${ext}`;
i++;
}
await RNFS.writeFile(target, b64, 'base64');
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Versions-Liste einer Datei
if (message.type === ('file_version_list_response' as any)) {
const p: any = message.payload || {};
setVersionsLoading(false);
if (!p.ok) {
setVersionsError(p.error || 'Unbekannter Fehler');
setVersionsList([]);
} else {
setVersionsError('');
setVersionsList(p.versions || []);
}
}
// Datei-Manager: Versions-Inhalt (Download einer alten Version)
if (message.type === ('file_version_download_response' as any)) {
const p: any = message.payload || {};
if (p.requestId && p.requestId !== versionDlPending.current) return;
versionDlPending.current = null;
if (!p.ok) {
ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
// base64 → Downloads-Ordner. Hash als Suffix damit Original nicht
// ueberschrieben wird wenn beide Versionen nebeneinander vorliegen
// sollen.
(async () => {
try {
const baseName = (p.name as string) || 'aria-version';
const shortHash = (p.hash as string || '').slice(0, 7);
const dot = baseName.lastIndexOf('.');
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
const ext = dot > 0 ? baseName.slice(dot) : '';
const dir = RNFS.DownloadDirectoryPath;
let target = `${dir}/${stem}@${shortHash}${ext}`;
let i = 1;
while (await RNFS.exists(target)) {
target = `${dir}/${stem}@${shortHash}_${i}${ext}`;
i++;
}
await RNFS.writeFile(target, p.base64, 'base64');
const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Restore-Bestaetigung
if (message.type === ('file_version_restore_response' as any)) {
const p: any = message.payload || {};
if (!p.ok) {
ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT);
// Versions-Liste neu laden damit der neue restore-Commit auftaucht
if (versionsOpen) {
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: versionsOpen.path });
}
// File-Liste auch refreshen (mtime hat sich geaendert)
if (fileManagerOpen) {
setFileManagerLoading(true);
rvs.send('file_list_request' as any, {});
}
}
// 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();
};
}, []);
// Datei-Manager: Auto-Reload bei RVS-Reconnect — sonst zeigt das offene
// Modal den Fehler "Connection refused" ewig an, obwohl die Verbindung
// schon wieder da ist. Triggered nur wenn das Modal gerade offen ist.
useEffect(() => {
const unsub = rvs.onStateChange((state) => {
if (state === 'connected' && fileManagerOpen) {
setFileManagerError('');
setFileManagerLoading(true);
rvs.send('file_list_request' as any, {});
}
});
return () => unsub();
}, [fileManagerOpen]);
// --- 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={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'open-' + Date.now(),
});
ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>👁</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'single-' + Date.now(),
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#34C759', fontSize:18}}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
// path-relativ-zu-uploads = nur der Dateiname,
// weil der File-Manager-Bereich flach ist
setVersionsOpen({name: f.name, path: f.name});
setVersionsList([]);
setVersionsError('');
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: f.name });
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>🕒</Text>
</TouchableOpacity>
<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>
{/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */}
<Modal
visible={versionsOpen !== null}
transparent
animationType="fade"
onRequestClose={() => setVersionsOpen(null)}
>
<TouchableOpacity
style={{flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'center', alignItems:'center'}}
activeOpacity={1}
onPress={() => setVersionsOpen(null)}
>
<TouchableOpacity
activeOpacity={1}
onPress={() => {}}
style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}}
>
<View style={{padding:12, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center'}}>
<Text style={{color:'#E0E0F0', fontSize:13, fontWeight:'bold', flex:1}} numberOfLines={1}>
Versionen {versionsOpen?.name || ''}
</Text>
<TouchableOpacity onPress={() => setVersionsOpen(null)} style={{padding:6}}>
<Text style={{color:'#888', fontSize:14}}></Text>
</TouchableOpacity>
</View>
<ScrollView style={{maxHeight:'85%'}} contentContainerStyle={{padding:8}}>
{versionsLoading && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>Lade...</Text>
)}
{!!versionsError && (
<Text style={{color:'#FF6B6B', padding:20}}>{versionsError}</Text>
)}
{!versionsLoading && !versionsError && versionsList.length === 0 && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>
Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index).
</Text>
)}
{versionsList.map(v => (
<View key={v.hash} style={{padding:10, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center', gap:8}}>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center', gap:6}}>
{v.isCurrent && (
<View style={{backgroundColor:'#34C75922', paddingHorizontal:6, paddingVertical:1, borderRadius:3}}>
<Text style={{color:'#34C759', fontSize:9}}>AKTIV</Text>
</View>
)}
<Text style={{color:'#0096FF', fontSize:11, fontFamily:'monospace'}}>
{v.hash.slice(0,7)}
</Text>
<Text style={{color:'#888', fontSize:11, flex:1}} numberOfLines={1}>
{v.subject || ''}
</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{new Date(v.ts).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000);
versionDlPending.current = reqId;
rvs.send('file_version_download_request' as any, {
path: versionsOpen.path,
hash: v.hash,
requestId: reqId,
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}}
>
<Text style={{color:'#0096FF', fontSize:11}}></Text>
</TouchableOpacity>
{!v.isCurrent && (
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
Alert.alert(
'Version aktiv setzen?',
`Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Restore', onPress: () => {
rvs.send('file_version_restore_request' as any, {
path: versionsOpen.path,
hash: v.hash,
});
ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT);
}},
],
);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}}
>
<Text style={{color:'#fff', fontSize:11}}></Text>
</TouchableOpacity>
)}
</View>
))}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</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 !== 'projects'}
>
{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>
</>)}
{/* === Voice-ID Enrollment (Sprecher-Erkennung) === */}
{currentSection === 'voice_id' && (<>
<Text style={styles.sectionTitle}>Stimme einrichten</Text>
<VoiceIdEnrollment />
</>)}
{/* === 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>
</>)}
{/* === Projekte === */}
{currentSection === 'projects' && (<>
<Text style={styles.sectionTitle}>Projekte</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Thread-Bündel im Hauptchat. Tap auf ein Projekt aktivieren, alle weiteren Nachrichten gehen
dort rein. Long-Press bearbeiten. + Neu" oder zu ARIA: „lass uns ein Projekt anlegen".
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<ProjectsBrowser />
</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;