mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 06:15:28 +02:00
fix(download): route verification notifications to pending challenges
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user