Files
Xpress-rider/lib/background/live_tracking_service.dart

293 lines
11 KiB
Dart

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<Position>? _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<void> _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;
}
}
}