Files
jinzhi/lib/common/router/deep_link_service.dart

193 lines
5.5 KiB
Dart

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<DeepLinkEvent> _pending = [];
static final Set<String> _pendingEventIds = {};
static final Map<String, DateTime> _seenEventIds = {};
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
_environmentHosts.split(','),
);
static Set<String> _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<String> 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<String> _normalizedHosts(Iterable<String> hosts) => {
for (final host in hosts)
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
};
}