Compare commits

...

4 Commits

Author SHA1 Message Date
duffyduck 2bac9c26ca release: bump version to 0.1.5.6 2026-05-16 14:32:34 +02:00
duffyduck c758727345 release: bump version to 0.1.5.5 2026-05-16 11:29:45 +02:00
duffyduck cb0e879118 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>
2026-05-16 11:27:01 +02:00
duffyduck ce6f5b551e fix(chat): Gedanken-Stream scrollt jetzt + Suche praeziser
(1) Gedanken-Stream Modal: vorheriger Fix mit onStartShouldSetResponder
    war falsch — der View wurde komplett zum Responder, die FlatList drin
    bekam null Touch-Events. Jetzt: outer View ohne Touch-Handling, ein
    separates TouchableOpacity-Element oberhalb des Sheets nur fuer den
    Tap-Outside-Close. Sheet-View ist plain View → FlatList scrollt frei.

(2) Such-Sprung praeziser: drei Verbesserungen
    - MAX_SCROLL_RETRIES 3 → 6: bei weiten Spruengen (Bubble #150 von
      Position 0) braucht FlatList mehrere Iterationen bis die Items in
      der Naehe gemessen sind
    - Pre-Scroll-Offset: Fallback fuer unmeasured Items ist jetzt der
      dynamische Mittel der bisher gemessenen Items (statt Pauschal-150).
      Beim Cold-Start sind nur die untersten 10 gemessen, aber deren
      Mittel ist immer noch eine bessere Schaetzung
    - Render-Pause nach Pre-Scroll 200 → 350 ms: bei weiten Spruengen
      braucht FlatList Zeit die Items zu mounten und onLayout zu feuern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:38 +02:00
6 changed files with 151 additions and 35 deletions
+39 -1
View File
@@ -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();
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10504 versionCode 10506
versionName "0.1.5.4" versionName "0.1.5.6"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.5.4", "version": "0.1.5.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+30 -21
View File
@@ -1415,7 +1415,10 @@ const ChatScreen: React.FC = () => {
// verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die // verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
// FlatList re-rendert). // FlatList re-rendert).
const scrollRetryCount = useRef<number>(0); const scrollRetryCount = useRef<number>(0);
const MAX_SCROLL_RETRIES = 3; // 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0)
// kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe
// gemessen sind. Vorher 3 = vorzeitig aufgegeben.
const MAX_SCROLL_RETRIES = 6;
const clearPendingScrollRetry = () => { const clearPendingScrollRetry = () => {
if (pendingScrollRetry.current) { if (pendingScrollRetry.current) {
clearTimeout(pendingScrollRetry.current); clearTimeout(pendingScrollRetry.current);
@@ -1459,13 +1462,18 @@ const ChatScreen: React.FC = () => {
// averageItemLength im Failed-Handler nur auf den ersten ~10 Items // averageItemLength im Failed-Handler nur auf den ersten ~10 Items
// und liefert einen voellig falschen Sprung). // und liefert einen voellig falschen Sprung).
// Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per // Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
// onLayout) + Fallback AVG fuer noch nicht gemessene. Bei „cold start" // onLayout) + dynamischer Fallback aus dem Mittel der bisher
// ist der Cache leer → AVG fuer alle → grob. Beim zweiten Such-Versuch // gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die
// sind die Bubbles in der Naehe gemessen → genauer. // neuesten unten in der invertierten Liste) — der Mittel daraus ist
// immer noch besser als die Pauschal-150.
const measured = Array.from(itemHeights.current.values());
const dynamicAvg = measured.length >= 5
? measured.reduce((a, b) => a + b, 0) / measured.length
: AVG_BUBBLE_HEIGHT;
let preOffset = 0; let preOffset = 0;
const inv = invertedMessagesRef.current; const inv = invertedMessagesRef.current;
for (let i = 0; i < idx; i++) { for (let i = 0; i < idx; i++) {
preOffset += itemHeights.current.get(inv[i].id) || AVG_BUBBLE_HEIGHT; preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg;
} }
try { try {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
@@ -1473,9 +1481,10 @@ const ChatScreen: React.FC = () => {
animated: false, animated: false,
}); });
} catch {} } catch {}
// Nach kurzer Render-Pause praezise nachsetzen. 200 ms statt 80 ms — // Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen
// bei Cold-Start braucht FlatList laenger fuer das Item-Layout, das // (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu
// war Stefans „erst beim zweiten Versuch klappt's"-Bug. // mounten und onLayout zu feuern. Zu kurz → averageItemLength im
// Failed-Handler basiert noch auf den falschen Items.
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(() => { setTimeout(() => {
try { try {
@@ -1483,7 +1492,7 @@ const ChatScreen: React.FC = () => {
} catch { } catch {
// onScrollToIndexFailed-Handler uebernimmt den Fallback // onScrollToIndexFailed-Handler uebernimmt den Fallback
} }
}, 200); }, 350);
}); });
}, [searchIndex, searchMatchIds]); }, [searchIndex, searchMatchIds]);
@@ -2411,19 +2420,19 @@ const ChatScreen: React.FC = () => {
transparent transparent
onRequestClose={() => setThoughtsVisible(false)} onRequestClose={() => setThoughtsVisible(false)}
> >
<TouchableOpacity <View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}} {/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable
activeOpacity={1} damit das Sheet-View NICHT als Responder den FlatList-Scroll
onPress={() => setThoughtsVisible(false)} blockiert. Frueher hatten wir den ganzen Hintergrund als
> TouchableOpacity + inneren View mit onStartShouldSetResponder
{/* View statt TouchableOpacity, sonst konsumiert das die Touch- = das hat alle Touch-Events kassiert. */}
Events und die FlatList laesst sich nicht scrollen. <TouchableOpacity
onStartShouldSetResponder={true} blockt aber die Propagation style={{flex:1}}
an das aeussere TouchableOpacity (close-on-tap-outside). */} activeOpacity={1}
onPress={() => setThoughtsVisible(false)}
/>
<View <View
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}} style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
onStartShouldSetResponder={() => true}
onResponderTerminationRequest={() => false}
> >
{/* Drag-Indicator */} {/* Drag-Indicator */}
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}> <View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
@@ -2504,7 +2513,7 @@ const ChatScreen: React.FC = () => {
/> />
)} )}
</View> </View>
</TouchableOpacity> </View>
</Modal> </Modal>
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles). {/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
+64
View File
@@ -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) === */}
+15 -10
View File
@@ -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) {