diff --git a/lib/app.dart b/lib/app.dart index 41c9d0fe..05e145ad 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart'; import 'package:spotiflac_android/screens/setup_screen.dart'; import 'package:spotiflac_android/screens/tutorial_screen.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; @@ -28,6 +29,7 @@ final _routerProvider = Provider((ref) { } return GoRouter( + navigatorKey: AppNavigationService.rootNavigatorKey, initialLocation: initialLocation, routes: [ GoRoute(path: '/', builder: (context, state) => const MainShell()), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 2583478a..a6b46424 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2096,14 +2096,31 @@ class DownloadQueueNotifier extends Notifier { ), ); - final opened = await openPendingExtensionVerification( - normalizedExtensionId, - browserMode: ref.read(settingsProvider).extensionVerificationBrowserMode, - ); - if (!opened) return false; + final browserMode = ref + .read(settingsProvider) + .extensionVerificationBrowserMode; + Uri? authUri; + Timer? helpDialogTimer; - final event = await grantEventFuture; - return event.success; + try { + final opened = await openPendingExtensionVerification( + normalizedExtensionId, + browserMode: browserMode, + onAuthUri: (uri) => authUri = uri, + ); + if (!opened) return false; + + helpDialogTimer = scheduleExtensionVerificationHelpDialog( + normalizedExtensionId, + authUri, + browserMode: browserMode, + ); + + final event = await grantEventFuture; + return event.success; + } finally { + helpDialogTimer?.cancel(); + } } Future _handleVerificationRequiredDownload( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 7f3a9b02..bb2af2cd 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -683,15 +683,26 @@ class TrackNotifier extends Notifier { } }); + final browserMode = ref + .read(settingsProvider) + .extensionVerificationBrowserMode; + Uri? authUri; + Timer? helpDialogTimer; + try { final opened = await openPendingExtensionVerification( normalizedExtensionId, - browserMode: ref - .read(settingsProvider) - .extensionVerificationBrowserMode, + browserMode: browserMode, + onAuthUri: (uri) => authUri = uri, ); if (!opened) return false; + helpDialogTimer = scheduleExtensionVerificationHelpDialog( + normalizedExtensionId, + authUri, + browserMode: browserMode, + ); + final event = await grantCompleter.future.timeout( const Duration(minutes: 5), ); @@ -702,6 +713,7 @@ class TrackNotifier extends Notifier { ); return false; } finally { + helpDialogTimer?.cancel(); await grantSub.cancel(); } } diff --git a/lib/services/app_navigation_service.dart b/lib/services/app_navigation_service.dart new file mode 100644 index 00000000..4e6d39ae --- /dev/null +++ b/lib/services/app_navigation_service.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class AppNavigationService { + static final GlobalKey rootNavigatorKey = + GlobalKey(); + + const AppNavigationService._(); +} diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index fb903e01..837f13f7 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -1,3 +1,9 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -30,6 +36,7 @@ bool _containsHttpStatusCode(String message, String code) { Future openPendingExtensionVerification( String extensionId, { String browserMode = 'external_first', + void Function(Uri authUri)? onAuthUri, }) async { final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty) return false; @@ -43,19 +50,9 @@ Future openPendingExtensionVerification( final uri = Uri.tryParse(authUrl); if (uri == null) return false; + onAuthUri?.call(uri); - final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first'; - final firstMode = preferInApp - ? LaunchMode.inAppBrowserView - : LaunchMode.externalApplication; - final fallbackMode = preferInApp - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView; - - var launched = await launchUrl(uri, mode: firstMode); - if (!launched) { - launched = await launchUrl(uri, mode: fallbackMode); - } + final launched = await _launchVerificationUrl(uri, browserMode); if (launched) { _log.i('Opened verification challenge for $normalizedExtensionId'); @@ -63,6 +60,12 @@ Future openPendingExtensionVerification( _log.w( 'Could not open verification challenge for $normalizedExtensionId', ); + return showExtensionVerificationHelpDialog( + normalizedExtensionId, + uri, + browserMode: browserMode, + immediateFailure: true, + ); } return launched; } catch (e) { @@ -72,3 +75,119 @@ Future openPendingExtensionVerification( return false; } } + +Timer? scheduleExtensionVerificationHelpDialog( + String extensionId, + Uri? authUri, { + String browserMode = 'external_first', + Duration delay = const Duration(seconds: 20), +}) { + final normalizedExtensionId = extensionId.trim(); + if (normalizedExtensionId.isEmpty || authUri == null) return null; + + return Timer(delay, () { + unawaited( + showExtensionVerificationHelpDialog( + normalizedExtensionId, + authUri, + browserMode: browserMode, + ), + ); + }); +} + +Future showExtensionVerificationHelpDialog( + String extensionId, + Uri authUri, { + String browserMode = 'external_first', + bool immediateFailure = false, +}) async { + final context = AppNavigationService.rootNavigatorKey.currentContext; + if (context == null) { + _log.w('Cannot show verification help dialog without root context'); + return false; + } + + final l10n = context.l10n; + final title = immediateFailure + ? l10n.extensionVerificationHelpTitleManual + : l10n.extensionVerificationHelpTitleWaiting; + final message = immediateFailure + ? l10n.extensionVerificationHelpMessageManual + : l10n.extensionVerificationHelpMessageWaiting; + + await showDialog( + context: context, + useRootNavigator: true, + builder: (dialogContext) { + final dialogL10n = dialogContext.l10n; + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message), + const SizedBox(height: 16), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(dialogContext).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + authUri.toString(), + maxLines: 4, + minLines: 1, + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogL10n.extensionVerificationClose), + ), + TextButton.icon( + icon: const Icon(Icons.copy), + label: Text(dialogL10n.extensionVerificationCopyLink), + onPressed: () { + Clipboard.setData(ClipboardData(text: authUri.toString())); + ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar( + SnackBar( + content: Text(dialogL10n.extensionVerificationLinkCopied), + ), + ); + }, + ), + FilledButton.icon( + icon: const Icon(Icons.open_in_browser), + label: Text(dialogL10n.extensionVerificationOpenBrowser), + onPressed: () { + unawaited(_launchVerificationUrl(authUri, browserMode)); + }, + ), + ], + ); + }, + ); + return true; +} + +Future _launchVerificationUrl(Uri uri, String browserMode) async { + final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first'; + final firstMode = preferInApp + ? LaunchMode.inAppBrowserView + : LaunchMode.externalApplication; + final fallbackMode = preferInApp + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView; + + var launched = await launchUrl(uri, mode: firstMode); + if (!launched) { + launched = await launchUrl(uri, mode: fallbackMode); + } + return launched; +}