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
+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);
}
});
}