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
@@ -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 (_) {}
}
}
}