import 'dart:convert'; enum DeepLinkSource { push, appLink, universalLink, customScheme } typedef DeepLinkNavigator = bool Function(String location); typedef DeepLinkAuthGuard = bool Function(String location); class DeepLinkEvent { const DeepLinkEvent({ required this.raw, required this.source, required this.receivedAt, this.eventId, }); final String raw; final DeepLinkSource source; final DateTime receivedAt; final String? eventId; } /// Source adapters feed one parser and a bounded FIFO. Navigation starts only /// after GoRouter has a context and the auth guard accepts the target. abstract final class DeepLinkService { static const maxPending = 20; static const expiry = Duration(minutes: 15); static const _environmentHosts = String.fromEnvironment( 'JINZHI_DEEP_LINK_HOSTS', ); static final List _pending = []; static final Set _pendingEventIds = {}; static final Map _seenEventIds = {}; static final Set _configuredHttpsHosts = _normalizedHosts( _environmentHosts.split(','), ); static Set _trustedHttpsHosts = {..._configuredHttpsHosts}; static DeepLinkNavigator? _navigator; static DeepLinkAuthGuard _authGuard = (_) => true; static int get pendingCount => _pending.length; static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push); static void handleUri(Uri uri) => enqueue( uri.toString(), source: uri.scheme.toLowerCase() == 'jinzhi' ? DeepLinkSource.customScheme : DeepLinkSource.universalLink, ); static void enqueue( String raw, { required DeepLinkSource source, DateTime? receivedAt, }) { final normalized = raw.trim(); if (normalized.isEmpty) return; final event = DeepLinkEvent( raw: normalized, source: source, receivedAt: receivedAt ?? DateTime.now(), eventId: _eventId(normalized), ); _discardExpired(event.receivedAt); _seenEventIds.removeWhere( (_, seenAt) => event.receivedAt.difference(seenAt) > expiry, ); if (event.eventId case final id? when _seenEventIds.containsKey(id)) return; if (_pending.length == maxPending) _removeAt(0); _pending.add(event); if (event.eventId case final id?) { _pendingEventIds.add(id); _seenEventIds[id] = event.receivedAt; } _drain(); } static void attachRouter( DeepLinkNavigator navigator, { DeepLinkAuthGuard? authGuard, }) { _navigator = navigator; _authGuard = authGuard ?? (_) => true; _drain(); } static void notifyAuthChanged() => _drain(); static void detachRouter() => _navigator = null; /// Universal/App Link domains are supplied by the release environment. An /// empty allowlist intentionally disables HTTPS deep links until the native /// association domain is confirmed. static void configureTrustedHttpsHosts(Iterable hosts) { _trustedHttpsHosts = _normalizedHosts(hosts); } static void resetForTest() { _pending.clear(); _pendingEventIds.clear(); _seenEventIds.clear(); _trustedHttpsHosts = {..._configuredHttpsHosts}; _navigator = null; _authGuard = (_) => true; } static void _drain() { final navigator = _navigator; if (navigator == null) return; _discardExpired(DateTime.now()); while (_pending.isNotEmpty) { final event = _pending.first; final target = _route(event.raw); if (target == null) { _removeAt(0); continue; } if (!_authGuard(target) || !navigator(target)) return; _removeAt(0); } } static void _discardExpired(DateTime now) { while (_pending.isNotEmpty && now.difference(_pending.first.receivedAt) > expiry) { _removeAt(0); } } static void _removeAt(int index) { final removed = _pending.removeAt(index); if (removed.eventId case final id?) _pendingEventIds.remove(id); } static String? _eventId(String raw) { try { final decoded = jsonDecode(raw); if (decoded is Map) { final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim(); if (id.isNotEmpty) return id; } } catch (_) {} final uri = Uri.tryParse(raw); final id = uri?.queryParameters['event_id']?.trim(); return id == null || id.isEmpty ? null : id; } static String? _route(String raw) { var source = raw.trim(); try { final decoded = jsonDecode(source); if (decoded is Map) { source = '${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}'; } } catch (_) {} final uri = Uri.tryParse(source); if (uri == null) return null; if (uri.scheme.toLowerCase() == 'jinzhi') { final path = uri.path.isNotEmpty ? uri.path : uri.host.isNotEmpty ? '/${uri.host}' : '/assets'; return _allowed(path) ? '$path${uri.hasQuery ? '?${uri.query}' : ''}' : null; } if (uri.scheme.toLowerCase() == 'https' && uri.port == 443 && uri.userInfo.isEmpty && _trustedHttpsHosts.contains(uri.host.toLowerCase()) && _allowed(uri.path)) { return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}'; } return null; } static bool _allowed(String path) => const { '/assets', '/market', '/news', '/profile', '/settings', }.contains(path); static Set _normalizedHosts(Iterable hosts) => { for (final host in hosts) if (host.trim().isNotEmpty) host.trim().toLowerCase(), }; }