feat(app): Hintergrund-Modus — App laeuft weiter wenn minimiert

Bisher pausierte Android nach ~30s im Hintergrund die JS-Engine.
WebSocket schlief ein, Trigger-Replies vom Brain kamen nicht durch,
Timer-Erinnerungen feuerten in der App nicht obwohl im Brain
ausgeloest. Nach laengerer Hintergrund-Pause warf Android den
Prozess ganz raus → beim Wiedereroeffnen Cold-Start, sah aus wie Crash.

Loesung: Foreground-Service mit persistenter Notification — die ist
ohnehin schon da fuer TTS/Mic-Aktivitaet (`AriaPlaybackService`).
Wir erweitern das Slot-System um einen `background`-Slot der dauerhaft
aktiv ist (Settings-Toggle, default an). Notification zeigt "ARIA aktiv
— Hintergrund-Modus" wenn nichts spezifisches laeuft, escaliert zu
"ARIA spricht/hoert" bei TTS/Mic. Tap → App.

Drei Dateien:
- services/backgroundAudio.ts: 'background' als 4. Slot (niedrigste
  Prio, Fallback-Notification). Bestehende tts/rec/wake unveraendert.
- App.tsx: beim Start `acquireBackgroundAudio('background')` aufrufen
  wenn Settings nicht explizit deaktiviert. Plus POST_NOTIFICATIONS-
  Permission-Request (Android 13+).
- screens/SettingsScreen.tsx: neuer Toggle in Allgemein-Section.
  Plus Hinweis auf Android-Akku-Optimierung-Whitelist falls trotzdem
  was klemmt (manche Hersteller-ROMs killen aggressiv).

AndroidManifest unveraendert — foregroundServiceType="mediaPlayback|
microphone" deckt unseren Use-Case ab (ARIA spielt regelmaessig TTS
ab, was den Type rechtfertigt). Service stoppt sich selbst wenn alle
Slots leer sind, das passiert nur wenn der User in Settings den
Hintergrund-Modus deaktiviert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 11:27:01 +02:00
parent ce6f5b551e
commit cb0e879118
3 changed files with 118 additions and 11 deletions
+39 -1
View File
@@ -6,7 +6,8 @@
*/
import React, { useEffect } from 'react';
import { StatusBar, StyleSheet } from 'react-native';
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
// --- Navigation ---
@@ -61,6 +63,42 @@ const App: React.FC = () => {
};
initConnection();
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
// durch weil Android nach ~30s die JS-Engine pausiert.
//
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
const initBackground = async () => {
const setting = await AsyncStorage.getItem('aria_background_mode');
if (setting === 'false') {
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
return;
}
// Permission fuer die persistente Notification
if (Platform.OS === 'android' && Platform.Version >= 33) {
try {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
} catch {}
}
try {
await acquireBackgroundAudio('background');
console.log('[App] Hintergrund-Modus aktiv');
} catch (err: any) {
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
}
};
initBackground();
// Beim Beenden: Verbindung sauber trennen
return () => {
rvs.disconnect();
+64
View File
@@ -53,6 +53,7 @@ import {
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
@@ -129,6 +130,7 @@ const SettingsScreen: React.FC = () => {
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -196,6 +198,10 @@ const SettingsScreen: React.FC = () => {
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');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -579,6 +585,37 @@ const SettingsScreen: React.FC = () => {
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);
}
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -1065,6 +1102,33 @@ const SettingsScreen: React.FC = () => {
/>
</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) === */}
+15 -10
View File
@@ -1,17 +1,21 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
* persistente Notification zeigt solange irgendein Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen.
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake).
* den hoechstprioren Slot an (tts > rec > wake > background).
*/
import { NativeModules } from 'react-native';
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
type Slot = 'tts' | 'rec' | 'wake' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
function topReason(): string {
for (const s of PRIORITY) {