From 08c738dc6977bf8f35ccff28de2d6db8be0583cb Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 1 Jul 2026 08:18:31 +0700 Subject: [PATCH] feat(settings): add extension verification browser mode preference Let users choose whether signed-session verification opens in the external browser or in-app browser first, with automatic fallback to the other mode when launch fails. --- lib/models/settings.dart | 22 ++--- lib/models/settings.g.dart | 3 + lib/providers/download_queue_provider.dart | 1 + lib/providers/settings_provider.dart | 24 ++++++ lib/providers/track_provider.dart | 3 + lib/screens/settings/app_settings_page.dart | 90 +++++++++++++++++++++ lib/utils/extension_auth_launcher.dart | 17 +++- 7 files changed, 147 insertions(+), 13 deletions(-) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b209e6ed..7835b121 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -43,14 +43,15 @@ class AppSettings { final String singleFilenameFormat; final String albumFolderStructure; final bool showExtensionStore; + final String + extensionVerificationBrowserMode; // 'external_first' or 'in_app_first' final String locale; final String lyricsMode; final String tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128' final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE - final bool - autoExportFailedDownloads; + final bool autoExportFailedDownloads; final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only final bool @@ -66,16 +67,13 @@ class AppSettings { final String localLibraryPath; final String localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark - final bool - localLibraryShowDuplicates; + final bool localLibraryShowDuplicates; final String localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly' - final bool - hasCompletedTutorial; + final bool hasCompletedTutorial; - final List - lyricsProviders; + final List lyricsProviders; final bool lyricsIncludeTranslationNetease; // Append translated lyrics (Netease) final bool @@ -90,8 +88,7 @@ class AppSettings { final String lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') - final bool - deduplicateDownloads; + final bool deduplicateDownloads; final bool saveDownloadHistory; final String playerMode; @@ -132,6 +129,7 @@ class AppSettings { this.singleFilenameFormat = '{title} - {artist}', this.albumFolderStructure = 'artist_album', this.showExtensionStore = true, + this.extensionVerificationBrowserMode = 'external_first', this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', @@ -199,6 +197,7 @@ class AppSettings { String? singleFilenameFormat, String? albumFolderStructure, bool? showExtensionStore, + String? extensionVerificationBrowserMode, String? locale, String? lyricsMode, String? tidalHighFormat, @@ -274,6 +273,9 @@ class AppSettings { singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, + extensionVerificationBrowserMode: + extensionVerificationBrowserMode ?? + this.extensionVerificationBrowserMode, locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 5228d009..a8f9b7f4 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, + extensionVerificationBrowserMode: + json['extensionVerificationBrowserMode'] as String? ?? 'external_first', locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', @@ -125,6 +127,7 @@ Map _$AppSettingsToJson( 'singleFilenameFormat': instance.singleFilenameFormat, 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, + 'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode, 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 21ffd957..2583478a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2098,6 +2098,7 @@ class DownloadQueueNotifier extends Notifier { final opened = await openPendingExtensionVerification( normalizedExtensionId, + browserMode: ref.read(settingsProvider).extensionVerificationBrowserMode, ); if (!opened) return false; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 4ebbc62d..16c3390d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -28,6 +28,10 @@ class SettingsNotifier extends Notifier { 'album', 'playlist', }; + static const Set _extensionVerificationBrowserModeValues = { + 'external_first', + 'in_app_first', + }; final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @@ -79,6 +83,10 @@ class SettingsNotifier extends Notifier { defaultSearchTab: sanitizedDefaultSearchTab, defaultService: loaded.defaultService, searchProvider: loaded.searchProvider, + extensionVerificationBrowserMode: + _normalizeExtensionVerificationBrowserMode( + loaded.extensionVerificationBrowserMode, + ), ); await _runMigrations(prefs); @@ -270,6 +278,14 @@ class SettingsNotifier extends Notifier { return 'all'; } + String _normalizeExtensionVerificationBrowserMode(String value) { + final normalized = value.trim().toLowerCase(); + if (_extensionVerificationBrowserModeValues.contains(normalized)) { + return normalized; + } + return 'external_first'; + } + String? _sanitizeRetiredBuiltInProviderId(String? providerId) { final normalized = providerId?.trim().toLowerCase(); if (normalized == null || normalized.isEmpty) return providerId; @@ -557,6 +573,14 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setExtensionVerificationBrowserMode(String mode) { + state = state.copyWith( + extensionVerificationBrowserMode: + _normalizeExtensionVerificationBrowserMode(mode), + ); + _saveSettings(); + } + void setLocale(String locale) { state = state.copyWith(locale: locale); _saveSettings(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index cf38b978..7f3a9b02 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -686,6 +686,9 @@ class TrackNotifier extends Notifier { try { final opened = await openPendingExtensionVerification( normalizedExtensionId, + browserMode: ref + .read(settingsProvider) + .extensionVerificationBrowserMode, ); if (!opened) return false; diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index 5e746686..29610c1c 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -75,6 +75,12 @@ class AppSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setShowExtensionStore(v), ), + _VerificationBrowserModeSelector( + currentMode: settings.extensionVerificationBrowserMode, + onChanged: (mode) => ref + .read(settingsProvider.notifier) + .setExtensionVerificationBrowserMode(mode), + ), SettingsSwitchItem( icon: Icons.system_update, title: context.l10n.optionsCheckUpdates, @@ -374,6 +380,90 @@ class _UpdateChannelSelector extends StatelessWidget { } } +class _VerificationBrowserModeSelector extends StatelessWidget { + final String currentMode; + final ValueChanged onChanged; + + const _VerificationBrowserModeSelector({ + required this.currentMode, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final normalizedMode = currentMode == 'in_app_first' + ? 'in_app_first' + : 'external_first'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.open_in_browser, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Verification browser', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 2), + Text( + normalizedMode == 'external_first' + ? 'Open challenges in the default browser first' + : 'Open challenges in the in-app browser first', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + _ChannelChip( + label: 'External', + isSelected: normalizedMode == 'external_first', + onTap: () => onChanged('external_first'), + ), + const SizedBox(width: 8), + _ChannelChip( + label: 'In-app', + isSelected: normalizedMode == 'in_app_first', + onTap: () => onChanged('in_app_first'), + ), + ], + ), + ], + ), + ), + Divider( + height: 1, + thickness: 1, + indent: 56, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + class _ChannelChip extends StatelessWidget { final String label; final bool isSelected; diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index ca737406..fb903e01 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -27,7 +27,10 @@ bool _containsHttpStatusCode(String message, String code) { message.contains('$code;'); } -Future openPendingExtensionVerification(String extensionId) async { +Future openPendingExtensionVerification( + String extensionId, { + String browserMode = 'external_first', +}) async { final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty) return false; @@ -41,9 +44,17 @@ Future openPendingExtensionVerification(String extensionId) async { final uri = Uri.tryParse(authUrl); if (uri == null) return false; - var launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + 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: LaunchMode.externalApplication); + launched = await launchUrl(uri, mode: fallbackMode); } if (launched) {