From 277a7f24fafe3c5907e4d2fdc24aa7d48d945b65 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 11 Apr 2026 21:18:23 +0700 Subject: [PATCH] fix: stabilize shared extension link handling --- lib/providers/extension_provider.dart | 36 ++++++++++++++++++ lib/providers/track_provider.dart | 26 ++++++++++++- lib/screens/main_shell.dart | 15 -------- lib/services/share_intent_service.dart | 51 +++++++------------------- 4 files changed, 74 insertions(+), 54 deletions(-) diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index f6da1bb9..7a2a1613 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -483,6 +483,7 @@ class ExtensionState { class ExtensionNotifier extends Notifier { AppLifecycleListener? _appLifecycleListener; bool _cleanupInFlight = false; + Completer? _initializationCompleter; @override ExtensionState build() { @@ -520,6 +521,13 @@ class ExtensionNotifier extends Notifier { Future initialize(String extensionsDir, String dataDir) async { if (state.isInitialized) return; + if (_initializationCompleter != null) { + await _initializationCompleter!.future; + return; + } + + final completer = Completer(); + _initializationCompleter = completer; state = state.copyWith(isLoading: true, error: null); @@ -531,6 +539,8 @@ class ExtensionNotifier extends Notifier { error: null, ); _log.i('Extension system disabled on this platform'); + completer.complete(); + _initializationCompleter = null; return; } @@ -544,6 +554,32 @@ class ExtensionNotifier extends Notifier { } catch (e) { _log.e('Failed to initialize extension system: $e'); state = state.copyWith(isLoading: false, error: e.toString()); + } finally { + if (!completer.isCompleted) { + completer.complete(); + } + if (identical(_initializationCompleter, completer)) { + _initializationCompleter = null; + } + } + } + + Future waitForInitialization({ + Duration timeout = const Duration(seconds: 30), + }) async { + if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) { + return; + } + + final future = _initializationCompleter?.future; + if (future == null) { + return; + } + + try { + await future.timeout(timeout); + } on TimeoutException { + _log.w('Timed out waiting for extension initialization after $timeout'); } } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 726c8ca6..402cdfd7 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('TrackProvider'); +const _extensionInitRetryTimeout = Duration(seconds: 30); class TrackState { final List tracks; @@ -203,13 +204,36 @@ class TrackNotifier extends Notifier { bool _isRequestValid(int requestId) => requestId == _currentRequestId; + bool _usesBuiltInUrlResolver(String url) { + final normalized = url.toLowerCase(); + return normalized.contains('deezer.com') || + normalized.contains('deezer.page.link') || + normalized.contains('qobuz.com') || + normalized.startsWith('qobuzapp://') || + normalized.contains('tidal.com'); + } + Future fetchFromUrl(String url, {bool useDeezerFallback = true}) async { final requestId = ++_currentRequestId; state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { - final extensionHandler = await PlatformBridge.findURLHandler(url); + var extensionHandler = await PlatformBridge.findURLHandler(url); + if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) { + final extensionState = ref.read(extensionProvider); + if (!extensionState.isInitialized && extensionState.isLoading) { + _log.i( + 'Extension URL handlers not ready yet, waiting for initialization...', + ); + await ref + .read(extensionProvider.notifier) + .waitForInitialization(timeout: _extensionInitRetryTimeout); + if (!_isRequestValid(requestId)) return; + extensionHandler = await PlatformBridge.findURLHandler(url); + } + } + if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 79a186cc..a0f8150a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; @@ -94,20 +93,6 @@ class _MainShellState extends ConsumerState } Future _handleSharedUrl(String url) async { - // Wait for extensions to be initialized before handling URL - final extState = ref.read(extensionProvider); - if (!extState.isInitialized) { - _log.d('Waiting for extensions to initialize before handling URL...'); - for (int i = 0; i < 50; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - if (ref.read(extensionProvider).isInitialized) { - _log.d('Extensions initialized, proceeding with URL handling'); - break; - } - } - } - if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index b3a3fc96..bdff9df2 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -13,27 +13,9 @@ class ShareIntentService { static final RegExp _spotifyUriPattern = RegExp( r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+', ); - static final RegExp _spotifyUrlPattern = RegExp( - r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', - ); - - static final RegExp _deezerUrlPattern = RegExp( - r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?', - ); - static final RegExp _deezerShortLinkPattern = RegExp( - r'https?://deezer\.page\.link/[a-zA-Z0-9]+', - ); - - static final RegExp _tidalUrlPattern = RegExp( - r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?', - ); - - static final RegExp _ytMusicUrlPattern = RegExp( - r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?', - ); - - static final RegExp _youtubeUrlPattern = RegExp( - r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?', + static final RegExp _genericHttpUrlPattern = RegExp( + "https?://[^\\s<>\\\"']+", + caseSensitive: false, ); final _sharedUrlController = StreamController.broadcast(); @@ -99,24 +81,17 @@ class ShareIntentService { return uriMatch.group(0); } - final patterns = [ - _spotifyUrlPattern, - _deezerUrlPattern, - _deezerShortLinkPattern, - _tidalUrlPattern, - _ytMusicUrlPattern, - _youtubeUrlPattern, - ]; + // Keep share parsing generic and let manifest-based URL handlers decide + // which installed extension can handle the incoming link. + for (final match in _genericHttpUrlPattern.allMatches(text)) { + final rawUrl = match.group(0); + if (rawUrl == null || rawUrl.isEmpty) { + continue; + } - for (final pattern in patterns) { - final match = pattern.firstMatch(text); - if (match != null) { - final fullUrl = match.group(0)!; - if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) { - return fullUrl; - } - final queryIndex = fullUrl.indexOf('?'); - return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl; + final sanitizedUrl = rawUrl.replaceFirst(RegExp(r'[.,;:!?)\]}]+$'), ''); + if (sanitizedUrl.isNotEmpty) { + return sanitizedUrl; } }