fix(download): route verification notifications to pending challenges

This commit is contained in:
zarzet
2026-09-06 16:03:46 +07:00
parent 4fc837b94c
commit 1b0c28b91a
21 changed files with 621 additions and 65 deletions
@@ -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)
@@ -78,6 +78,7 @@ class MainActivity: FlutterFragmentActivity() {
private var backendChannel: MethodChannel? = null
private var libraryStorageReceiver: BroadcastReceiver? = null
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
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()
@@ -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)
@@ -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())
}
}
@@ -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",
+9
View File
@@ -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"
+36
View File
@@ -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<void>? _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<void> _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;
+17 -20
View File
@@ -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<DownloadQueueState> {
int _failedInSession = 0;
int _queueItemSequence = 0;
bool _isLoaded = false;
final Completer<void> _queueRestored = Completer<void>();
bool _foregroundResumeScheduled = false;
bool _iosBackgroundExecutionExpired = false;
StreamSubscription<List<String>>? _iosBackgroundExpirationSubscription;
@@ -446,6 +449,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
ref.onDispose(() {
if (!_queueRestored.isCompleted) _queueRestored.complete();
_verificationWaitCoordinator.cancelAll();
_progressPoller.stop();
_connectivitySub?.cancel();
@@ -460,9 +464,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
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<DownloadQueueState> {
});
}
Future<void> handleVerificationNotificationTap(
VerificationNotification target,
) => _handleVerificationNotificationTap(target);
void _handleIosBackgroundDownloadExpiration(List<String> nativeItemIds) {
if (!Platform.isIOS) return;
final cancelledItemIds = nativeItemIds.toSet();
@@ -1415,6 +1427,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
}
}
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)) {
@@ -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,
@@ -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(
@@ -2,6 +2,41 @@
part of 'download_queue_provider.dart';
extension _DownloadQueueVerificationGate on DownloadQueueNotifier {
Future<void> _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');
}
@@ -45,6 +45,9 @@ class DownloadVerificationWaitCoordinator {
bool hasActiveFlow(String service) =>
_flowsByService.containsKey(_serviceKey(service));
bool hasActiveWaiter(String itemId) =>
_cancellationsByItem.containsKey(itemId);
Future<bool> waitForGrant({
required String itemId,
required String service,
+31 -4
View File
@@ -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<void>? _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<void> initialize() async {
if (_isInitialized) return;
Future<void> initialize() {
if (_isInitialized) return Future.value();
return _initialization ??= _initialize().whenComplete(() {
_initialization = null;
});
}
Future<void> _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<void> showVerificationRequired() async {
Future<void> 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,
+13
View File
@@ -203,6 +203,8 @@ class PlatformBridge {
StreamController<ExtensionSessionGrantEvent>.broadcast();
static final StreamController<void> _libraryStorageEvents =
StreamController<void>.broadcast();
static final StreamController<void> _verificationNotificationEvents =
StreamController<void>.broadcast();
static final StreamController<List<String>>
_iosBackgroundDownloadExpirationEvents =
StreamController<List<String>>.broadcast();
@@ -223,6 +225,14 @@ class PlatformBridge {
return _libraryStorageEvents.stream;
}
static Stream<void> verificationNotificationEvents() {
_ensureBackendEventHandler();
return _verificationNotificationEvents.stream;
}
static Future<String?> consumeVerificationNotification() =>
_channel.invokeMethod<String>('consumeVerificationNotification');
static Stream<List<String>> 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 <String>[];
@@ -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<String, VerificationNotification> _pending = {};
final Set<String> _handled = {};
final Set<String> _active = {};
Future<void> Function(VerificationNotification)? _handler;
void setHandler(Future<void> 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<void>.sync(() => handler(target)).whenComplete(() {
_active.remove(target.tapId);
_handled.add(target.tapId);
if (_handled.length > 32) _handled.remove(_handled.first);
}),
);
}
}
}
+25
View File
@@ -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;
}
}
+24 -16
View File
@@ -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<T> runExtensionOperationWithVerificationRetry<T>({
@@ -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<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'in_app_first',
@@ -100,14 +88,22 @@ Future<bool> 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<bool> 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, {
+76 -9
View File
@@ -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<void>(
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<void>();
final cancellation = Completer<void>();
final result = openVerificationAndAwaitGrant(
'tidal-web',
'provider-a',
browserMode: 'in_app_first',
awaitForeground: (_) => foreground.future,
cancellationSignal: cancellation.future,
@@ -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<void> 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<Object?>();
final cancellation = Completer<void>();
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());
});
}
+47 -1
View File
@@ -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 = <MethodCall>[];
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 = <VerificationNotification>[];
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(
+72
View File
@@ -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 = <VerificationNotification>[];
router.receive(target.encode());
router.receive(target.encode());
expect(seen, isEmpty);
router.setHandler((value) async => seen.add(value));
await Future<void>.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<void>();
final seen = <String>[];
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<void>.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);
}
});
}