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:
+39
-1
@@ -6,7 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
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 { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
|
|||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -61,6 +63,42 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
initConnection();
|
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
|
// Beim Beenden: Verbindung sauber trennen
|
||||||
return () => {
|
return () => {
|
||||||
rvs.disconnect();
|
rvs.disconnect();
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService from '../services/gpsTracking';
|
||||||
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import TriggerBrowser from '../components/TriggerBrowser';
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
@@ -129,6 +130,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [currentMode, setCurrentMode] = useState('normal');
|
const [currentMode, setCurrentMode] = useState('normal');
|
||||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||||
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
||||||
|
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
||||||
const [scannerVisible, setScannerVisible] = useState(false);
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
const [logTab, setLogTab] = useState<LogTab>('live');
|
const [logTab, setLogTab] = useState<LogTab>('live');
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
@@ -196,6 +198,10 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
||||||
if (saved !== null) setGpsEnabled(saved === 'true');
|
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
|
// gpsTrackingService status syncen + auf Aenderungen lauschen
|
||||||
setGpsTracking(gpsTrackingService.isActive());
|
setGpsTracking(gpsTrackingService.isActive());
|
||||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||||
@@ -579,6 +585,37 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
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 ---
|
// --- XTTS Voice ---
|
||||||
|
|
||||||
const selectVoice = useCallback((voiceName: string) => {
|
const selectVoice = useCallback((voiceName: string) => {
|
||||||
@@ -1065,6 +1102,33 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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) === */}
|
{/* === Spracheingabe (geraetelokal) === */}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
|
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
|
||||||
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
|
* 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
|
* 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":
|
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||||
* - 'tts' : ARIA spricht
|
* - 'tts' : ARIA spricht
|
||||||
* - 'rec' : Aufnahme laeuft
|
* - 'rec' : Aufnahme laeuft
|
||||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
* - '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
|
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
* 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';
|
import { NativeModules } from 'react-native';
|
||||||
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
|
|||||||
|
|
||||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||||
|
|
||||||
type Slot = 'tts' | 'rec' | 'wake';
|
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
||||||
|
|
||||||
const slots = new Set<Slot>();
|
const slots = new Set<Slot>();
|
||||||
|
|
||||||
// Prioritaet fuer den Notification-Text — hoechste zuerst.
|
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
|
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||||
|
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
||||||
|
|
||||||
function topReason(): string {
|
function topReason(): string {
|
||||||
for (const s of PRIORITY) {
|
for (const s of PRIORITY) {
|
||||||
|
|||||||
Reference in New Issue
Block a user