f2bfd4bbc6
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>
226 lines
8.2 KiB
TypeScript
226 lines
8.2 KiB
TypeScript
/**
|
|
* 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<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;
|
|
|
|
class GpsTrackingService {
|
|
private watchId: number | null = null;
|
|
private active = false;
|
|
private listeners: Set<Listener> = 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<typeof setInterval> | 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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
if (this.active) this.stop(reason);
|
|
else await this.start(reason);
|
|
}
|
|
}
|
|
|
|
export default new GpsTrackingService();
|