From a8470eb76cff76894363e90ad2bb435a575c1e95 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 16 Sep 2026 00:26:08 +0300 Subject: [PATCH] feat: add device authentication, place gates support, and PiP navigation features --- apps/siro_maps/APP_REVIEW_INSTRUCTIONS.md | 80 ++++++ apps/siro_maps/GOOGLE_PLAY_STORE_LISTING.md | 42 ++++ apps/siro_maps/android/app/build.gradle.kts | 6 +- apps/siro_maps/android/app/proguard-rules.pro | 10 + .../android/app/src/main/AndroidManifest.xml | 7 +- .../lib/core/constants/api_constants.dart | 1 + .../services/device_fingerprint_service.dart | 5 +- .../services/telemetry_tracking_service.dart | 236 ++++++++++++++++++ .../repositories/map_saas_repository.dart | 31 +++ .../cubits/navigation/navigation_cubit.dart | 32 +-- apps/siro_maps/pubspec.lock | 12 +- apps/siro_maps/pubspec.yaml | 2 +- apps/siro_maps/test/env_test.dart | 13 +- .../test/gates_and_fingerprint_test.dart | 2 +- .../test/telemetry_tracking_test.dart | 210 ++++++++++++++++ 15 files changed, 652 insertions(+), 37 deletions(-) create mode 100644 apps/siro_maps/APP_REVIEW_INSTRUCTIONS.md create mode 100644 apps/siro_maps/lib/core/services/telemetry_tracking_service.dart create mode 100644 apps/siro_maps/test/telemetry_tracking_test.dart diff --git a/apps/siro_maps/APP_REVIEW_INSTRUCTIONS.md b/apps/siro_maps/APP_REVIEW_INSTRUCTIONS.md new file mode 100644 index 0000000..26cedff --- /dev/null +++ b/apps/siro_maps/APP_REVIEW_INSTRUCTIONS.md @@ -0,0 +1,80 @@ +# تعليمات فاحصي متجر التطبيقات (App Review & Testing Instructions) +## لمتجر جوجل بلاي (Google Play Console) ومتجر آبل (App Store Connect) + +--- + +### أولاً: النص المخصص للنسخ المباشر إلى Google Play Console (باللغة الإنجليزية - المعتمدة للمراجعين) + +> **مكان اللصق في Google Play Console**: +> انتقل إلى: **سياسة التطبيق (App content)** ➔ **الوصول إلى التطبيق (App access)** ➔ اختر **"جميع الوظائف أو بعضها خاضع لقيود" (All or some functionality is restricted)** ➔ أضف تعليمات جديدة: + +```text +IMPORTANT NOTICE FOR APP REVIEWERS: + +1. Operational Geographic Coverage: +Uruk Map is engineered on an independent, self-hosted regional spatial vector engine. Our active vector map tiles and routing servers are currently provisioned specifically for three operational countries in the MENA region: +- Jordan (الأردن) +- Iraq (العراق) +- Egypt (مصر) + +If you test the app on a physical device or emulator located outside these three countries (e.g., from the United States or Europe) with local GPS enabled, the camera will center on your local coordinates where our regional map tiles are not rendered, resulting in an empty tile canvas. This is expected behavior due to localized regional hosting. + +2. How to Test and Review the App Successfully: + +OPTION A (Recommended - Instant In-App Search): +1. Launch the app and grant or dismiss location permissions. +2. In the top search bar, type any landmark or city within our coverage area: + - For Jordan: Type "Amman" or "عمان" or "City Mall" + - For Iraq: Type "Baghdad" or "بغداد" or "Tahrir Square" + - For Egypt: Type "Cairo" or "القاهرة" or "Nasr City" +3. Select any result. The map will instantly fly to the location, load rich vector tiles, 3D buildings, and gates. +4. Tap "Start Navigation" or "ابدأ الملاحة" to experience turn-by-turn routing, Arabic voice guidance, and vehicle markers. + +OPTION B (Simulated GPS Location): +In your emulator or testing device, set a mock GPS location to any of our operational hubs: +- Amman (Jordan): Latitude 31.9539, Longitude 35.9106 +- Baghdad (Iraq): Latitude 33.3152, Longitude 44.3661 +- Cairo (Egypt): Latitude 30.0444, Longitude 31.2357 +The app will immediately render full street networks and navigation HUD. + +3. Authentication & Credentials: +No username or password is required. The application automatically registers via secure hardware device fingerprinting and provisions a consumer API key seamlessly on initial launch. + +For any technical inquiries during review, please contact: support@intaleqapp.com +``` + +--- + +### ثانياً: النص باللغة العربية (للتوثيق الداخلي والمراجعين الناطقين بالعربية) + +```text +ملاحظة هامة لفاحصي ومراجعي التطبيق: + +1. نطاق التغطية الجغرافية التشغيلية: +تعتمد «خرائط أوروك» على منظومة خرائط وملاحة فيكتورية مستقلة ومستضافة ذاتياً، وتغطي خوادم الخرائط والبيانات المكانية الحالية ثلاث دول تشغيلية رئيسية في منطقة الشرق الأوسط وشمال أفريقيا: +- الأردن +- العراق +- مصر + +في حال تم فتح التطبيق واختباره من جهاز يقع خارج هذه الدول الثلاث (مثل الولايات المتحدة أو أوروبا) مع تفعيل نظام تحديد المواقع المحلي (GPS)، فإن الكاميرا ستتوجه لموقع الجهاز الحالي حيث لا تتوفر بلاطات خرائط محلية خارج نطاق التغطية، مما يؤدي لعدم ظهور تفاصيل الطرق. هذا سلوك طبيعي ناتج عن التخصيص الجغرافي للمنظومة. + +2. كيفية فحص وتجربة التطبيق بنجاح: + +الخيار الأول (الموصى به - عبر البحث المباشر داخل التطبيق): +1. افتح التطبيق، ثم في شريط البحث العلوي ابحث عن أي مدينة أو معلم في الدول المدعومة: + - الأردن: ابحث عن "عمان" أو "سيتي مول" أو "الدوار السابع" + - العراق: ابحث عن "بغداد" أو "ساحة التحرير" أو "أربيل" + - مصر: ابحث عن "القاهرة" أو "ميدان التحرير" أو "مدينة نصر" +2. اختر أي نتيجة من القائمة، وستنتقل الخريطة فوراً للموقع وتظهر البلاطات الفيكتورية وتفاصيل الطرق والبوابات. +3. اضغط على زر "ابدأ الملاحة" لتجربة مسارات السير والتوجيه الصوتي ونمط القيادة. + +الخيار الثاني (عبر محاكاة الموقع GPS Mock Location): +في المحاكي أو جهاز الفحص، قم بتعيين إحداثيات موقع وهمي ضمن إحدى المدن التالية: +- عمان (الأردن): خط العرض 31.9539 ، خط الطول 35.9106 +- بغداد (العراق): خط العرض 33.3152 ، خط الطول 44.3661 +- القاهرة (مصر): خط العرض 30.0444 ، خط الطول 31.2357 +وستعمل الخريطة والملاحة اللحظية على الفور. + +3. بيانات تسجيل الدخول والوصول: +لا يتطلب التطبيق أي اسم مستخدم أو كلمة مرور. التطبيق يعمل فوراً بمجرد الفتح ويقوم بتوليد مفتاح وصول مشفر عبر بصمة الجهاز تلقائياً. +``` diff --git a/apps/siro_maps/GOOGLE_PLAY_STORE_LISTING.md b/apps/siro_maps/GOOGLE_PLAY_STORE_LISTING.md index 5cf2858..60798a1 100644 --- a/apps/siro_maps/GOOGLE_PLAY_STORE_LISTING.md +++ b/apps/siro_maps/GOOGLE_PLAY_STORE_LISTING.md @@ -183,3 +183,45 @@ - هل يشارك التطبيق الموقع الجغرافي الدقيق للمستخدم مع أطراف خارجية لأغراض إعلانية؟ **لا (No)** - هل يستخدم التطبيق الموقع لأغراض تقديم خدمات الملاحة والتوجيه للمستخدم؟ **نعم (Yes)** - **النتيجة التلقائية**: تصنيف **مناسب للجميع (Everyone / PEGI 3)**. + +--- + +## 6. تعليمات الوصول للفاحصين (App Access & Reviewer Instructions) + +> **مكان اللصق في Google Play Console**: +> من القائمة الجانبية: **سياسة التطبيق (App content)** ➔ **الوصول إلى التطبيق (App access)** ➔ اختر **"جميع الوظائف أو بعضها خاضع لقيود" (All or some functionality is restricted)** ➔ أضف الإرشادات التالية باللغة الإنجليزية: + +### نص الإرشادات بالإنجليزية (لإدخاله في خانة تعليمات فاحصي جوجل): +```text +IMPORTANT NOTICE FOR APP REVIEWERS: + +1. Operational Geographic Coverage: +Uruk Map operates on an independent, self-hosted regional spatial vector engine. Our vector map tiles and routing servers are currently live specifically for three operational countries in the MENA region: +- Jordan (الأردن) +- Iraq (العراق) +- Egypt (مصر) + +If you test the app on a physical device or emulator located outside these three countries (e.g. from the United States or Europe) with local GPS enabled, the camera will center on your local coordinates where our regional map tiles are not rendered, resulting in an empty tile canvas. This is expected behavior due to localized regional hosting. + +2. How to Test and Review the App Successfully: + +OPTION A (Recommended - Instant In-App Search): +1. Launch the app and grant or dismiss location permissions. +2. In the top search bar, type any landmark or city within our coverage area: + - For Jordan: Type "Amman" or "عمان" or "City Mall" + - For Iraq: Type "Baghdad" or "بغداد" or "Tahrir Square" + - For Egypt: Type "Cairo" or "القاهرة" or "Nasr City" +3. Select any result. The map will instantly fly to the location, load rich vector tiles, 3D buildings, and gates. +4. Tap "Start Navigation" or "ابدأ الملاحة" to experience turn-by-turn routing, Arabic voice guidance, and vehicle markers. + +OPTION B (Simulated GPS Location): +In your emulator or testing device, set a mock GPS location to any of our operational hubs: +- Amman (Jordan): Latitude 31.9539, Longitude 35.9106 +- Baghdad (Iraq): Latitude 33.3152, Longitude 44.3661 +- Cairo (Egypt): Latitude 30.0444, Longitude 31.2357 +The app will immediately render full street networks and navigation HUD. + +3. Authentication & Credentials: +No username or password is required. The application automatically registers via secure hardware device fingerprinting and provisions a consumer API key seamlessly on initial launch. +``` + diff --git a/apps/siro_maps/android/app/build.gradle.kts b/apps/siro_maps/android/app/build.gradle.kts index 8497e77..b0ef060 100644 --- a/apps/siro_maps/android/app/build.gradle.kts +++ b/apps/siro_maps/android/app/build.gradle.kts @@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.urukmap.app" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -64,6 +64,10 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + ndk { + debugSymbolLevel = "none" + } } debug { signingConfig = signingConfigs.getByName("debug") diff --git a/apps/siro_maps/android/app/proguard-rules.pro b/apps/siro_maps/android/app/proguard-rules.pro index a26d94a..5d9638a 100644 --- a/apps/siro_maps/android/app/proguard-rules.pro +++ b/apps/siro_maps/android/app/proguard-rules.pro @@ -63,3 +63,13 @@ -dontwarn kotlin.** -dontwarn kotlinx.coroutines.** -keep class kotlin.Metadata { *; } + +# ── 6. PLAY STORE CORE & DEFERRED COMPONENTS ────────────────────────────────── +-dontwarn com.google.android.play.core.** +-dontwarn com.google.android.play.core.splitcompat.** +-dontwarn com.google.android.play.core.splitinstall.** +-dontwarn com.google.android.play.core.tasks.** + +# ── 7. GENERAL DEPENDENCIES ─────────────────────────────────────────────────── +-dontwarn javax.annotation.** +-dontwarn org.checkerframework.** diff --git a/apps/siro_maps/android/app/src/main/AndroidManifest.xml b/apps/siro_maps/android/app/src/main/AndroidManifest.xml index 00e9981..ae751ef 100644 --- a/apps/siro_maps/android/app/src/main/AndroidManifest.xml +++ b/apps/siro_maps/android/app/src/main/AndroidManifest.xml @@ -3,12 +3,13 @@ - - - + + + + = 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}'; diff --git a/apps/siro_maps/lib/core/services/telemetry_tracking_service.dart b/apps/siro_maps/lib/core/services/telemetry_tracking_service.dart new file mode 100644 index 0000000..dde3d58 --- /dev/null +++ b/apps/siro_maps/lib/core/services/telemetry_tracking_service.dart @@ -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 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 _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 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 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 flush({MapSaasRepository? repository}) async { + if (_buffer.isEmpty || _isFlushing) return true; + + _isFlushing = true; + final repo = repository ?? MapSaasRepository(); + final pointsToSend = List.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 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; + } +} diff --git a/apps/siro_maps/lib/data/repositories/map_saas_repository.dart b/apps/siro_maps/lib/data/repositories/map_saas_repository.dart index 1dda967..99fa6da 100644 --- a/apps/siro_maps/lib/data/repositories/map_saas_repository.dart +++ b/apps/siro_maps/lib/data/repositories/map_saas_repository.dart @@ -349,4 +349,35 @@ class MapSaasRepository { return false; } } + + /// Batch ingest multiple telemetry points in a single HTTP request (POST /api/telemetry/batch) + Future sendTelemetryBatch({ + required List> 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; + } + } } diff --git a/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart b/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart index 861bd7b..9dc9ce0 100644 --- a/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart +++ b/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart @@ -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 { 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 { 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 { _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 { 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 { _connectivitySub?.cancel(); _searchDebounce?.cancel(); ttsService.stop(); + TelemetryTrackingService.instance.dispose(repository: repository); return super.close(); } } diff --git a/apps/siro_maps/pubspec.lock b/apps/siro_maps/pubspec.lock index 9593e05..45dc2e7 100644 --- a/apps/siro_maps/pubspec.lock +++ b/apps/siro_maps/pubspec.lock @@ -627,10 +627,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -643,10 +643,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1032,10 +1032,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/apps/siro_maps/pubspec.yaml b/apps/siro_maps/pubspec.yaml index 4c3754b..ce33d65 100644 --- a/apps/siro_maps/pubspec.yaml +++ b/apps/siro_maps/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+4 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/apps/siro_maps/test/env_test.dart b/apps/siro_maps/test/env_test.dart index ed0c83b..7fa8de8 100644 --- a/apps/siro_maps/test/env_test.dart +++ b/apps/siro_maps/test/env_test.dart @@ -4,14 +4,11 @@ import 'package:siro_maps/core/env/env.dart'; void main() { group('Env & Obfuscated Secrets Verification', () { - test('MapSaaS API key is decrypted correctly from obfuscated bytecode', () { - expect(Env.mapSaasApiKey, equals('in_9478b32836d19cff73db3063')); - expect(ApiConstants.mapSaasKey, equals('in_9478b32836d19cff73db3063')); - }); - - test('Google Map API key is decrypted correctly from obfuscated bytecode', () { - expect(Env.googleMapApiKey, equals('AIzaSyAPFR_XbRN0XZ5Iz3AYDjNYHGJG2s2QWwM')); - expect(ApiConstants.googleMapApiKey, equals('AIzaSyAPFR_XbRN0XZ5Iz3AYDjNYHGJG2s2QWwM')); + test('MapSaaS and Google API keys in Env default to empty (dynamic provisioning via Device Fingerprint)', () { + expect(Env.mapSaasApiKey, isEmpty); + expect(ApiConstants.mapSaasKey, isEmpty); + expect(Env.googleMapApiKey, isEmpty); + expect(ApiConstants.googleMapApiKey, isEmpty); }); }); } diff --git a/apps/siro_maps/test/gates_and_fingerprint_test.dart b/apps/siro_maps/test/gates_and_fingerprint_test.dart index 8af4478..0735b24 100644 --- a/apps/siro_maps/test/gates_and_fingerprint_test.dart +++ b/apps/siro_maps/test/gates_and_fingerprint_test.dart @@ -102,7 +102,7 @@ void main() { expect(headers.containsKey('x-client-version'), isTrue); expect(headers.containsKey('x-device-platform'), isTrue); expect(headers.containsKey('x-api-key'), isTrue); - expect(headers['x-api-key'], isNotEmpty); + expect(headers['x-api-key'], equals(ApiConstants.mapSaasKey)); }); test('Provisions dedicated consumer API key and switches activeApiKey', () async { diff --git a/apps/siro_maps/test/telemetry_tracking_test.dart b/apps/siro_maps/test/telemetry_tracking_test.dart new file mode 100644 index 0000000..bfc59e4 --- /dev/null +++ b/apps/siro_maps/test/telemetry_tracking_test.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:siro_maps/core/services/device_fingerprint_service.dart'; +import 'package:siro_maps/core/services/telemetry_tracking_service.dart'; +import 'package:siro_maps/data/repositories/map_saas_repository.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late TelemetryTrackingService telemetryService; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + telemetryService = TelemetryTrackingService.instance; + telemetryService.resetForTesting(); + DeviceFingerprintService.instance.setMockState( + fingerprint: 'siro_test_hw_unit_test_device_123', + ); + }); + + group('TelemetryTrackingService Sampling & Stationary Filter Tests', () { + test('Samples position only if at least 3 seconds have passed', () { + final baseTime = DateTime(2026, 9, 15, 12, 0, 0); + + // First point at T=0 + final p1 = telemetryService.recordPosition( + latitude: 31.9539, + longitude: 35.9106, + speedKmH: 45.0, + heading: 90.0, + now: baseTime, + ); + expect(p1, isTrue); + expect(telemetryService.bufferedPointCount, equals(1)); + + // Attempt second point after only 1 second (T=1s) -> Should be rejected + final p2 = telemetryService.recordPosition( + latitude: 31.9540, + longitude: 35.9107, + speedKmH: 46.0, + heading: 90.0, + now: baseTime.add(const Duration(seconds: 1)), + ); + expect(p2, isFalse); + expect(telemetryService.bufferedPointCount, equals(1)); + + // Attempt third point after 3 seconds (T=3s) -> Should be accepted + final p3 = telemetryService.recordPosition( + latitude: 31.9545, + longitude: 35.9110, + speedKmH: 48.0, + heading: 92.0, + now: baseTime.add(const Duration(seconds: 3)), + ); + expect(p3, isTrue); + expect(telemetryService.bufferedPointCount, equals(2)); + }); + + test('Smart stationary filter suppresses redundant points when vehicle is stopped', () { + final baseTime = DateTime(2026, 9, 15, 12, 0, 0); + + // Stop at intersection: initial point (speed = 0) + final initial = telemetryService.recordPosition( + latitude: 31.9500, + longitude: 35.9100, + speedKmH: 0.0, + heading: 0.0, + now: baseTime, + ); + expect(initial, isTrue); + expect(telemetryService.bufferedPointCount, equals(1)); + + // After 5 seconds: still at same spot (speed = 0, distance = 0) -> filter out + final duplicateAfter5s = telemetryService.recordPosition( + latitude: 31.9500, + longitude: 35.9100, + speedKmH: 0.0, + heading: 0.0, + now: baseTime.add(const Duration(seconds: 5)), + ); + expect(duplicateAfter5s, isFalse); + expect(telemetryService.bufferedPointCount, equals(1)); + + // After 35 seconds: still at same spot -> 30s heartbeat triggers! + final heartbeatAfter35s = telemetryService.recordPosition( + latitude: 31.9500, + longitude: 35.9100, + speedKmH: 0.0, + heading: 0.0, + now: baseTime.add(const Duration(seconds: 35)), + ); + expect(heartbeatAfter35s, isTrue); + expect(telemetryService.bufferedPointCount, equals(2)); + }); + }); + + group('Telemetry Batch Upload & Offline Buffer Tests', () { + test('Flushes batched telemetry to server in a single request', () async { + List receivedPayloadPoints = []; + + final mockClient = MockClient((request) async { + if (request.url.path.contains('/api/telemetry/batch')) { + final body = jsonDecode(request.body) as Map; + receivedPayloadPoints = body['points'] as List; + return http.Response(jsonEncode({'success': true, 'count': receivedPayloadPoints.length}), 201); + } + return http.Response('Not Found', 404); + }); + + final mockRepo = MapSaasRepository(client: mockClient); + + // Record 3 points spaced by 3 seconds + final baseTime = DateTime(2026, 9, 15, 12, 0, 0); + telemetryService.recordPosition( + latitude: 31.951, + longitude: 35.911, + speedKmH: 30, + heading: 45, + now: baseTime, + ); + telemetryService.recordPosition( + latitude: 31.952, + longitude: 35.912, + speedKmH: 35, + heading: 45, + now: baseTime.add(const Duration(seconds: 3)), + ); + telemetryService.recordPosition( + latitude: 31.953, + longitude: 35.913, + speedKmH: 40, + heading: 50, + now: baseTime.add(const Duration(seconds: 6)), + ); + + expect(telemetryService.bufferedPointCount, equals(3)); + + // Flush buffer to backend + final success = await telemetryService.flush(repository: mockRepo); + expect(success, isTrue); + expect(receivedPayloadPoints.length, equals(3)); + // Buffer should be empty after successful flush + expect(telemetryService.bufferedPointCount, equals(0)); + }); + + test('Retains points in offline buffer if batch upload encounters error', () async { + final offlineClient = MockClient((request) async { + return http.Response('Gateway Timeout', 504); + }); + + final mockRepo = MapSaasRepository(client: offlineClient); + + telemetryService.recordPosition( + latitude: 31.951, + longitude: 35.911, + speedKmH: 20, + heading: 10, + now: DateTime(2026, 9, 15, 12, 0, 0), + ); + + expect(telemetryService.bufferedPointCount, equals(1)); + + final success = await telemetryService.flush(repository: mockRepo); + expect(success, isFalse); + // Points must still be retained for next attempt + expect(telemetryService.bufferedPointCount, equals(1)); + }); + }); + + group('10-Day Periodic Updates & Maintenance Tests', () { + test('Skips update if less than 10 days since last check', () async { + final now = DateTime(2026, 9, 15, 12, 0, 0); + final lastCheck = now.subtract(const Duration(days: 4)); // Only 4 days ago + + SharedPreferences.setMockInitialValues({ + TelemetryTrackingService.prefKeyLastPeriodicCheck: lastCheck.millisecondsSinceEpoch, + }); + + final prefs = await SharedPreferences.getInstance(); + final executed = await telemetryService.checkPeriodicUpdates( + now: now, + mockPrefs: prefs, + ); + + expect(executed, isFalse); + }); + + test('Executes maintenance cycle if 10 or more days elapsed', () async { + final now = DateTime(2026, 9, 15, 12, 0, 0); + final lastCheck = now.subtract(const Duration(days: 12)); // 12 days ago + + SharedPreferences.setMockInitialValues({ + TelemetryTrackingService.prefKeyLastPeriodicCheck: lastCheck.millisecondsSinceEpoch, + }); + + final prefs = await SharedPreferences.getInstance(); + final executed = await telemetryService.checkPeriodicUpdates( + now: now, + mockPrefs: prefs, + ); + + expect(executed, isTrue); + final updatedTimestamp = prefs.getInt(TelemetryTrackingService.prefKeyLastPeriodicCheck); + expect(updatedTimestamp, equals(now.millisecondsSinceEpoch)); + }); + }); +}