/** * GPS-Tracking-Service. * * Wenn aktiv: pushed alle paar Sekunden die aktuelle Position als * `location_update {lat, lon}` an den RVS-Server, damit Brain-Watcher * mit `near()`-Conditions etwas zum Vergleichen haben. * * Default: AUS. Wird entweder vom User manuell in Settings angeschaltet * oder von ARIA via location_tracking-RVS-Message (Brain-Tool * `request_location_tracking`). * * Energie-Schutz: distanceFilter 30m, interval 15s. Echte Fahrt-Updates * (Geschwindigkeit) kommen sauber durch, stationaer wird kaum gesendet. */ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native'; import Geolocation from '@react-native-community/geolocation'; 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 { try { const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY); return v === 'true'; } catch { return false; } } export async function setBackgroundGpsEnabled(enabled: boolean): Promise { 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 { 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; class GpsTrackingService { private watchId: number | null = null; private active = false; private listeners: Set = new Set(); // Defensive: nicht zu schnell oeffentlich togglen private lastChangeAt = 0; // Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut // an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC // = 5 min) wenn der User stationaer ist und distanceFilter keine Updates // mehr triggert. private lastLat: number | null = null; private lastLon: number | null = null; private heartbeatTimer: ReturnType | null = null; isActive(): boolean { return this.active; } onChange(cb: Listener): () => void { this.listeners.add(cb); return () => { this.listeners.delete(cb); }; } private notify() { for (const cb of this.listeners) { try { cb(this.active); } catch {} } } /** Beim App-Start: gespeicherten Zustand wiederherstellen (Default off). */ async restoreFromStorage(): Promise { try { const v = await AsyncStorage.getItem('aria_gps_tracking'); if (v === 'true') { console.log('[gps-track] Restore: war an, starte wieder'); this.start('Beim Start wiederhergestellt'); } } catch {} } private async ensurePermission(): Promise { if (Platform.OS !== 'android') return true; try { const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { title: 'GPS-Tracking', message: 'ARIA braucht laufende Standort-Updates damit GPS-Watcher (Blitzer-Warner, near()) funktionieren.', buttonPositive: 'Erlauben', buttonNegative: 'Abbrechen', }, ); return granted === PermissionsAndroid.RESULTS.GRANTED; } catch (e) { console.warn('[gps-track] Permission-Fehler:', e); return false; } } async start(reason: string = ''): Promise { if (this.active) return true; const ok = await this.ensurePermission(); if (!ok) { ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG); 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 { this.watchId = Geolocation.watchPosition( (pos) => { const lat = pos.coords.latitude; const lon = pos.coords.longitude; this.lastLat = lat; this.lastLon = lon; rvs.send('location_update' as any, { lat, lon }); }, (err) => { console.warn('[gps-track] watchPosition error:', err?.code, err?.message); }, { enableHighAccuracy: true, distanceFilter: 30, // erst senden wenn 30m gewandert interval: 15000, // (Android) gewuenschte Frequenz fastestInterval: 10000, // (Android) max Frequenz } as any, ); // Heartbeat: alle 60s die letzte bekannte Position erneut senden. // Sonst bleibt der Brain-State stale wenn der User stationaer ist // (distanceFilter blockt watchPosition-Updates) → near()-Watcher // verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s). // Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich. if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); this.heartbeatTimer = setInterval(() => { if (this.lastLat != null && this.lastLon != null) { rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon }); } }, 60_000); this.active = true; this.lastChangeAt = Date.now(); this.notify(); AsyncStorage.setItem('aria_gps_tracking', 'true').catch(() => {}); ToastAndroid.show( reason ? `GPS-Tracking aktiv (${reason})` : 'GPS-Tracking aktiv', ToastAndroid.SHORT, ); console.log('[gps-track] gestartet', reason ? `(${reason})` : ''); return true; } catch (e: any) { console.warn('[gps-track] start fehlgeschlagen:', e?.message); return false; } } stop(reason: string = ''): void { if (!this.active) return; if (this.watchId !== null) { try { Geolocation.clearWatch(this.watchId); } catch {} this.watchId = null; } if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } // Location-Foreground-Service-Slot freigeben (falls vorher acquired) try { releaseBackgroundAudio('location'); } catch {} this.active = false; this.lastChangeAt = Date.now(); this.notify(); AsyncStorage.setItem('aria_gps_tracking', 'false').catch(() => {}); ToastAndroid.show( reason ? `GPS-Tracking aus (${reason})` : 'GPS-Tracking aus', ToastAndroid.SHORT, ); console.log('[gps-track] gestoppt', reason ? `(${reason})` : ''); } async toggle(reason: string = ''): Promise { if (this.active) this.stop(reason); else await this.start(reason); } } export default new GpsTrackingService();