feat: add device authentication, place gates support, and PiP navigation features

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:26 +03:00
parent 279c9fa510
commit a8470eb76c
15 changed files with 652 additions and 37 deletions
+80
View File
@@ -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. بيانات تسجيل الدخول والوصول:
لا يتطلب التطبيق أي اسم مستخدم أو كلمة مرور. التطبيق يعمل فوراً بمجرد الفتح ويقوم بتوليد مفتاح وصول مشفر عبر بصمة الجهاز تلقائياً.
```
@@ -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.
```
+5 -1
View File
@@ -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")
+10
View File
@@ -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.**
@@ -3,12 +3,13 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Android Auto Navigation Permissions required by Google Play Console -->
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/>
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE"/>
<application
android:label="Uruk Map"
android:name="${applicationName}"
@@ -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();
}
}
+6 -6
View File
@@ -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:
+1 -1
View File
@@ -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'
+5 -8
View File
@@ -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);
});
});
}
@@ -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 {
@@ -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<dynamic> receivedPayloadPoints = [];
final mockClient = MockClient((request) async {
if (request.url.path.contains('/api/telemetry/batch')) {
final body = jsonDecode(request.body) as Map<String, dynamic>;
receivedPayloadPoints = body['points'] as List<dynamic>;
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));
});
});
}