feat(app): Background-GPS als opt-in Settings-Toggle

Stefan-Anforderung: GPS soll auch im Hintergrund liefern (Auto-Szenarien,
Handy-Tasche), aber NUR fuer Power-User die das bewusst aktivieren.
Mama-Tauglichkeit bleibt erhalten — Default AUS, keine Surprise-Permission.

Aenderungen:

AndroidManifest:
- ACCESS_BACKGROUND_LOCATION Permission
- FOREGROUND_SERVICE_LOCATION Permission
- AriaPlaybackService foregroundServiceType erweitert um |location
  (vorher: mediaPlayback|microphone)

backgroundAudio.ts:
- Neuer Slot 'location' zwischen 'wake' und 'background' in der
  Prioritaeten-Liste. Notification zeigt entsprechend.

gpsTracking.ts:
- isBackgroundGpsEnabled() / setBackgroundGpsEnabled() AsyncStorage-Helper
- ensureBackgroundLocationPermission() pruefte ACCESS_BACKGROUND_LOCATION
  und oeffnet Android-Settings wenn fehlend (auf Android 10+ kann das
  NICHT ueber den normalen Permission-Dialog angefordert werden)
- start(): wenn BG-GPS enabled, acquireBackgroundAudio('location') →
  Foreground-Service hochziehen mit type=location
- stop(): releaseBackgroundAudio('location')

SettingsScreen.tsx:
- Neuer Toggle "GPS auch im Hintergrund" direkt unter dem
  GPS-Tracking-Toggle, rot (#FF3B30) statt orange weil's eine stark
  privacy-relevante Einstellung ist
- Erklaerungs-Text zu Android-Settings + Akku-Verbrauch
- Beim Aktivieren: Permission-Check, ggf. Android-Settings oeffnen
- Wenn Tracking bereits laeuft: neustart damit location-Slot greift

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 20:19:16 +02:00
parent b182ef5ed5
commit f2bfd4bbc6
4 changed files with 131 additions and 7 deletions
@@ -9,13 +9,20 @@
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) --> <!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Background-Location ist OPT-IN (Settings → GPS auch im Hintergrund).
Muss vom User explizit in Android-Einstellungen auf "Immer erlauben"
gesetzt werden — kann nicht ueber den normalen Permission-Dialog
angefordert werden (Android 10+). Default: aus. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft. <!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word, Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
Aufnahme im Gespraechsmodus). --> Aufnahme im Gespraechsmodus). LOCATION wird nur aktiv wenn der
User Background-GPS in Settings einschaltet. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze <!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
@@ -57,6 +64,6 @@
<service <service
android:name=".AriaPlaybackService" android:name=".AriaPlaybackService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" /> android:foregroundServiceType="mediaPlayback|microphone|location" />
</application> </application>
</manifest> </manifest>
+54 -1
View File
@@ -52,7 +52,11 @@ import {
TTS_SPEED_STORAGE_KEY, TTS_SPEED_STORAGE_KEY,
} from '../services/audio'; } from '../services/audio';
import audioService from '../services/audio'; import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking'; import gpsTrackingService, {
isBackgroundGpsEnabled,
setBackgroundGpsEnabled,
ensureBackgroundLocationPermission,
} from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio'; 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';
@@ -134,6 +138,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 [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false); const [scannerVisible, setScannerVisible] = useState(false);
@@ -216,6 +221,8 @@ const SettingsScreen: React.FC = () => {
const offGps = gpsTrackingService.onChange(setGpsTracking); const offGps = gpsTrackingService.onChange(setGpsTracking);
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?) // Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
gpsTrackingService.restoreFromStorage().catch(() => {}); gpsTrackingService.restoreFromStorage().catch(() => {});
// Background-GPS-Toggle initial laden
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => { AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -1117,6 +1124,52 @@ const SettingsScreen: React.FC = () => {
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'} thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
/> />
</View> </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> </View>
{/* === Bubble-Anzeige === */} {/* === Bubble-Anzeige === */}
+4 -3
View File
@@ -9,13 +9,14 @@
* - '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)
* - 'location' : Background-GPS-Tracking (opt-in in Settings)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle). * - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben * Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen. * → 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 > background). * den hoechstprioren Slot an (tts > rec > wake > location > background).
*/ */
import { NativeModules } from 'react-native'; import { NativeModules } from 'react-native';
@@ -27,13 +28,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative }; const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake' | 'background'; type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
const slots = new Set<Slot>(); const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background' // Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft. // ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background']; const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
function topReason(): string { function topReason(): string {
for (const s of PRIORITY) { for (const s of PRIORITY) {
+64 -1
View File
@@ -14,9 +14,62 @@
*/ */
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native'; import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
import Geolocation from '@react-native-community/geolocation'; import Geolocation from '@react-native-community/geolocation';
import rvs from './rvs'; import rvs from './rvs';
import { acquireBackgroundAudio, releaseBackgroundAudio } from './backgroundAudio';
// Opt-in Background-GPS — Settings-Toggle "GPS auch im Hintergrund".
// Default AUS. Wenn AN: ACCESS_BACKGROUND_LOCATION-Permission noetig
// (kann nicht ueber Standard-Dialog angefordert werden, User muss in
// Android-Settings auf "Immer erlauben" gehen) + ForegroundService mit
// foregroundServiceType=location wird hochgezogen.
export const BG_GPS_STORAGE_KEY = 'aria_gps_background_enabled';
export async function isBackgroundGpsEnabled(): Promise<boolean> {
try {
const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY);
return v === 'true';
} catch {
return false;
}
}
export async function setBackgroundGpsEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_GPS_STORAGE_KEY, String(enabled));
} catch {}
}
/** Prueft ob ACCESS_BACKGROUND_LOCATION gewaehrt ist und oeffnet sonst die
* Android-App-Settings damit der User "Immer erlauben" auswaehlen kann.
* Returns true wenn permission ok, false wenn User Settings oeffnen muss. */
export async function ensureBackgroundLocationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.check(
'android.permission.ACCESS_BACKGROUND_LOCATION' as any,
);
if (granted) return true;
// Erst FINE_LOCATION anfordern falls noch nicht da
const fine = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
if (fine !== PermissionsAndroid.RESULTS.GRANTED) return false;
// Ab Android 10+ kann BACKGROUND_LOCATION NICHT ueber den normalen
// PermissionsAndroid.request abgefragt werden — User muss in Settings
// auf "Immer erlauben" wechseln. Wir oeffnen die App-Settings-Seite.
ToastAndroid.show(
'Bitte in Android-Einstellungen unter Standort "Immer erlauben" auswaehlen',
ToastAndroid.LONG,
);
Linking.openSettings();
return false;
} catch (e) {
console.warn('[gps-track] BG-Permission-Check fehlgeschlagen:', e);
return false;
}
}
type Listener = (active: boolean) => void; type Listener = (active: boolean) => void;
@@ -86,6 +139,14 @@ class GpsTrackingService {
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG); ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
return false; return false;
} }
// Background-GPS opt-in: wenn aktiv, ForegroundService mit type=location
// hochziehen. Brauche ACCESS_BACKGROUND_LOCATION (User muss in Android-
// Settings 'Immer erlauben' aktivieren). Wenn die fehlt, watchPosition
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
const bgEnabled = await isBackgroundGpsEnabled();
if (bgEnabled) {
try { await acquireBackgroundAudio('location'); } catch {}
}
try { try {
this.watchId = Geolocation.watchPosition( this.watchId = Geolocation.watchPosition(
(pos) => { (pos) => {
@@ -142,6 +203,8 @@ class GpsTrackingService {
clearInterval(this.heartbeatTimer); clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null; this.heartbeatTimer = null;
} }
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
try { releaseBackgroundAudio('location'); } catch {}
this.active = false; this.active = false;
this.lastChangeAt = Date.now(); this.lastChangeAt = Date.now();
this.notify(); this.notify();