193 lines
5.5 KiB
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(),
|
|
};
|
|
}
|