From 1b0c28b91a6270eeea0405b80c3f99b7beed386b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:03:46 +0700 Subject: [PATCH] fix(download): route verification notifications to pending challenges --- .../com/zarz/spotiflac/DownloadService.kt | 13 ++- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 18 ++++ .../com/zarz/spotiflac/NativeWorkerPolicy.kt | 4 + .../VerificationNotificationIntent.kt | 23 +++++ .../zarz/spotiflac/NativeWorkerPolicyTest.kt | 11 +++ lib/l10n/arb/app_en.arb | 9 ++ lib/main.dart | 36 ++++++++ lib/providers/download_queue_provider.dart | 37 ++++---- ...download_queue_provider_native_worker.dart | 9 +- .../download_queue_provider_single_item.dart | 7 +- .../download_queue_provider_verification.dart | 40 +++++++- .../download_verification_retry_guard.dart | 3 + lib/services/notification_service.dart | 35 ++++++- lib/services/platform_bridge.dart | 13 +++ lib/services/verification_notification.dart | 91 +++++++++++++++++++ lib/utils/download_error_type.dart | 25 +++++ lib/utils/extension_auth_launcher.dart | 40 ++++---- test/extension_auth_launcher_test.dart | 85 +++++++++++++++-- .../extension_verification_feedback_test.dart | 67 ++++++++++++++ test/notification_service_test.dart | 48 +++++++++- test/verification_notification_test.dart | 72 +++++++++++++++ 21 files changed, 621 insertions(+), 65 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/VerificationNotificationIntent.kt create mode 100644 lib/services/verification_notification.dart create mode 100644 lib/utils/download_error_type.dart create mode 100644 test/extension_verification_feedback_test.dart create mode 100644 test/verification_notification_test.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index a780d632..b4ba074e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -1255,7 +1255,7 @@ class DownloadService : Service() { settingsJson = settingsJson, includeItems = true, ) - showNativeVerificationRequired() + showNativeVerificationRequired(request, result) updateNotification(0L, 0L) retryCurrentRequest = true } else { @@ -1622,7 +1622,7 @@ class DownloadService : Service() { // replace this same notification ID while owning // the interactive challenge; if Flutter is // suspended, the native alert remains visible. - showNativeVerificationRequired() + showNativeVerificationRequired(request, result) updateNotification(0L, 0L) retryCurrentRequest = true } else { @@ -1987,11 +1987,14 @@ class DownloadService : Service() { } } - private fun showNativeVerificationRequired() { + private fun showNativeVerificationRequired(request: NativeDownloadRequest, result: JSONObject) { + val extensionId = result.optString("service").trim().ifEmpty { + JSONObject(request.requestJson).optString("service").trim() + } val pendingIntent = PendingIntent.getActivity( this, - 0, - Intent(this, MainActivity::class.java), + VERIFICATION_REQUIRED_NOTIFICATION_ID, + VerificationNotificationIntent.create(this, extensionId, request.itemId), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 21b05edd..6ef7f684 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -78,6 +78,7 @@ class MainActivity: FlutterFragmentActivity() { private var backendChannel: MethodChannel? = null private var libraryStorageReceiver: BroadcastReceiver? = null private val pendingSessionGrantEvents = mutableListOf>() + private var pendingVerificationNotification: String? = null private var pendingSafTreeResult: MethodChannel.Result? = null internal val safScanLock = Any() internal var safScanProgress = SafScanProgress() @@ -697,15 +698,27 @@ class MainActivity: FlutterFragmentActivity() { // delegate looks it up by cached id (see getCachedEngineId above). AudioServicePlugin.getFlutterEngine(this) super.onCreate(savedInstanceState) + handleVerificationNotificationIntent(intent) handleExtensionOAuthIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + handleVerificationNotificationIntent(intent) handleExtensionOAuthIntent(intent) } + private fun handleVerificationNotificationIntent(intent: Intent?) { + if (intent?.action != VerificationNotificationIntent.ACTION) return + val payload = intent.getStringExtra(VerificationNotificationIntent.PAYLOAD) + ?.takeIf { it.isNotBlank() } ?: return + pendingVerificationNotification = payload + intent.removeExtra(VerificationNotificationIntent.PAYLOAD) + // Keep the payload until Dart is initialized and explicitly consumes it. + backendChannel?.invokeMethod("extensionVerificationNotificationTapped", null) + } + /** * Deliver Spotify (or other) OAuth authorization code to the extension runtime * and run its token exchange (e.g. completeSpotifyLogin). State is a one-time @@ -951,6 +964,11 @@ class MainActivity: FlutterFragmentActivity() { scope.launch { try { when (call.method) { + "consumeVerificationNotification" -> { + val payload = pendingVerificationNotification + pendingVerificationNotification = null + result.success(payload) + } "ensureInstallMarker" -> { val installState = withContext(Dispatchers.IO) { ensureInstallMarker() diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt index e95f0937..9fe3d719 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt @@ -50,6 +50,10 @@ internal object NativeWorkerPolicy { if (errorType.equals("verification_required", ignoreCase = true)) { return true } + when (errorType?.trim()?.lowercase()) { + "authentication_error", "provider_auth_failed", + "request_auth_invalid", "provider_reauth_required" -> return false + } val message = errorMessage.orEmpty() return message.contains("verification required", ignoreCase = true) || message.contains("challenge required", ignoreCase = true) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/VerificationNotificationIntent.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/VerificationNotificationIntent.kt new file mode 100644 index 00000000..6c0258e3 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/VerificationNotificationIntent.kt @@ -0,0 +1,23 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.content.Intent +import org.json.JSONObject +import java.util.UUID + +internal object VerificationNotificationIntent { + const val ACTION = "com.zarz.spotiflac.VERIFY_EXTENSION" + const val PAYLOAD = "verification_payload" + + fun create(context: Context, extensionId: String, itemId: String): Intent { + val payload = JSONObject() + .put("kind", "extension_verification") + .put("extension_id", extensionId) + .put("item_id", itemId) + .put("tap_id", "native:${UUID.randomUUID()}") + return Intent(context, MainActivity::class.java) + .setAction(ACTION) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(PAYLOAD, payload.toString()) + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt index 5c278c65..1afc6b85 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt @@ -106,6 +106,17 @@ class NativeWorkerPolicyTest { @Test fun verificationDetectionUsesTypeAndMessageFallback() { + for (errorType in listOf( + "authentication_error", "provider_auth_failed", + "request_auth_invalid", "provider_reauth_required", + )) { + assertFalse( + NativeWorkerPolicy.isVerificationRequired( + errorType = errorType, + errorMessage = "Provider unauthorized; verification required upstream", + ), + ) + } assertTrue( NativeWorkerPolicy.isVerificationRequired( errorType = "verification_required", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index f821aa62..6f87c789 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6470,6 +6470,15 @@ "@extensionVerificationBrowserInApp": { "description": "Chip label for in-app browser verification mode" }, + "extensionVerificationUnavailable": "No verification page is available for {extension}. Retry the download to request a new challenge.", + "@extensionVerificationUnavailable": { + "description": "Shown when the app cannot retrieve a pending verification challenge", + "placeholders": { + "extension": { + "type": "String" + } + } + }, "extensionVerificationHelpTitleManual": "Open verification manually", "@extensionVerificationHelpTitleManual": { "description": "Dialog title when automatic browser launch for verification fails" diff --git a/lib/main.dart b/lib/main.dart index cc095e34..2e4d96f1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; final _log = AppLogger('Main'); @@ -235,6 +236,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> Timer? _localLibraryWarmupTimer; bool _localLibraryWarmupScheduled = false; bool _autoScanTriggeredOnLaunch = false; + StreamSubscription? _verificationNotificationSubscription; @override void initState() { @@ -242,6 +244,10 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + _verificationNotificationSubscription = + PlatformBridge.verificationNotificationEvents().listen( + (_) => unawaited(_consumeVerificationNotification()), + ); _initializeAppServices(); _initializeExtensions(); _initializeDeferredProviders(); @@ -255,12 +261,15 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> _localLibraryEnabledSub?.close(); _downloadHistoryWarmupTimer?.cancel(); _localLibraryWarmupTimer?.cancel(); + _verificationNotificationSubscription?.cancel(); + NotificationService().verificationNotifications.setHandler(null); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { + unawaited(_consumeVerificationNotification()); CoverCacheManager.scheduleMaintenance(); _maybeAutoScanLocalLibrary(); if (ref.exists(localLibraryProvider)) { @@ -414,11 +423,38 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> storage.dataDir, masterKey: storage.masterKey, ); + if (!mounted) return; + NotificationService().verificationNotifications.setHandler(( + target, + ) async { + if (!mounted) return; + try { + await ref + .read(downloadQueueProvider.notifier) + .handleVerificationNotificationTap(target); + } catch (error) { + _log.w('Could not open verification notification: $error'); + if (mounted) showExtensionVerificationUnavailable(target.extensionId); + } + }); + await _consumeVerificationNotification(); } catch (e) { debugPrint('Failed to initialize extensions: $e'); } } + Future _consumeVerificationNotification() async { + if (!mounted || !Platform.isAndroid) return; + try { + final payload = await PlatformBridge.consumeVerificationNotification(); + if (mounted) { + NotificationService().verificationNotifications.receive(payload); + } + } catch (error) { + _log.w('Could not read pending verification notification: $error'); + } + } + @override Widget build(BuildContext context) { return widget.child; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index f0eb2be2..6aabbcc1 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -24,6 +24,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/verification_notification.dart'; import 'package:spotiflac_android/utils/logger.dart' hide log; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -32,6 +33,7 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; +import 'package:spotiflac_android/utils/download_error_type.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/progress_stream_poller.dart'; @@ -363,6 +365,7 @@ class DownloadQueueNotifier extends Notifier { int _failedInSession = 0; int _queueItemSequence = 0; bool _isLoaded = false; + final Completer _queueRestored = Completer(); bool _foregroundResumeScheduled = false; bool _iosBackgroundExecutionExpired = false; StreamSubscription>? _iosBackgroundExpirationSubscription; @@ -446,6 +449,7 @@ class DownloadQueueNotifier extends Notifier { }); ref.onDispose(() { + if (!_queueRestored.isCompleted) _queueRestored.complete(); _verificationWaitCoordinator.cancelAll(); _progressPoller.stop(); _connectivitySub?.cancel(); @@ -460,9 +464,13 @@ class DownloadQueueNotifier extends Notifier { }); Future.microtask(() async { - updateSettings(ref.read(settingsProvider)); - await _initOutputDir(); - await _loadQueueFromStorage(); + try { + updateSettings(ref.read(settingsProvider)); + await _initOutputDir(); + await _loadQueueFromStorage(); + } finally { + if (!_queueRestored.isCompleted) _queueRestored.complete(); + } }); return const DownloadQueueState(); } @@ -525,6 +533,10 @@ class DownloadQueueNotifier extends Notifier { }); } + Future handleVerificationNotificationTap( + VerificationNotification target, + ) => _handleVerificationNotificationTap(target); + void _handleIosBackgroundDownloadExpiration(List nativeItemIds) { if (!Platform.isIOS) return; final cancelledItemIds = nativeItemIds.toSet(); @@ -1415,6 +1427,8 @@ class DownloadQueueNotifier extends Notifier { item.status != DownloadStatus.skipped) { return item; } + _verificationRetryGuard.clearItem(item.id); + _rateLimitRetriedItemIds.remove(item.id); return item.copyWith( status: DownloadStatus.queued, progress: 0, @@ -1571,23 +1585,6 @@ class DownloadQueueNotifier extends Notifier { } } - DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) { - switch (errorType) { - case 'not_found': - return DownloadErrorType.notFound; - case 'rate_limit': - return DownloadErrorType.rateLimit; - case 'network': - return DownloadErrorType.network; - case 'permission': - return DownloadErrorType.permission; - case 'verification_required': - return DownloadErrorType.verificationRequired; - default: - return DownloadErrorType.unknown; - } - } - DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) { final lowerMsg = errorMsg.toLowerCase(); if (isExtensionVerificationRequired(errorMsg)) { diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 1c76a29c..143a570b 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1106,13 +1106,12 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { ? (resultMap?['error']?.toString() ?? 'Download failed') : error; final backendErrorType = resultMap == null - ? DownloadErrorType.unknown - : _downloadErrorTypeFromBackend( + ? null + : downloadErrorTypeFromBackend( resultMap['error_type']?.toString(), ); - final errorType = backendErrorType == DownloadErrorType.unknown - ? _downloadErrorTypeFromMessage(errorMsg) - : backendErrorType; + final errorType = + backendErrorType ?? _downloadErrorTypeFromMessage(errorMsg); try { if (await _recoverNativeWorkerStorageFailure( context: context, diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 6294c835..f5d15948 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1873,10 +1873,9 @@ class _DownloadRun { return false; } - final backendErrorType = n._downloadErrorTypeFromBackend(errorTypeStr); - final errorType = backendErrorType == DownloadErrorType.unknown - ? n._downloadErrorTypeFromMessage(errorMsg) - : backendErrorType; + final backendErrorType = downloadErrorTypeFromBackend(errorTypeStr); + final errorType = + backendErrorType ?? n._downloadErrorTypeFromMessage(errorMsg); if (errorType == DownloadErrorType.verificationRequired) { await n._handleVerificationRequiredDownload( diff --git a/lib/providers/download_queue_provider_verification.dart b/lib/providers/download_queue_provider_verification.dart index 0afbdb0f..9f7e01f3 100644 --- a/lib/providers/download_queue_provider_verification.dart +++ b/lib/providers/download_queue_provider_verification.dart @@ -2,6 +2,41 @@ part of 'download_queue_provider.dart'; extension _DownloadQueueVerificationGate on DownloadQueueNotifier { + Future _handleVerificationNotificationTap( + VerificationNotification target, + ) async { + await _queueRestored.future; + if (!ref.mounted) return; + final item = _findItemById(target.itemId); + if (item != null && + (item.status == DownloadStatus.completed || + item.status == DownloadStatus.skipped || + _isLocallyCancelled(item.id, item: item))) { + return; + } + // Foregrounding wakes an existing queue waiter. Do not replace it or + // reopen a browser that the queue already owns. + if ((item != null && + _verificationWaitCoordinator.hasActiveWaiter(item.id)) || + _verificationWaitCoordinator.hasActiveFlow(target.extensionId)) { + return; + } + // Failed items may not survive queue restoration. The notification still + // identifies the owning extension, but cannot restart a missing item. + final verified = await _openVerificationAndWait( + item?.id ?? 'notification:${target.tapId}', + target.extensionId, + ); + if (!ref.mounted || !verified || state.isPaused) return; + final current = _findItemById(target.itemId); + if (current != null && + current.status == DownloadStatus.failed && + current.errorType == DownloadErrorType.verificationRequired && + !_isLocallyCancelled(current.id, item: current)) { + await retryItem(current.id); + } + } + /// Completes when the app is in the foreground. Verification challenges /// can only be handled there: launching a browser from the background is /// blocked by the OS and the challenge would expire unseen. @@ -97,7 +132,10 @@ extension _DownloadQueueVerificationGate on DownloadQueueNotifier { ); try { - await _notificationService.showVerificationRequired(); + await _notificationService.showVerificationRequired( + extensionId: targetService, + itemId: item.id, + ); } catch (error) { _log.w('Failed to show the verification-required notification: $error'); } diff --git a/lib/providers/download_verification_retry_guard.dart b/lib/providers/download_verification_retry_guard.dart index 983615db..8cc3be68 100644 --- a/lib/providers/download_verification_retry_guard.dart +++ b/lib/providers/download_verification_retry_guard.dart @@ -45,6 +45,9 @@ class DownloadVerificationWaitCoordinator { bool hasActiveFlow(String service) => _flowsByService.containsKey(_serviceKey(service)); + bool hasActiveWaiter(String itemId) => + _cancellationsByItem.containsKey(itemId); + Future waitForGrant({ required String itemId, required String service, diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ed17e6ac..6c5d9c83 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,6 +6,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/services/verification_notification.dart'; class NotificationService { static final NotificationService _instance = NotificationService._internal(); @@ -15,6 +16,8 @@ class NotificationService { final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); bool _isInitialized = false; + Future? _initialization; + final verificationNotifications = VerificationNotificationRouter(); bool _notificationPermissionRequested = false; AppLocalizations? _l10n; @@ -43,9 +46,14 @@ class NotificationService { static const String libraryChannelDescription = 'Shows local library scan progress'; - Future initialize() async { - if (_isInitialized) return; + Future initialize() { + if (_isInitialized) return Future.value(); + return _initialization ??= _initialize().whenComplete(() { + _initialization = null; + }); + } + Future _initialize() async { const androidSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', ); @@ -60,7 +68,16 @@ class NotificationService { iOS: iosSettings, ); - await _notifications.initialize(settings: initSettings); + await _notifications.initialize( + settings: initSettings, + onDidReceiveNotificationResponse: (response) { + verificationNotifications.receive(response.payload); + }, + ); + final launch = await _notifications.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp == true) { + verificationNotifications.receive(launch?.notificationResponse?.payload); + } if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { final androidImpl = _notifications @@ -126,6 +143,7 @@ class NotificationService { required String title, required String body, required NotificationDetails details, + String? payload, }) async { if (!await _ensureNotificationPermission()) return; @@ -135,6 +153,7 @@ class NotificationService { title: title, body: body, notificationDetails: details, + payload: payload, ); } on PlatformException catch (e) { final isNotificationsNotAllowed = @@ -305,7 +324,10 @@ class NotificationService { ); } - Future showVerificationRequired() async { + Future showVerificationRequired({ + required String extensionId, + required String itemId, + }) async { if (!_isInitialized) await initialize(); unawaited(HapticFeedback.mediumImpact()); @@ -319,6 +341,11 @@ class NotificationService { id: verificationRequiredId, title: title, body: body, + payload: VerificationNotification( + extensionId: extensionId, + itemId: itemId, + tapId: 'dart:${DateTime.now().microsecondsSinceEpoch}', + ).encode(), details: _details( playSound: true, presentBadge: true, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 40fa1103..236a46a7 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -203,6 +203,8 @@ class PlatformBridge { StreamController.broadcast(); static final StreamController _libraryStorageEvents = StreamController.broadcast(); + static final StreamController _verificationNotificationEvents = + StreamController.broadcast(); static final StreamController> _iosBackgroundDownloadExpirationEvents = StreamController>.broadcast(); @@ -223,6 +225,14 @@ class PlatformBridge { return _libraryStorageEvents.stream; } + static Stream verificationNotificationEvents() { + _ensureBackendEventHandler(); + return _verificationNotificationEvents.stream; + } + + static Future consumeVerificationNotification() => + _channel.invokeMethod('consumeVerificationNotification'); + static Stream> iosBackgroundDownloadExpirationEvents() { _ensureBackendEventHandler(); return _iosBackgroundDownloadExpirationEvents.stream; @@ -250,6 +260,9 @@ class PlatformBridge { case 'libraryStorageChanged': _libraryStorageEvents.add(null); return null; + case 'extensionVerificationNotificationTapped': + _verificationNotificationEvents.add(null); + return null; case 'iosBackgroundDownloadExpired': final raw = call.arguments; var itemIds = const []; diff --git a/lib/services/verification_notification.dart b/lib/services/verification_notification.dart new file mode 100644 index 00000000..7eead4a1 --- /dev/null +++ b/lib/services/verification_notification.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; + +class VerificationNotification { + const VerificationNotification({ + required this.extensionId, + required this.itemId, + required this.tapId, + }); + + final String extensionId; + final String itemId; + final String tapId; + + String encode() => jsonEncode({ + 'kind': 'extension_verification', + 'extension_id': extensionId, + 'item_id': itemId, + 'tap_id': tapId, + }); + + static VerificationNotification? parse(Object? payload) { + try { + final value = payload is String ? jsonDecode(payload) : payload; + if (value is! Map || value['kind'] != 'extension_verification') { + return null; + } + final extensionId = value['extension_id']; + final itemId = value['item_id']; + final tapId = value['tap_id']; + if (extensionId is! String || + extensionId.trim().isEmpty || + itemId is! String || + itemId.trim().isEmpty || + tapId is! String || + tapId.trim().isEmpty) { + return null; + } + return VerificationNotification( + extensionId: extensionId.trim(), + itemId: itemId.trim(), + tapId: tapId.trim(), + ); + } on FormatException { + return null; + } + } +} + +/// Holds cold-start taps until extensions and queue restoration are ready. +/// A repeated native/plugin delivery must not launch a second challenge. +class VerificationNotificationRouter { + final Map _pending = {}; + final Set _handled = {}; + final Set _active = {}; + Future Function(VerificationNotification)? _handler; + + void setHandler(Future Function(VerificationNotification)? handler) { + _handler = handler; + _drain(); + } + + void receive(Object? payload) { + final target = VerificationNotification.parse(payload); + if (target == null || + _handled.contains(target.tapId) || + _active.contains(target.tapId)) { + return; + } + _pending.putIfAbsent(target.tapId, () => target); + _drain(); + } + + void _drain() { + final handler = _handler; + if (handler == null) return; + for (final target in _pending.values.toList()) { + _pending.remove(target.tapId); + _active.add(target.tapId); + // The queue deduplicates flows per extension. A different provider's + // notification must not wait behind an unrelated grant timeout. + unawaited( + Future.sync(() => handler(target)).whenComplete(() { + _active.remove(target.tapId); + _handled.add(target.tapId); + if (_handled.length > 32) _handled.remove(_handled.first); + }), + ); + } + } +} diff --git a/lib/utils/download_error_type.dart b/lib/utils/download_error_type.dart new file mode 100644 index 00000000..f40578c6 --- /dev/null +++ b/lib/utils/download_error_type.dart @@ -0,0 +1,25 @@ +import 'package:spotiflac_android/models/download_item.dart'; + +/// Null permits legacy message inference; unknown preserves a typed failure's +/// original message without mistaking provider authentication for a challenge. +DownloadErrorType? downloadErrorTypeFromBackend(String? errorType) { + switch (errorType?.trim().toLowerCase()) { + case 'not_found': + return DownloadErrorType.notFound; + case 'rate_limit': + return DownloadErrorType.rateLimit; + case 'network': + return DownloadErrorType.network; + case 'permission': + return DownloadErrorType.permission; + case 'verification_required': + return DownloadErrorType.verificationRequired; + case 'authentication_error': + case 'provider_auth_failed': + case 'request_auth_invalid': + case 'provider_reauth_required': + return DownloadErrorType.unknown; + default: + return null; + } +} diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 9e072b78..acf76886 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -22,10 +22,7 @@ bool isExtensionVerificationRequired(Object error) { message.contains('needsverification') || message.contains('needs verification') || message.contains('session is not authenticated') || - message.contains('unauthorized') || - message.contains('precondition required') || - _containsHttpStatusCode(message, '401') || - _containsHttpStatusCode(message, '428'); + message.contains('signed session expired'); } Future runExtensionOperationWithVerificationRetry({ @@ -82,15 +79,6 @@ String? extensionIdFromVerificationError( return null; } -bool _containsHttpStatusCode(String message, String code) { - return message.contains('http $code') || - message.contains('http status $code') || - message.contains('status $code') || - message.contains('$code for ') || - message.contains('$code:') || - message.contains('$code;'); -} - Future openPendingExtensionVerification( String extensionId, { String browserMode = 'in_app_first', @@ -100,14 +88,22 @@ Future openPendingExtensionVerification( final normalizedExtensionId = extensionId.trim(); if (normalizedExtensionId.isEmpty) return false; + var cancelled = false; + if (cancellationSignal != null) { + unawaited(cancellationSignal.then((_) => cancelled = true)); + } try { final pending = await _awaitVerificationStepOrCancellation( PlatformBridge.getExtensionPendingAuth(normalizedExtensionId), cancellationSignal, ); - if (pending == null) return false; - final authUrl = pending['auth_url']?.toString().trim() ?? ''; - if (authUrl.isEmpty) return false; + if (cancelled) return false; + final authUrl = pending?['auth_url']?.toString().trim() ?? ''; + if (authUrl.isEmpty) { + _log.w('No pending verification challenge for $normalizedExtensionId'); + showExtensionVerificationUnavailable(normalizedExtensionId); + return false; + } final uri = Uri.tryParse(authUrl); if (uri == null) return false; @@ -138,10 +134,22 @@ Future openPendingExtensionVerification( _log.w( 'Failed to open verification challenge for $normalizedExtensionId: $e', ); + if (!cancelled) showExtensionVerificationUnavailable(normalizedExtensionId); return false; } } +void showExtensionVerificationUnavailable(String extensionId) { + final context = AppNavigationService.rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text(context.l10n.extensionVerificationUnavailable(extensionId)), + duration: const Duration(seconds: 8), + ), + ); +} + Timer? scheduleExtensionVerificationHelpDialog( String extensionId, Uri? authUri, { diff --git a/test/extension_auth_launcher_test.dart b/test/extension_auth_launcher_test.dart index 80269f50..ae25b0a3 100644 --- a/test/extension_auth_launcher_test.dart +++ b/test/extension_auth_launcher_test.dart @@ -1,9 +1,76 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/utils/download_error_type.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; void main() { + test('typed provider auth errors override misleading verification text', () { + for (final type in [ + 'authentication_error', + 'PROVIDER_AUTH_FAILED', + 'request_auth_invalid', + 'provider_reauth_required', + ]) { + final errorType = + downloadErrorTypeFromBackend(type) ?? + (isExtensionVerificationRequired('Verification required upstream') + ? DownloadErrorType.verificationRequired + : DownloadErrorType.unknown); + expect(errorType, DownloadErrorType.unknown, reason: type); + } + expect(downloadErrorTypeFromBackend(null), isNull); + expect(downloadErrorTypeFromBackend('unknown'), isNull); + expect( + downloadErrorTypeFromBackend('verification_required'), + DownloadErrorType.verificationRequired, + ); + }); + + test('provider auth and HTTP status errors do not imply verification', () { + for (final message in [ + 'Provider unauthorized', + 'HTTP 401 for /download', + 'HTTP status 428: precondition required', + 'PROVIDER_AUTH_FAILED: unauthorized', + ]) { + expect( + isExtensionVerificationRequired(message), + isFalse, + reason: message, + ); + } + for (final message in [ + 'verification_required: canonical gateway challenge', + 'VERIFY_REQUIRED', + 'signed session is not authenticated', + 'signed session expired', + ]) { + expect(isExtensionVerificationRequired(message), isTrue, reason: message); + } + }); + + test( + 'does not open a verification flow for a provider authentication error', + () async { + var verificationCalls = 0; + await expectLater( + runExtensionOperationWithVerificationRetry( + extensionId: 'provider-a', + browserMode: 'in_app_first', + operation: () async => throw StateError('Provider unauthorized'), + verify: () async { + verificationCalls++; + return true; + }, + ), + throwsStateError, + ); + expect(verificationCalls, 0); + }, + ); + test('verification challenge expires after three minutes', () { expect(extensionVerificationGrantTimeout, const Duration(minutes: 3)); }); @@ -11,20 +78,20 @@ void main() { test('extracts the extension that raised a verification challenge', () { expect( extensionIdFromVerificationError( - "verification_required: extension 'tidal-web' needs verification", - const ['amazon-web', 'tidal-web'], + "verification_required: extension 'provider-b' needs verification", + const ['provider-a', 'provider-b'], ), - 'tidal-web', + 'provider-b', ); }); test('prefers the longest known extension id in legacy errors', () { expect( extensionIdFromVerificationError( - 'qobuz-web verification_required', - const ['qobuz', 'qobuz-web'], + 'sample-provider verification_required', + const ['sample', 'sample-provider'], ), - 'qobuz-web', + 'sample-provider', ); }); @@ -35,7 +102,7 @@ void main() { var verificationCalls = 0; final result = await runExtensionOperationWithVerificationRetry( - extensionId: 'qobuz-web', + extensionId: 'provider-a', browserMode: 'in_app_first', operation: () async { operationCalls++; @@ -59,7 +126,7 @@ void main() { await expectLater( runExtensionOperationWithVerificationRetry( - extensionId: 'qobuz-web', + extensionId: 'provider-a', browserMode: 'in_app_first', operation: () async { operationCalls++; @@ -77,7 +144,7 @@ void main() { final foreground = Completer(); final cancellation = Completer(); final result = openVerificationAndAwaitGrant( - 'tidal-web', + 'provider-a', browserMode: 'in_app_first', awaitForeground: (_) => foreground.future, cancellationSignal: cancellation.future, diff --git a/test/extension_verification_feedback_test.dart b/test/extension_verification_feedback_test.dart new file mode 100644 index 00000000..3073125f --- /dev/null +++ b/test/extension_verification_feedback_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/services/app_navigation_service.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; + +void main() { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future showApp(WidgetTester tester) => tester.pumpWidget( + MaterialApp( + navigatorKey: AppNavigationService.rootNavigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: Text('Library')), + ), + ); + + testWidgets('missing pending challenge explains why no page opened', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getExtensionPendingAuth'); + return null; + }); + await showApp(tester); + expect(await openPendingExtensionVerification('provider-a'), isFalse); + await tester.pump(); + expect( + find.text( + 'No verification page is available for provider-a. ' + 'Retry the download to request a new challenge.', + ), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('cancelled pending lookup does not show a failure message', ( + tester, + ) async { + final pending = Completer(); + final cancellation = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => pending.future); + await showApp(tester); + final result = openPendingExtensionVerification( + 'provider-a', + cancellationSignal: cancellation.future, + ); + cancellation.complete(); + expect(await result, isFalse); + pending.complete(); + await tester.pump(); + expect(find.byType(SnackBar), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/test/notification_service_test.dart b/test/notification_service_test.dart index 45a6144d..8a2ab745 100644 --- a/test/notification_service_test.dart +++ b/test/notification_service_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/verification_notification.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -10,6 +11,11 @@ void main() { const channel = MethodChannel('dexterous.com/flutter/local_notifications'); final methodCalls = []; + const coldTarget = VerificationNotification( + extensionId: 'provider-a', + itemId: 'cold-source-item', + tapId: 'dart:cold-tap', + ); setUp(() { debugDefaultTargetPlatformOverride = TargetPlatform.android; @@ -17,6 +23,16 @@ void main() { .setMockMethodCallHandler(channel, (call) async { methodCalls.add(call); if (call.method == 'initialize') return true; + if (call.method == 'getNotificationAppLaunchDetails') { + return { + 'notificationLaunchedApp': true, + 'notificationResponse': { + 'notificationId': 4, + 'notificationResponseType': 0, + 'payload': coldTarget.encode(), + }, + }; + } return null; }); }); @@ -30,7 +46,10 @@ void main() { test('verification uses a distinct audible Android alert channel', () async { final notificationService = NotificationService(); - await notificationService.showVerificationRequired(); + await notificationService.showVerificationRequired( + extensionId: 'provider-b', + itemId: 'source-item', + ); final alertChannelCall = methodCalls.firstWhere( (call) => @@ -58,6 +77,33 @@ void main() { expect(androidDetails['importance'], Importance.defaultImportance.value); expect(androidDetails['playSound'], isTrue); expect(androidDetails['enableVibration'], isTrue); + final target = VerificationNotification.parse(showArguments['payload']); + expect(target?.extensionId, 'provider-b'); + expect(target?.itemId, 'source-item'); + + final tapped = []; + notificationService.verificationNotifications.setHandler((target) async { + tapped.add(target); + }); + expect(tapped.single.extensionId, 'provider-a'); + expect(tapped.single.itemId, 'cold-source-item'); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('didReceiveNotificationResponse', { + 'notificationResponseType': 0, + 'id': 4, + 'payload': showArguments['payload'], + }), + ), + (_) {}, + ); + expect(tapped.map((target) => target.extensionId), [ + 'provider-a', + 'provider-b', + ]); + notificationService.verificationNotifications.setHandler(null); await notificationService.cancelVerificationRequired(); expect( diff --git a/test/verification_notification_test.dart b/test/verification_notification_test.dart new file mode 100644 index 00000000..1abaf523 --- /dev/null +++ b/test/verification_notification_test.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/verification_notification.dart'; + +void main() { + const target = VerificationNotification( + extensionId: 'provider-a', + itemId: 'source-track', + tapId: 'native:tap-1', + ); + + test( + 'cold tap waits for a ready handler and keeps fallback ownership', + () async { + final router = VerificationNotificationRouter(); + final seen = []; + router.receive(target.encode()); + router.receive(target.encode()); + expect(seen, isEmpty); + router.setHandler((value) async => seen.add(value)); + await Future.delayed(Duration.zero); + expect(seen.single.extensionId, 'provider-a'); + expect(seen.single.itemId, 'source-track'); + router.receive(target.encode()); + expect(seen, hasLength(1)); + }, + ); + + test( + 'duplicate active tap is ignored without blocking another provider', + () async { + final router = VerificationNotificationRouter(); + final wait = Completer(); + final seen = []; + router.setHandler((value) async { + seen.add(value.extensionId); + await wait.future; + }); + router.receive(target.encode()); + router.receive(target.encode()); + router.receive( + const VerificationNotification( + extensionId: 'provider-b', + itemId: 'another-track', + tapId: 'dart:tap-2', + ).encode(), + ); + expect(seen, ['provider-a', 'provider-b']); + wait.complete(); + await Future.delayed(Duration.zero); + }, + ); + + test('unrelated and incomplete notifications cannot guess an extension', () { + for (final payload in [ + null, + '', + 'not json', + '{}', + {'kind': 'extension_verification', 'item_id': 'track', 'tap_id': 'tap'}, + { + 'kind': 'other', + 'extension_id': 'provider-a', + 'item_id': 'track', + 'tap_id': 'tap', + }, + ]) { + expect(VerificationNotification.parse(payload), isNull); + } + }); +}