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