MotorSignal-App/lib/services/location_service.dart

366 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
final locationServiceProvider = Provider<LocationService>((ref) {
final service = LocationService();
ref.onDispose(() => service.dispose());
return service;
});
final locationStreamProvider = StreamProvider<Position>((ref) {
final service = ref.watch(locationServiceProvider);
return service.getFilteredPositionStream();
});
final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
final service = ref.watch(locationServiceProvider);
return await service.hasPermissions();
});
class LocationService {
Position? _lastValidPosition;
double? _emaSpeed;
final StreamController<Position> _controller = StreamController<Position>.broadcast();
StreamSubscription<Position>? _rawPositionSub;
Timer? _watchdogTimer;
bool _isDisposed = false;
// Filtering thresholds
static const double _maxRealisticSpeed = 100.0; // roughly 223 MPH in m/s
static const double _maxAcceleration = 30.0; // m/s^2 (allows extreme superbike launch / braking while rejecting GPS teleportation)
static const double _speedEmaAlpha = 0.35; // Smoothing factor for speed
void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic;
LocationService() {
_initStream();
}
Future<bool> requestPermissions() async {
try {
// 1. Geolocator native permission check & request
LocationPermission geoPermission = await Geolocator.checkPermission();
if (geoPermission == LocationPermission.denied) {
geoPermission = await Geolocator.requestPermission();
}
// 2. Permission handler check & request
final locStatus = await Permission.locationWhenInUse.request();
await Permission.notification.request();
if (geoPermission == LocationPermission.whileInUse ||
geoPermission == LocationPermission.always ||
locStatus.isGranted) {
try {
await Permission.locationAlways.request();
} catch (_) {}
// Restart/refresh GPS stream now that permissions are granted
_startListening();
return true;
}
} catch (_) {}
return false;
}
Future<bool> hasPermissions() async {
try {
final geoPermission = await Geolocator.checkPermission();
if (geoPermission == LocationPermission.whileInUse ||
geoPermission == LocationPermission.always) {
return true;
}
final status = await Permission.locationWhenInUse.status;
return status.isGranted;
} catch (_) {
return false;
}
}
Stream<Position> getFilteredPositionStream() {
return _controller.stream;
}
void _initStream() {
_startListening();
_startWatchdog();
}
Future<void> _startListening() async {
if (_isDisposed) return;
await _rawPositionSub?.cancel();
_rawPositionSub = null;
try {
final isServiceEnabled = await Geolocator.isLocationServiceEnabled();
if (!isServiceEnabled) {
return;
}
// Immediately bootstrap with last known position if available
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && !_isDisposed) {
_handleRawPosition(lastPos);
}
// Platform-specific LocationSettings
LocationSettings locationSettings;
if (defaultTargetPlatform == TargetPlatform.android) {
locationSettings = AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 0,
intervalDuration: const Duration(milliseconds: 200),
forceLocationManager: true, // Directly access GPS hardware receiver
);
} else {
locationSettings = const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 0,
);
}
_rawPositionSub = Geolocator.getPositionStream(locationSettings: locationSettings).listen(
(pos) {
_handleRawPosition(pos);
},
onError: (err) {
onGpsDiagnostic?.call(
Position(
longitude: _lastValidPosition?.longitude ?? 0.0,
latitude: _lastValidPosition?.latitude ?? 0.0,
timestamp: DateTime.now(),
accuracy: 999.0,
altitude: 0.0,
heading: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
altitudeAccuracy: 0.0,
headingAccuracy: 0.0,
),
false,
'GPS Stream Error: $err',
);
// Try to reconnect in 2 seconds
Future.delayed(const Duration(seconds: 2), () {
if (!_isDisposed) _startListening();
});
},
cancelOnError: false,
);
} catch (_) {}
}
void _startWatchdog() {
_watchdogTimer?.cancel();
// Watchdog every 1000ms: if stream has gone quiet (>2s since last fix), poll getCurrentPosition actively
_watchdogTimer = Timer.periodic(const Duration(milliseconds: 1000), (_) async {
if (_isDisposed) return;
final now = DateTime.now();
final lastFixAge = _lastValidPosition == null
? 999
: now.difference(_lastValidPosition!.timestamp).inMilliseconds;
if (lastFixAge > 2000) {
try {
final hasPerm = await hasPermissions();
if (!hasPerm) return;
Position? pos;
try {
pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 2),
),
);
} catch (_) {}
if (pos != null && !_isDisposed) {
_handleRawPosition(pos);
}
} catch (_) {}
}
});
}
void _handleRawPosition(Position position) {
if (_isValidPosition(position)) {
final filtered = _applyEmaFilter(position);
if (!_controller.isClosed) {
_controller.add(filtered);
}
}
}
bool _isValidPosition(Position position) {
// Dynamic accuracy threshold: allow wider accuracy during satellite lock & highway speeds
final currentSpeed = _emaSpeed ?? position.speed;
final maxAcceptableAccuracy = _lastValidPosition == null
? 80.0
: (currentSpeed > 10.0 ? 60.0 : 45.0);
if (position.accuracy > maxAcceptableAccuracy) {
onGpsDiagnostic?.call(
position,
false,
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
);
return false; // Reject extremely poor accuracy
}
// Reject 0.0 MPH glitch if accuracy is degraded while motorcycle was previously at speed
if (position.speed == 0.0 && currentSpeed > 10.0 && position.accuracy > 30.0) {
onGpsDiagnostic?.call(
position,
false,
'Transient zero-speed cell glitch rejected (${position.accuracy.toStringAsFixed(1)}m)',
);
return false;
}
if (position.speed > _maxRealisticSpeed) {
onGpsDiagnostic?.call(
position,
false,
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
);
return false; // Reject impossible speeds (>223 MPH)
}
if (_lastValidPosition != null) {
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
// If signal was lost for > 10 seconds (e.g. tunnel or stop), treat as new baseline fix
if (timeDiff > 10.0) {
_lastValidPosition = position;
_emaSpeed = position.speed;
onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
return true;
}
if (timeDiff >= 0.25) {
// Acceleration check (only compute over stable time windows >= 250ms)
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
if (accel > _maxAcceleration) {
onGpsDiagnostic?.call(
position,
false,
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
);
return false; // Reject impossible acceleration spikes
}
// Path consistency check (distance jump vs time)
final distanceJump = Geolocator.distanceBetween(
_lastValidPosition!.latitude,
_lastValidPosition!.longitude,
position.latitude,
position.longitude,
);
final impliedSpeed = distanceJump / timeDiff;
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
onGpsDiagnostic?.call(
position,
false,
'Position jump anomaly (${(impliedSpeed * 2.23694).toStringAsFixed(1)} MPH implied speed)',
);
return false;
}
}
}
_lastValidPosition = position;
onGpsDiagnostic?.call(position, true, null);
return true;
}
bool _isStationaryLocked = true;
Position? _stationaryAnchorPosition;
DateTime? _lowSpeedStartTime;
Position _applyEmaFilter(Position position) {
// Robust stationary hysteresis filter:
// GPS Doppler noise and multipath jitter typically fluctuates between 0.2 m/s and 0.7 m/s (0.5 - 1.6 MPH)
// while the motorcycle is completely stationary.
final rawSpeed = position.speed;
if (rawSpeed < 0.6) {
_lowSpeedStartTime ??= position.timestamp;
final lowSpeedDuration = position.timestamp.difference(_lowSpeedStartTime!).inMilliseconds;
_stationaryAnchorPosition ??= position;
final driftFromAnchor = Geolocator.distanceBetween(
_stationaryAnchorPosition!.latitude,
_stationaryAnchorPosition!.longitude,
position.latitude,
position.longitude,
);
// Lock to absolute 0.0 MPH if below threshold for > 800ms or within 2.5m drift radius
if (lowSpeedDuration >= 800 || driftFromAnchor < 2.5) {
_isStationaryLocked = true;
_emaSpeed = 0.0;
}
} else if (rawSpeed >= 0.8) {
// Break out of stationary lock only upon genuine sustained movement
double moveDistance = 0.0;
if (_stationaryAnchorPosition != null) {
moveDistance = Geolocator.distanceBetween(
_stationaryAnchorPosition!.latitude,
_stationaryAnchorPosition!.longitude,
position.latitude,
position.longitude,
);
}
if (!_isStationaryLocked || moveDistance >= 2.0 || rawSpeed >= 1.2) {
_isStationaryLocked = false;
_stationaryAnchorPosition = null;
_lowSpeedStartTime = null;
if (_emaSpeed == null || _emaSpeed == 0.0) {
_emaSpeed = rawSpeed;
} else {
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
}
} else {
_emaSpeed = 0.0;
}
} else {
// Transition zone (0.6 - 0.8 m/s): maintain current stationary lock state
if (_isStationaryLocked) {
_emaSpeed = 0.0;
} else {
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
}
}
return Position(
longitude: position.longitude,
latitude: position.latitude,
timestamp: position.timestamp,
accuracy: position.accuracy,
altitude: position.altitude,
heading: position.heading,
speed: _emaSpeed ?? 0.0,
speedAccuracy: position.speedAccuracy,
altitudeAccuracy: position.altitudeAccuracy,
headingAccuracy: position.headingAccuracy,
isMocked: position.isMocked,
floor: position.floor,
);
}
void dispose() {
_isDisposed = true;
_watchdogTimer?.cancel();
_rawPositionSub?.cancel();
_controller.close();
}
}