feat: add device authentication, place gates support, and PiP navigation features
This commit is contained in:
@@ -12,6 +12,7 @@ class ApiConstants {
|
||||
static const String mapSaasSearch = 'https://map-saas.intaleqapp.com/api/geocoding/search';
|
||||
static const String mapSaasPlaces = 'https://map-saas.intaleqapp.com/api/geocoding/places';
|
||||
static const String mapSaasTelemetry = 'https://map-saas.intaleqapp.com/api/telemetry';
|
||||
static const String mapSaasTelemetryBatch = 'https://map-saas.intaleqapp.com/api/telemetry/batch';
|
||||
static const String mapSaasStyleBase = 'https://map-saas.intaleqapp.com/api/maps/style.json';
|
||||
static const String mapSaasDeviceProvision = 'https://map-saas.intaleqapp.com/api/auth/device-provision';
|
||||
static const String googlePlacesNearby = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json';
|
||||
|
||||
@@ -77,7 +77,10 @@ class DeviceFingerprintService {
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('🔑 [DeviceFingerprintService] Hardware Fingerprint: $fingerprintId');
|
||||
debugPrint('🛡️ [DeviceFingerprintService] Active Key: ${activeApiKey.substring(0, 10)}... (Dedicated: $isDedicatedKeyActive)');
|
||||
final displayKey = activeApiKey.length >= 10
|
||||
? '${activeApiKey.substring(0, 10)}...'
|
||||
: (activeApiKey.isEmpty ? 'NONE (Unset)' : activeApiKey);
|
||||
debugPrint('🛡️ [DeviceFingerprintService] Active Key: $displayKey (Dedicated: $isDedicatedKeyActive)');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [DeviceFingerprintService] Initialization error: $e');
|
||||
_fingerprintId ??= 'siro_${Platform.operatingSystem}_fallback_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../data/repositories/map_saas_repository.dart';
|
||||
import 'device_fingerprint_service.dart';
|
||||
|
||||
/// Single telemetry point snapshot recorded during driving.
|
||||
class TelemetryPoint {
|
||||
final String driverId;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double speed;
|
||||
final double heading;
|
||||
final double elevation;
|
||||
final double distance;
|
||||
final DateTime timestamp;
|
||||
|
||||
const TelemetryPoint({
|
||||
required this.driverId,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.speed,
|
||||
required this.heading,
|
||||
this.elevation = 0.0,
|
||||
this.distance = 0.0,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'driver_id': driverId,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'speed': speed < 0 ? 0.0 : speed,
|
||||
'heading': heading < 0 ? 0.0 : heading,
|
||||
'elevation': elevation,
|
||||
'distance': distance,
|
||||
};
|
||||
}
|
||||
|
||||
/// Ultra-lightweight Telemetry & Periodic Sync Engine.
|
||||
///
|
||||
/// 1. Samples GPS location every 3 seconds with a stationary filter ("خفيف على البطارية").
|
||||
/// 2. Batches and flushes points to MapSaaS backend every 2 minutes (or 40 points).
|
||||
/// 3. Offline buffer retains up to 400 points if the device is disconnected.
|
||||
/// 4. Executes the 10-day maintenance & update cycle (style, road metadata, device quota).
|
||||
class TelemetryTrackingService {
|
||||
TelemetryTrackingService._();
|
||||
static final TelemetryTrackingService instance = TelemetryTrackingService._();
|
||||
|
||||
static const String prefKeyLastPeriodicCheck = 'siro_last_periodic_update_check_ms';
|
||||
static const int samplingIntervalSeconds = 3;
|
||||
static const int uploadIntervalSeconds = 120; // 2 minutes
|
||||
static const int maxBatchSize = 40; // 40 points * 3s = 120s
|
||||
static const int maxBufferSize = 400; // Offline safety ceiling (~20 minutes offline)
|
||||
|
||||
final List<TelemetryPoint> _buffer = [];
|
||||
DateTime? _lastSampleTime;
|
||||
DateTime? _lastFlushTime;
|
||||
TelemetryPoint? _lastRecordedPoint;
|
||||
Timer? _periodicFlushTimer;
|
||||
bool _isFlushing = false;
|
||||
|
||||
/// Expose buffer count for observability and unit tests
|
||||
int get bufferedPointCount => _buffer.length;
|
||||
List<TelemetryPoint> get bufferedPoints => List.unmodifiable(_buffer);
|
||||
DateTime? get lastFlushTime => _lastFlushTime;
|
||||
|
||||
/// Initializes periodic flush timer
|
||||
void initialize() {
|
||||
_periodicFlushTimer?.cancel();
|
||||
_lastFlushTime = DateTime.now();
|
||||
_periodicFlushTimer = Timer.periodic(
|
||||
const Duration(seconds: uploadIntervalSeconds),
|
||||
(_) => flush(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops tracking timers and flushes remaining points
|
||||
Future<void> dispose({MapSaasRepository? repository}) async {
|
||||
_periodicFlushTimer?.cancel();
|
||||
_periodicFlushTimer = null;
|
||||
await flush(repository: repository);
|
||||
}
|
||||
|
||||
/// Records a new GPS position snapshot every 3 seconds.
|
||||
/// Applies a smart stationary filter to eliminate redundant points when parked.
|
||||
bool recordPosition({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required double speedKmH,
|
||||
required double heading,
|
||||
double elevation = 0.0,
|
||||
double remainingDistance = 0.0,
|
||||
String? customDriverId,
|
||||
DateTime? now,
|
||||
}) {
|
||||
final currentTime = now ?? DateTime.now();
|
||||
|
||||
// 1. Throttle: Must be at least 3 seconds since last sample
|
||||
if (_lastSampleTime != null) {
|
||||
final elapsedSeconds = currentTime.difference(_lastSampleTime!).inSeconds;
|
||||
if (elapsedSeconds < samplingIntervalSeconds) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Smart Stationary Filter ("بخفة دم"):
|
||||
// If vehicle is completely stopped (speed < 1 km/h) and has not moved > 3 meters,
|
||||
// only record a single heartbeat point every 30 seconds instead of spamming duplicates.
|
||||
if (_lastRecordedPoint != null) {
|
||||
final isNearZeroSpeed = speedKmH < 1.0;
|
||||
final distMeters = Geolocator.distanceBetween(
|
||||
_lastRecordedPoint!.latitude,
|
||||
_lastRecordedPoint!.longitude,
|
||||
latitude,
|
||||
longitude,
|
||||
);
|
||||
|
||||
if (isNearZeroSpeed && distMeters < 3.0) {
|
||||
final elapsedSinceLastRecorded =
|
||||
currentTime.difference(_lastRecordedPoint!.timestamp).inSeconds;
|
||||
if (elapsedSinceLastRecorded < 30) {
|
||||
// Stationary and under 30 seconds heartbeat threshold: Skip point
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final driverId = customDriverId ?? DeviceFingerprintService.instance.fingerprintId;
|
||||
|
||||
final point = TelemetryPoint(
|
||||
driverId: driverId,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
speed: speedKmH,
|
||||
heading: heading,
|
||||
elevation: elevation,
|
||||
distance: remainingDistance,
|
||||
timestamp: currentTime,
|
||||
);
|
||||
|
||||
// Evict oldest points if offline buffer reaches ceiling
|
||||
if (_buffer.length >= maxBufferSize) {
|
||||
_buffer.removeAt(0);
|
||||
}
|
||||
|
||||
_buffer.add(point);
|
||||
_lastSampleTime = currentTime;
|
||||
_lastRecordedPoint = point;
|
||||
|
||||
// 3. Proactive flush if batch size threshold reached
|
||||
if (_buffer.length >= maxBatchSize) {
|
||||
flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Flushes all buffered telemetry points to MapSaaS backend in a single request.
|
||||
Future<bool> flush({MapSaasRepository? repository}) async {
|
||||
if (_buffer.isEmpty || _isFlushing) return true;
|
||||
|
||||
_isFlushing = true;
|
||||
final repo = repository ?? MapSaasRepository();
|
||||
final pointsToSend = List<TelemetryPoint>.from(_buffer);
|
||||
final payload = pointsToSend.map((p) => p.toJson()).toList();
|
||||
|
||||
try {
|
||||
final ok = await repo.sendTelemetryBatch(points: payload);
|
||||
if (ok) {
|
||||
// Remove successfully sent points from buffer
|
||||
_buffer.removeRange(0, pointsToSend.length);
|
||||
_lastFlushTime = DateTime.now();
|
||||
debugPrint('🛰️ [TelemetryTrackingService] Flushed ${pointsToSend.length} points to server.');
|
||||
_isFlushing = false;
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('⚠️ [TelemetryTrackingService] Batch upload failed, retaining points in buffer.');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [TelemetryTrackingService] Flush error: $e');
|
||||
}
|
||||
|
||||
_isFlushing = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Checks and performs the 10-day periodic maintenance & update cycle.
|
||||
/// Runs on app startup or navigation entry.
|
||||
Future<bool> checkPeriodicUpdates({
|
||||
DateTime? now,
|
||||
SharedPreferences? mockPrefs,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = mockPrefs ?? await SharedPreferences.getInstance();
|
||||
final lastCheckMs = prefs.getInt(prefKeyLastPeriodicCheck);
|
||||
final currentTime = now ?? DateTime.now();
|
||||
|
||||
if (lastCheckMs != null) {
|
||||
final lastCheck = DateTime.fromMillisecondsSinceEpoch(lastCheckMs);
|
||||
final differenceInDays = currentTime.difference(lastCheck).inDays;
|
||||
if (differenceInDays < 10) {
|
||||
// Less than 10 days since last update check
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('🔄 [TelemetryTrackingService] 10-day periodic update cycle triggered...');
|
||||
|
||||
// 1. Re-validate device fingerprint and refresh API key / quota
|
||||
await DeviceFingerprintService.instance.provisionDedicatedApiKey();
|
||||
|
||||
// 2. Persist new check timestamp
|
||||
await prefs.setInt(prefKeyLastPeriodicCheck, currentTime.millisecondsSinceEpoch);
|
||||
|
||||
debugPrint('✅ [TelemetryTrackingService] 10-day periodic update check completed successfully.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [TelemetryTrackingService] Periodic update error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Testing helper to reset internal buffer and timers
|
||||
@visibleForTesting
|
||||
void resetForTesting() {
|
||||
_buffer.clear();
|
||||
_lastSampleTime = null;
|
||||
_lastFlushTime = null;
|
||||
_lastRecordedPoint = null;
|
||||
_periodicFlushTimer?.cancel();
|
||||
_periodicFlushTimer = null;
|
||||
_isFlushing = false;
|
||||
}
|
||||
}
|
||||
@@ -349,4 +349,35 @@ class MapSaasRepository {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch ingest multiple telemetry points in a single HTTP request (POST /api/telemetry/batch)
|
||||
Future<bool> sendTelemetryBatch({
|
||||
required List<Map<String, dynamic>> points,
|
||||
}) async {
|
||||
if (points.isEmpty) return true;
|
||||
final uri = Uri.parse(ApiConstants.mapSaasTelemetryBatch);
|
||||
try {
|
||||
final payload = {
|
||||
'points': points,
|
||||
};
|
||||
final response = await client.post(
|
||||
uri,
|
||||
headers: {
|
||||
..._baseHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
final ok = response.statusCode == 200 || response.statusCode == 201;
|
||||
if (ok) {
|
||||
print("📦 [MapSaasRepo] Telemetry batch sent successfully: ${points.length} points");
|
||||
} else {
|
||||
print("⚠️ [MapSaasRepo] Telemetry batch HTTP ${response.statusCode}: ${response.body}");
|
||||
}
|
||||
return ok;
|
||||
} catch (e) {
|
||||
print("❌ [MapSaasRepo] sendTelemetryBatch error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../core/services/car_platform_bridge.dart';
|
||||
import '../../../core/services/connectivity_service.dart';
|
||||
import '../../../core/services/location_service.dart';
|
||||
import '../../../core/services/pip_service.dart';
|
||||
import '../../../core/services/telemetry_tracking_service.dart';
|
||||
import '../../../core/services/tts_service.dart';
|
||||
import '../../../core/services/vehicle_icon_generator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -36,8 +37,6 @@ class NavigationCubit extends Cubit<NavigationState> {
|
||||
DateTime? _offRouteStartTime;
|
||||
bool _isRerouting = false;
|
||||
int _lastTraveledIndexInFullRoute = 0;
|
||||
DateTime? _lastTelemetrySent;
|
||||
final String _driverId = 'driver_${DateTime.now().millisecondsSinceEpoch % 100000}';
|
||||
|
||||
NavigationCubit({
|
||||
required this.repository,
|
||||
@@ -68,6 +67,10 @@ class NavigationCubit extends Cubit<NavigationState> {
|
||||
CarPlatformBridge.ensureInitialized();
|
||||
await ttsService.init();
|
||||
|
||||
// Initialize 3-second telemetry tracking & 2-minute batch uploads
|
||||
TelemetryTrackingService.instance.initialize();
|
||||
TelemetryTrackingService.instance.checkPeriodicUpdates();
|
||||
|
||||
// Load saved vehicle & search preferences
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -286,20 +289,15 @@ class NavigationCubit extends Cubit<NavigationState> {
|
||||
_startMovementInterpolation(newLoc, heading);
|
||||
}
|
||||
|
||||
// Periodic driver telemetry stream (every 5 seconds)
|
||||
final now = DateTime.now();
|
||||
if (_lastTelemetrySent == null || now.difference(_lastTelemetrySent!).inSeconds >= 5) {
|
||||
_lastTelemetrySent = now;
|
||||
repository.sendDriverTelemetry(
|
||||
driverId: _driverId,
|
||||
latitude: newLoc.latitude,
|
||||
longitude: newLoc.longitude,
|
||||
speed: speedKmH,
|
||||
heading: heading,
|
||||
elevation: alt,
|
||||
distance: state.remainingDistance,
|
||||
);
|
||||
}
|
||||
// Continuous 3-second telemetry sampling with stationary filtering and 2-minute batching
|
||||
TelemetryTrackingService.instance.recordPosition(
|
||||
latitude: newLoc.latitude,
|
||||
longitude: newLoc.longitude,
|
||||
speedKmH: speedKmH,
|
||||
heading: heading,
|
||||
elevation: alt,
|
||||
remainingDistance: state.remainingDistance,
|
||||
);
|
||||
|
||||
// Proactively move camera on first acquired GPS lock (only when style is loaded)
|
||||
if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) {
|
||||
@@ -776,6 +774,7 @@ class NavigationCubit extends Cubit<NavigationState> {
|
||||
|
||||
void stopNavigation() {
|
||||
print("🛑 [NavigationCubit] stopNavigation triggered.");
|
||||
TelemetryTrackingService.instance.flush(repository: repository);
|
||||
_movementInterpolationTimer?.cancel();
|
||||
_movementInterpolationTimer = null;
|
||||
ttsService.stop();
|
||||
@@ -1277,6 +1276,7 @@ class NavigationCubit extends Cubit<NavigationState> {
|
||||
_connectivitySub?.cancel();
|
||||
_searchDebounce?.cancel();
|
||||
ttsService.stop();
|
||||
TelemetryTrackingService.instance.dispose(repository: repository);
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user