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 55830beee8
commit ce896df30a
86 changed files with 3414 additions and 341 deletions
@@ -13,6 +13,7 @@ class ApiConstants {
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 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';
// 50 km Radius Threshold (Strictly as specified)
@@ -20,6 +20,10 @@ class AppColors {
static const Color tacticalNavy = Color(0xFF0B192C);
static const Color tacticalEmerald = Color(0xFF059669);
static const Color sovereignGold = Color(0xFFD97706);
static const Color urukGold = Color(0xFFD4AF37);
static const Color urukGoldLight = Color(0xFFFFF9E6);
static const Color urukGoldDark = Color(0xFF8C6B1C);
static const Color urukGoldBorder = Color(0x40D4AF37);
static const Color coralDanger = Color(0xFFDC2626);
// Borders & Dividers
@@ -0,0 +1,290 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
/// Parsed destination target from an external deep link or navigation intent
class DeepLinkTarget {
final LatLng destination;
final String title;
final bool autoStartNavigation;
DeepLinkTarget({
required this.destination,
required this.title,
this.autoStartNavigation = false,
});
@override
String toString() =>
'DeepLinkTarget(dest: ${destination.latitude},${destination.longitude}, title: $title, autoNav: $autoStartNavigation)';
}
class DeepLinkService {
DeepLinkService._();
static final DeepLinkService instance = DeepLinkService._();
static const _channel = MethodChannel('com.siro.siro_maps/deep_link');
final _targetController = StreamController<DeepLinkTarget>.broadcast();
Stream<DeepLinkTarget> get targetStream => _targetController.stream;
DeepLinkTarget? _initialTarget;
DeepLinkTarget? get initialTarget => _initialTarget;
bool _initialized = false;
void init() {
if (_initialized) return;
_initialized = true;
_channel.setMethodCallHandler(_handleMethodCall);
_checkInitialLink();
}
void clearInitialTarget() {
_initialTarget = null;
}
Future<void> _checkInitialLink() async {
try {
final String? initialLink = await _channel.invokeMethod<String>('getInitialLink');
if (initialLink != null && initialLink.isNotEmpty) {
final target = parseUri(initialLink);
if (target != null) {
_initialTarget = target;
_targetController.add(target);
}
}
} catch (e) {
debugPrint('⚠️ [DeepLinkService] Failed to get initial link: $e');
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method == 'onDeepLink') {
final String? url = call.arguments['url']?.toString();
if (url != null && url.isNotEmpty) {
final target = parseUri(url);
if (target != null) {
_targetController.add(target);
}
}
}
}
/// Universal parser supporting:
/// - geo:31.95,35.91
/// - geo:31.95,35.91?q=31.95,35.91(City%20Mall)
/// - geo:0,0?q=31.95,35.91
/// - siromaps://navigate?lat=31.95&lng=35.91&title=...
/// - siromaps://route?dlat=31.95&dlng=35.91&title=...
/// - google.navigation:q=31.95,35.91
/// - https://maps.google.com/?q=31.95,35.91
/// - https://www.google.com/maps/dir/?destination=31.95,35.91
/// - https://maps.siro.app/navigate?lat=31.95&lng=35.91
DeepLinkTarget? parseUri(String uriString) {
try {
final raw = uriString.trim();
if (raw.isEmpty) return null;
// ── 1. Standard "geo:" URI (Android / WhatsApp / SMS / taxi apps) ──
if (raw.startsWith('geo:')) {
return _parseGeoUri(raw);
}
// ── 2. "google.navigation:" URI ──
if (raw.startsWith('google.navigation:')) {
return _parseGoogleNavigationUri(raw);
}
// ── 3. Custom Scheme "siromaps://" or "https://" ──
final uri = _safeParseUri(raw);
if (uri == null) return null;
final scheme = uri.scheme.toLowerCase();
if (scheme == 'siromaps') {
final bool isNavigate = uri.host == 'navigate' || uri.path == '/navigate';
final latStr = uri.queryParameters['lat'] ?? uri.queryParameters['dlat'];
final lngStr = uri.queryParameters['lng'] ?? uri.queryParameters['dlng'];
final title = uri.queryParameters['title'] ??
uri.queryParameters['label'] ??
uri.queryParameters['dname'] ??
'وجهة محددة';
if (latStr != null && lngStr != null) {
final lat = double.tryParse(latStr);
final lng = double.tryParse(lngStr);
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return DeepLinkTarget(
destination: LatLng(lat, lng),
title: _safeDecode(title),
autoStartNavigation: isNavigate,
);
}
}
}
// ── 4. Web URLs (Google Maps / Siro Web Links) ──
if (scheme == 'http' || scheme == 'https') {
// e.g. maps.siro.app or map-saas.intaleqapp.com
if (uri.host.contains('siro') || uri.host.contains('intaleqapp')) {
final isNavigate = uri.path.contains('navigate');
final latStr = uri.queryParameters['lat'] ?? uri.queryParameters['dlat'];
final lngStr = uri.queryParameters['lng'] ?? uri.queryParameters['dlng'];
final title = uri.queryParameters['title'] ?? uri.queryParameters['label'] ?? 'وجهة محددة';
if (latStr != null && lngStr != null) {
final lat = double.tryParse(latStr);
final lng = double.tryParse(lngStr);
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return DeepLinkTarget(
destination: LatLng(lat, lng),
title: _safeDecode(title),
autoStartNavigation: isNavigate,
);
}
}
}
// e.g. maps.google.com or google.com/maps
if (uri.host.contains('google.com') || uri.host.contains('goo.gl')) {
final q = uri.queryParameters['q'] ??
uri.queryParameters['destination'] ??
uri.queryParameters['daddr'];
if (q != null) {
final coords = _extractLatLngFromString(q);
if (coords != null) {
return DeepLinkTarget(
destination: coords,
title: 'وجهة من خرائط جوجل',
autoStartNavigation: uri.queryParameters.containsKey('dirflg') ||
uri.path.contains('dir'),
);
}
}
}
}
} catch (e) {
debugPrint('⚠️ [DeepLinkService] Parse error on "$uriString": $e');
}
return null;
}
DeepLinkTarget? _parseGeoUri(String raw) {
final withoutScheme = raw.substring(4); // remove "geo:"
final parts = withoutScheme.split('?');
final baseCoords = parts[0].trim();
String? queryPart = parts.length > 1 ? parts[1] : null;
LatLng? destination;
String title = 'وجهة محددة';
// 1. Check if queryPart has q=...
if (queryPart != null) {
final qIndex = queryPart.indexOf('q=');
if (qIndex != -1) {
String qVal = queryPart.substring(qIndex + 2);
final ampIndex = qVal.indexOf('&');
if (ampIndex != -1) qVal = qVal.substring(0, ampIndex);
// Check if label is in parentheses: e.g. 31.95,35.91(City Mall)
final parenStart = qVal.indexOf('(');
final parenEnd = qVal.lastIndexOf(')');
if (parenStart != -1 && parenEnd != -1 && parenEnd > parenStart) {
final label = qVal.substring(parenStart + 1, parenEnd);
title = _safeDecode(label);
qVal = qVal.substring(0, parenStart);
}
final coords = _extractLatLngFromString(qVal);
if (coords != null) {
destination = coords;
}
}
}
// 2. If no valid coordinates in query, check base coords (e.g. geo:31.95,35.91)
if (destination == null && baseCoords.isNotEmpty) {
final coords = _extractLatLngFromString(baseCoords);
if (coords != null && (coords.latitude != 0.0 || coords.longitude != 0.0)) {
destination = coords;
}
}
if (destination != null) {
return DeepLinkTarget(
destination: destination,
title: title,
autoStartNavigation: false,
);
}
return null;
}
DeepLinkTarget? _parseGoogleNavigationUri(String raw) {
// google.navigation:q=31.95,35.91&mode=d
final qIndex = raw.indexOf('q=');
if (qIndex == -1) return null;
var qVal = raw.substring(qIndex + 2);
final ampIndex = qVal.indexOf('&');
if (ampIndex != -1) qVal = qVal.substring(0, ampIndex);
final coords = _extractLatLngFromString(qVal);
if (coords != null) {
return DeepLinkTarget(
destination: coords,
title: 'وجهة ملاحة',
autoStartNavigation: true, // navigation intent explicitly requests starting directions
);
}
return null;
}
LatLng? _extractLatLngFromString(String text) {
try {
final clean = text.trim();
final split = clean.split(',');
if (split.length >= 2) {
final lat = double.tryParse(split[0].trim());
final lng = double.tryParse(split[1].trim());
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return LatLng(lat, lng);
}
}
} catch (_) {}
return null;
}
bool _isValidCoordinate(double lat, double lng) {
return lat >= -90.0 && lat <= 90.0 && lng >= -180.0 && lng <= 180.0;
}
static String _safeDecode(String text) {
var s = text.replaceAll('+', ' ').replaceAll('%20', ' ');
try {
return Uri.decodeComponent(s);
} catch (_) {
try {
return Uri.decodeFull(s);
} catch (_) {
return s;
}
}
}
static Uri? _safeParseUri(String raw) {
final clean = raw.trim();
if (clean.isEmpty) return null;
try {
return Uri.parse(clean);
} catch (_) {
try {
return Uri.parse(Uri.encodeFull(clean));
} catch (_) {
return null;
}
}
}
}
@@ -0,0 +1,253 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/api_constants.dart';
/// Frictionless User Identity & Authentication via Hardware Device Fingerprint.
/// Generates a deterministic, hardware-backed identity and provisions a dedicated,
/// isolated consumer API key from the MapSaaS backend.
/// Saves credentials securely in FlutterSecureStorage (Keychain / Keystore).
class DeviceFingerprintService {
DeviceFingerprintService._();
static final DeviceFingerprintService instance = DeviceFingerprintService._();
static const MethodChannel _hardwareChannel =
MethodChannel('com.siro.siro_maps/device_hardware');
static const String _secureKeyApiKey = 'siro_provisioned_api_key';
static const String _secureKeyFingerprint = 'siro_hardware_fingerprint';
static const String _secureKeyPlan = 'siro_provisioned_plan';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
String? _fingerprintId;
String? _provisionedApiKey;
String? _plan;
int _rateLimit = 60;
String? _hardwareId;
String? _deviceModel;
String? _deviceBrand;
String? _osVersion;
bool _isInitialized = false;
String get fingerprintId =>
_fingerprintId ?? 'siro_${Platform.operatingSystem}_anonymous';
String get shortFingerprint {
if (_fingerprintId == null) return 'GUEST';
final parts = _fingerprintId!.split('_');
final raw = parts.length > 2 ? parts.last : _fingerprintId!;
return raw.length > 8 ? raw.substring(0, 8).toUpperCase() : raw.toUpperCase();
}
/// Active API Key: Dedicated per-device key if provisioned, or the embedded fallback key.
String get activeApiKey => _provisionedApiKey ?? ApiConstants.mapSaasKey;
bool get isDedicatedKeyActive => _provisionedApiKey != null && _provisionedApiKey!.isNotEmpty;
String? get deviceModel => _deviceModel;
String? get deviceBrand => _deviceBrand;
String? get osVersion => _osVersion;
String? get plan => _plan;
int get rateLimit => _rateLimit;
/// Initializes hardware device extraction, reads secure storage, and provisions dedicated key.
Future<void> init({http.Client? httpClient}) async {
if (_isInitialized) return;
try {
// 1. Extract physical hardware telemetry
await _extractHardwareTelemetry();
// 2. Read stored API Key & Fingerprint from secure storage (with SharedPreferences fallback)
await _loadPersistedCredentials();
// 3. If no dedicated API key exists, provision one from MapSaaS backend
if (_provisionedApiKey == null || _provisionedApiKey!.isEmpty) {
await provisionDedicatedApiKey(httpClient: httpClient);
}
_isInitialized = true;
debugPrint('🔑 [DeviceFingerprintService] Hardware Fingerprint: $fingerprintId');
debugPrint('🛡️ [DeviceFingerprintService] Active Key: ${activeApiKey.substring(0, 10)}... (Dedicated: $isDedicatedKeyActive)');
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] Initialization error: $e');
_fingerprintId ??= 'siro_${Platform.operatingSystem}_fallback_${DateTime.now().millisecondsSinceEpoch}';
}
}
/// Extracts deterministic physical device information (Hardware ID, Board, Brand, Model)
Future<void> _extractHardwareTelemetry() async {
String hardwareSeed = '';
try {
if (Platform.isAndroid || Platform.isIOS) {
final dynamic rawInfo =
await _hardwareChannel.invokeMethod('getHardwareInfo');
if (rawInfo is Map) {
final nativeInfo = Map<String, dynamic>.from(rawInfo);
_hardwareId = nativeInfo['hardwareId']?.toString();
_deviceModel = nativeInfo['model']?.toString();
_deviceBrand = nativeInfo['brand']?.toString();
final manufacturer = nativeInfo['manufacturer']?.toString() ?? '';
final board = nativeInfo['board']?.toString() ?? '';
final hardware = nativeInfo['hardware']?.toString() ?? '';
if (Platform.isAndroid) {
// Android Pure Hardware Seed: ANDROID_ID + Brand + Manufacturer + Model + Hardware + Board
hardwareSeed = '${_hardwareId}_${_deviceBrand}_${manufacturer}_${_deviceModel}_${hardware}_$board';
} else {
// iOS Pure Hardware Seed: identifierForVendor + Machine Architecture
hardwareSeed = '${_hardwareId}_$_deviceModel';
}
}
}
} catch (e) {
debugPrint('ℹ️ [DeviceFingerprintService] Native hardware channel: $e');
}
if (hardwareSeed.isEmpty) {
// Fallback for macOS, desktop, or headless unit tests
hardwareSeed = 'device_${Platform.operatingSystem}_${Platform.localHostname}';
}
final digest = sha256.convert(utf8.encode(hardwareSeed)).toString();
_fingerprintId = 'siro_${Platform.operatingSystem}_hw_${digest.substring(0, 24)}';
}
/// Loads credentials from FlutterSecureStorage with fallback to SharedPreferences
Future<void> _loadPersistedCredentials() async {
try {
final savedFp = await _readSecure(_secureKeyFingerprint);
final savedKey = await _readSecure(_secureKeyApiKey);
final savedPlan = await _readSecure(_secureKeyPlan);
if (savedFp != null && savedFp.isNotEmpty) {
_fingerprintId = savedFp;
}
if (savedKey != null && savedKey.isNotEmpty) {
_provisionedApiKey = savedKey;
}
if (savedPlan != null && savedPlan.isNotEmpty) {
_plan = savedPlan;
}
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] SecureStorage read error, using local fallback: $e');
}
}
/// Contacts MapSaaS Backend to provision a dedicated mobile consumer API key
Future<bool> provisionDedicatedApiKey({http.Client? httpClient}) async {
final client = httpClient ?? http.Client();
final url = Uri.parse(ApiConstants.mapSaasDeviceProvision);
final payload = {
'deviceFingerprint': fingerprintId,
'hardwareId': _hardwareId,
'brand': _deviceBrand,
'model': _deviceModel,
'platform': Platform.operatingSystem,
'osVersion': _osVersion,
'appVersion': '2.4.0-pro',
};
try {
debugPrint('🛰️ [DeviceFingerprintService] Provisioning dedicated key from $url...');
final response = await client
.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 8));
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final key = data['apiKey'] as String?;
final plan = data['plan'] as String?;
final rate = data['rateLimit'] as int?;
if (key != null && key.isNotEmpty) {
_provisionedApiKey = key;
_plan = plan ?? 'FREE';
_rateLimit = rate ?? 60;
// Save into FlutterSecureStorage
await _writeSecure(_secureKeyApiKey, key);
await _writeSecure(_secureKeyFingerprint, fingerprintId);
await _writeSecure(_secureKeyPlan, _plan!);
debugPrint('✅ [DeviceFingerprintService] Dedicated key provisioned successfully: $key (Rate: $_rateLimit req/min)');
return true;
}
} else {
debugPrint('⚠️ [DeviceFingerprintService] Provisioning returned status ${response.statusCode}: ${response.body}');
}
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] Provisioning failed (offline or network error): $e');
debugPrint('ℹ️ [DeviceFingerprintService] Operating with resilient embedded key fallback.');
} finally {
if (httpClient == null) {
client.close();
}
}
return false;
}
/// Safe helper to read from FlutterSecureStorage with fallback
Future<String?> _readSecure(String key) async {
try {
return await _secureStorage.read(key: key);
} catch (_) {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}
}
/// Safe helper to write to FlutterSecureStorage with fallback
Future<void> _writeSecure(String key, String value) async {
try {
await _secureStorage.write(key: key, value: value);
} catch (_) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
}
/// Resets or overrides key for testing purposes
@visibleForTesting
void setMockState({
String? fingerprint,
String? apiKey,
String? plan,
int? rateLimit,
bool clearApiKey = false,
}) {
if (fingerprint != null) _fingerprintId = fingerprint;
if (clearApiKey) {
_provisionedApiKey = null;
} else if (apiKey != null) {
_provisionedApiKey = apiKey;
}
if (plan != null) _plan = plan;
if (rateLimit != null) _rateLimit = rateLimit;
_isInitialized = true;
}
/// Headers automatically injected into every MapSaaS request
Map<String, String> get headers => {
'x-device-fingerprint': fingerprintId,
'x-device-platform': Platform.operatingSystem,
'x-device-model': _deviceModel ?? 'Unknown',
'x-client-version': '2.4.0-pro',
'x-api-key': activeApiKey,
};
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class PipService {
PipService._();
static final PipService instance = PipService._();
static const _channel = MethodChannel('com.siro.siro_maps/pip');
final ValueNotifier<bool> isInPipMode = ValueNotifier<bool>(false);
bool _initialized = false;
void init() {
if (_initialized) return;
_initialized = true;
if (Platform.isAndroid) {
_channel.setMethodCallHandler(_handleMethodCall);
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method == 'onPipChanged') {
final bool inPip = call.arguments['isInPip'] as bool? ?? false;
isInPipMode.value = inPip;
debugPrint('📺 [PipService] Picture-in-Picture mode changed: $inPip');
}
}
/// Check if Picture-in-Picture mode is supported (native on Android, in-app floating HUD on iOS)
Future<bool> isPipSupported() async {
if (Platform.isAndroid) {
try {
final bool? supported = await _channel.invokeMethod<bool>('isPipSupported');
return supported ?? true;
} catch (_) {
return true;
}
}
// iOS and other platforms support in-app floating PiP HUD
return true;
}
/// Programmatically enter Picture-in-Picture mode
/// On Android: triggers native OS activity PiP window.
/// On iOS / others: triggers responsive in-app floating Mini-HUD.
Future<bool> enterPictureInPicture() async {
if (Platform.isAndroid) {
try {
final bool? success = await _channel.invokeMethod<bool>('enterPictureInPicture');
if (success == true) {
isInPipMode.value = true;
return true;
}
} catch (e) {
debugPrint('⚠️ [PipService] Native Android PiP failed, falling back to In-App PiP: $e');
}
}
// On iOS and graceful fallback: activate In-App PiP mode
isInPipMode.value = true;
debugPrint('📺 [PipService] In-App PiP mode activated');
return true;
}
/// Exit Picture-in-Picture mode and restore full HUD
Future<void> exitPictureInPicture() async {
isInPipMode.value = false;
debugPrint('📺 [PipService] Exited PiP mode');
}
/// Toggle Picture-in-Picture mode
Future<void> togglePip() async {
if (isInPipMode.value) {
await exitPictureInPicture();
} else {
await enterPictureInPicture();
}
}
/// Inform native platform whether turn-by-turn navigation is currently active
/// (enables automatic PiP on home swipe in Android 12+)
Future<void> setNavigating(bool isNavigating) async {
if (Platform.isAndroid) {
try {
await _channel.invokeMethod('setNavigating', {'isNavigating': isNavigating});
} catch (_) {}
}
}
}
@@ -48,4 +48,39 @@ class ArabicSearchNormalizer {
return false;
}
static final Set<String> _categoryKeywords = {
// Single-word categories
'مسجد', 'جامع', 'مصلى', 'صلاة',
'مطعم', 'مطاعم', 'شاورما', 'مشاوي', 'سناك', 'فلافل', 'برغر',
'دكان', 'دكاكين', 'سوبرماركت', 'ماركت', 'بقالة', 'بقال', 'تموين', 'هايبر',
'مخبز', 'مخابز', 'افران', 'فرن', 'معجنات', 'حلويات', 'كعك',
'كافيه', 'كوفي', 'مقهى', 'قهوة', 'كافتيريا',
'صيدلية', 'صيدليات', 'دواء',
'مستشفى', 'مستشفيات', 'عيادة', 'عيادات',
'كازية', 'بنزين', 'وقود', 'غاز',
'بنك', 'بنوك', 'صراف', 'مصرف',
'مدرسة', 'مدارس', 'جامعة', 'جامعات', 'كلية', 'روضة',
'فندق', 'فنادق', 'منتجع',
// Multi-word compound category phrases
'محطة بنزين',
'محطة وقود',
'محطة غاز',
'سوبر ماركت',
'ميني ماركت',
'شقق فندقية',
'مركز صحي',
'وجبات سريعة',
'صراف الي',
'كوفي شوب',
}.map((k) => normalize(k)).toSet();
/// Returns true if the query is asking for a general POI category
/// (e.g. "مطعم", "مخبز", "سوبرماركت", "محطة بنزين") rather than a specific proper name (e.g. "مطعم هاشم", "سيتي مول").
static bool isCategoryQuery(String query) {
final norm = normalize(query);
if (norm.isEmpty) return false;
return _categoryKeywords.contains(norm);
}
}