From 39273edd85dd6daac1f0cd26c0772ecde915b8ab Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:26:40 +0700 Subject: [PATCH] fix: retry verification browser launch and failed searches --- lib/screens/home_tab.dart | 4 + lib/utils/extension_auth_launcher.dart | 15 +- test/extension_verification_browser_test.dart | 137 ++++++++++++++++++ test/home_search_tabs_test.dart | 52 +++++++ 4 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 test/extension_verification_browser_test.dart diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 88cab746..30c746d4 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -372,6 +372,10 @@ class _HomeTabState extends ConsumerState } void _onTrackStateChanged(TrackState? previous, TrackState next) { + if (next.error != null) { + // Failed requests must remain retryable without changing the search text. + _lastSearchQuery = null; + } if (!identical(previous?.tracks, next.tracks)) { // Home stays alive across tabs. Empty/loading/error branches can skip // result building, so release the previous search's derived lists here. diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index acf76886..6f51ea68 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -480,9 +480,16 @@ Future _launchVerificationUrl(Uri uri, String browserMode) async { ? LaunchMode.externalApplication : LaunchMode.inAppBrowserView; - var launched = await launchUrl(uri, mode: firstMode); - if (!launched) { - launched = await launchUrl(uri, mode: fallbackMode); + for (final mode in [firstMode, fallbackMode]) { + try { + if (await launchUrl(uri, mode: mode)) return true; + } on PlatformException catch (error) { + // Android reports some launch failures as exceptions rather than false. + // Keep the other mode and manual verification help available in both cases. + _log.w( + 'Verification browser launch failed (${mode.name}): ${error.code}', + ); + } } - return launched; + return false; } diff --git a/test/extension_verification_browser_test.dart b/test/extension_verification_browser_test.dart new file mode 100644 index 00000000..f8d7b0b1 --- /dev/null +++ b/test/extension_verification_browser_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final uri = Uri.parse('https://example.invalid/verify?state=sample'); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(backend, null); + }); + + for (final inAppFirst in [true, false]) { + final browserMode = inAppFirst ? 'in_app_first' : 'external_first'; + for (final throwsError in [true, false]) { + test( + 'browser fallback after launch failure: $browserMode, throws=$throwsError', + () async { + final modes = []; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'launch'); + final arguments = Map.from(call.arguments as Map); + expect(arguments['url'], uri.toString()); + modes.add(arguments['useWebView'] as bool); + if (modes.length == 1) { + if (throwsError) { + throw PlatformException(code: 'ACTIVITY_NOT_FOUND'); + } + return false; + } + return true; + }); + expect( + await launchExtensionAuthUrl(uri, browserMode: browserMode), + isTrue, + ); + expect(modes, [inAppFirst, !inAppFirst]); + }, + ); + } + } + + test( + 'both browser failures return false so manual help can be shown', + () async { + var launches = 0; + messenger.setMockMethodCallHandler(channel, (_) async { + launches++; + throw PlatformException(code: 'ACTIVITY_NOT_FOUND'); + }); + expect( + await launchExtensionAuthUrl(uri, browserMode: 'in_app_first'), + isFalse, + ); + expect(launches, 2); + }, + ); + + test( + 'direct search verifies through the fallback browser and retries once', + () async { + var searches = 0; + var pendingLookups = 0; + var launches = 0; + messenger.setMockMethodCallHandler(backend, (call) async { + switch (call.method) { + case 'customSearchWithExtension': + searches++; + if (searches == 1) { + throw PlatformException( + code: 'ERROR', + message: + "verification_required: extension 'sample-provider' needs signed-session verification: Error: VERIFY_REQUIRED", + ); + } + return [ + { + 'id': 'sample-track', + 'name': 'Sample track', + 'artist_name': 'Artist', + }, + ]; + case 'getExtensionPendingAuth': + pendingLookups++; + expect(call.arguments, {'extension_id': 'sample-provider'}); + return {'auth_url': uri.toString()}; + case 'completeExtensionSessionGrant': + return true; + default: + fail('Unexpected backend call: ${call.method}'); + } + }); + messenger.setMockMethodCallHandler(channel, (call) async { + launches++; + if (launches == 1) throw PlatformException(code: 'ACTIVITY_NOT_FOUND'); + unawaited( + PlatformBridge.completeExtensionSessionGrant( + 'sample-provider', + 'test-grant', + ), + ); + return true; + }); + final container = ProviderContainer( + overrides: [settingsProvider.overrideWith(_Settings.new)], + ); + addTearDown(container.dispose); + await container + .read(trackProvider.notifier) + .customSearch('sample-provider', 'fallback verification'); + final state = container.read(trackProvider); + expect(state.error, isNull); + expect(state.isLoading, isFalse); + expect(state.tracks.single.id, 'sample-track'); + expect(searches, 2); + expect(pendingLookups, 1); + expect(launches, 2); + }, + ); +} + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} diff --git a/test/home_search_tabs_test.dart b/test/home_search_tabs_test.dart index 9eda5e22..daf2434f 100644 --- a/test/home_search_tabs_test.dart +++ b/test/home_search_tabs_test.dart @@ -82,6 +82,48 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('failed verification search can retry the same query', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final search = _Search(failFirst: true); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith(_Explore.new), + downloadHistoryProvider.overrideWith(_History.new), + recentAccessProvider.overrideWith(_Recent.new), + trackProvider.overrideWith(() => search), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const _Tabs(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Search')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Example'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(search._requests, 1); + expect(find.text('Found artist'), findsNothing); + + await tester.tap(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(search._requests, 2); + expect(find.text('Found artist'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } class _Tabs extends StatefulWidget { @@ -185,6 +227,9 @@ class _Recent extends RecentAccessNotifier { } class _Search extends TrackNotifier { + _Search({bool failFirst = false}) : _failFirst = failFirst; + + final bool _failFirst; int _requests = 0; @override @@ -196,6 +241,13 @@ class _Search extends TrackNotifier { bool allowVerificationRetry = true, }) async { _requests++; + if (_failFirst && _requests == 1) { + state = const TrackState( + hasSearchText: true, + error: 'verification_required', + ); + return; + } state = const TrackState( hasSearchText: true, searchExtensionId: 'example',