import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'dart:math' as math; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nearle/utils/kalman_filter.dart'; import 'package:nearle/utils/mqtt_service.dart'; import 'package:battery_plus/battery_plus.dart'; class LiveTrackingService { static final LiveTrackingService _instance = LiveTrackingService._internal(); factory LiveTrackingService() => _instance; LiveTrackingService._internal(); StreamSubscription? _positionStreamSubscription; bool _isTracking = false; bool _isProcessing = false; NearleKalmanFilter? _kf; DateTime? _lastUpdateTime; DateTime? _lastSentTime; DateTime? _lastTelemetrySent; Timer? _watchdogTimer; // Thresholds static const double _maxAccuracyMeters = 50.0; // Reject GPS > 50m accuracy static const double _minDistanceMeters = 5.0; // Minimum movement to count static const double _maxJumpMeters = 200.0; // Reject single-step jumps > 200m static const int _rateLimitSeconds = 3; // Minimum seconds between updates static const int _watchdogSeconds = 45; // Restart if no update in 45s void startTracking() async { if (_isTracking) return; final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { debugPrint('[LIVE TRACKING] Location services disabled.'); return; } final permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { debugPrint('[LIVE TRACKING] Location permission denied.'); return; } _isTracking = true; _lastSentTime = null; debugPrint('[LIVE TRACKING] Starting high-frequency tracking...'); _startStream(); _startWatchdog(); } void _startStream() { _positionStreamSubscription?.cancel(); const locationSettings = LocationSettings( accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 0, ); _positionStreamSubscription = Geolocator.getPositionStream( locationSettings: locationSettings, ).listen( (Position position) async { await _sendToKalmanBackend(position); }, onError: (error) { debugPrint('[LIVE TRACKING] Stream error: $error — restarting in 5s'); _positionStreamSubscription?.cancel(); _positionStreamSubscription = null; if (_isTracking) { Future.delayed(const Duration(seconds: 5), () { if (_isTracking) _startStream(); }); } }, cancelOnError: true, ); } void _startWatchdog() { _watchdogTimer?.cancel(); _watchdogTimer = Timer.periodic( const Duration(seconds: _watchdogSeconds), (_) { if (!_isTracking) return; final lastSent = _lastSentTime; if (lastSent == null) return; final staleSeconds = DateTime.now().difference(lastSent).inSeconds; if (staleSeconds > _watchdogSeconds) { debugPrint( '[LIVE TRACKING] Watchdog: stream stale for ${staleSeconds}s — restarting', ); _positionStreamSubscription?.cancel(); _positionStreamSubscription = null; _kf = null; _lastUpdateTime = null; _startStream(); } }, ); } void stopTracking() { if (!_isTracking) return; debugPrint('[LIVE TRACKING] Stopping tracking.'); _watchdogTimer?.cancel(); _watchdogTimer = null; _positionStreamSubscription?.cancel(); _positionStreamSubscription = null; _kf = null; _lastUpdateTime = null; _isTracking = false; } Future _sendToKalmanBackend(Position position) async { final now = DateTime.now(); // Rate limiter if (_lastSentTime != null && now.difference(_lastSentTime!).inSeconds < _rateLimitSeconds) { return; } // Mutex — prevent concurrent processing if (_isProcessing) return; _isProcessing = true; _lastSentTime = now; try { // Reject mocked GPS (anti-cheat) if (position.isMocked) { debugPrint('[LIVE TRACKING] Skipped mocked position'); return; } // Reject poor-accuracy GPS (power saver / other apps degrading GPS) if (position.accuracy > _maxAccuracyMeters) { debugPrint( '[LIVE TRACKING] Skipped low-accuracy position: ${position.accuracy.toStringAsFixed(0)}m', ); return; } final prefs = await SharedPreferences.getInstance(); final userid = prefs.getInt('userId') ?? prefs.getInt('userid'); final currentOrderId = prefs.getString('current_riding_order_id') ?? ''; final double headingRadians = position.heading * (math.pi / 180.0); final double velocityLng = position.speed * math.sin(headingRadians); final double velocityLat = position.speed * math.cos(headingRadians); double displayLat = position.latitude; double displayLng = position.longitude; if (_kf == null) { _kf = NearleKalmanFilter( lat: position.latitude, lng: position.longitude, ); } else { final double dt = _lastUpdateTime != null ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 : _rateLimitSeconds.toDouble(); _kf!.predict(dt); _kf!.update(position.latitude, position.longitude); displayLat = _kf!.x[0]; displayLng = _kf!.x[1]; } _lastUpdateTime = now; // Stamp that the main isolate is actively tracking. // The foreground service reads this to avoid double-counting KMs. await prefs.setInt('live_tracking_last_update_ms', now.millisecondsSinceEpoch); final payload = { 'userid': userid, 'orderid': currentOrderId, 'lat': displayLat, 'lng': displayLng, 'raw_lat': position.latitude, 'raw_lng': position.longitude, 'speed': position.speed, 'heading': position.heading, 'velocity_lat': velocityLat, 'velocity_lng': velocityLng, 'timestamp': now.toIso8601String(), }; // Accumulate cumulative KMs for active deliveries final activeDeliveryIds = prefs.getStringList('active_tracking_delivery_ids') ?? []; for (final dId in activeDeliveryIds) { try { final lastLatStr = prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; final lastLngStr = prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; final currentCumKm = double.tryParse( prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', ) ?? 0.0; if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { final lastLat = double.tryParse(lastLatStr) ?? 0.0; final lastLng = double.tryParse(lastLngStr) ?? 0.0; if (lastLat != 0 && lastLng != 0 && displayLat != 0 && displayLng != 0) { final distanceMeters = Geolocator.distanceBetween( lastLat, lastLng, displayLat, displayLng, ); // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s) final lastUpdateMs = prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; final elapsedSeconds = lastUpdateMs > 0 ? (now.millisecondsSinceEpoch - lastUpdateMs) / 1000.0 : _rateLimitSeconds.toDouble(); final maxRealisticMeters = elapsedSeconds * 33.3; // 120 km/h ceiling if (distanceMeters > maxRealisticMeters && distanceMeters > _maxJumpMeters) { // GPS jumped — update anchor without counting the phantom distance debugPrint( '[LIVE TRACKING] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' 'in ${elapsedSeconds.toStringAsFixed(1)}s (max realistic: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', ); await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); } else if (distanceMeters >= _minDistanceMeters) { final newCumKm = currentCumKm + (distanceMeters / 1000.0); await prefs.setString( 'delivery_tracking_${dId}_cumulativeKm', newCumKm.toStringAsFixed(4), ); await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); } // else: too small — skip without moving anchor (avoids GPS noise accumulation) } } else { // First point for this delivery — set anchor only await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); } } catch (_) {} } final mqttService = NearleMqttService(); if (mqttService.isConnected) { mqttService.publishLocation(payload); debugPrint( '[LIVE TRACKING] ${displayLat.toStringAsFixed(6)}, ${displayLng.toStringAsFixed(6)} ' '(acc: ${position.accuracy.toStringAsFixed(0)}m, speed: ${position.speed.toStringAsFixed(1)} m/s)', ); } else { debugPrint('[LIVE TRACKING] MQTT not connected — attempting reconnect'); mqttService.connect(); } // Telemetry once every 5 minutes (timestamp guard prevents duplicate fires // across the 3-second GPS update window at the same minute mark) final shouldSendTelemetry = _lastTelemetrySent == null || now.difference(_lastTelemetrySent!).inMinutes >= 5; if (now.minute % 5 == 0 && shouldSendTelemetry) { _lastTelemetrySent = now; try { final battery = Battery(); final batteryLevel = await battery.batteryLevel; mqttService.publishTelemetry({ 'battery_level': batteryLevel, 'gps_accuracy': position.accuracy, 'mocked': position.isMocked, 'timestamp': now.toIso8601String(), }); } catch (_) {} } } catch (e) { debugPrint('[LIVE TRACKING] Error: $e'); } finally { _isProcessing = false; } } }