feat(extensions): manual verification help when browser launch fails

Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.
This commit is contained in:
zarzet
2026-07-01 11:24:07 +07:00
parent 4d6f7d8b08
commit dcfd95f276
5 changed files with 180 additions and 22 deletions
+2
View File
@@ -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<GoRouter>((ref) {
}
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
+24 -7
View File
@@ -2096,14 +2096,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
),
);
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<bool> _handleVerificationRequiredDownload(
+15 -3
View File
@@ -683,15 +683,26 @@ class TrackNotifier extends Notifier<TrackState> {
}
});
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<TrackState> {
);
return false;
} finally {
helpDialogTimer?.cancel();
await grantSub.cancel();
}
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
class AppNavigationService {
static final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
const AppNavigationService._();
}
+131 -12
View File
@@ -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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<void>(
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<bool> _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;
}