diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 1b023a46..0c5b22e6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -443,8 +443,21 @@ class DownloadQueueNotifier extends Notifier { 'Verification required for $normalizedExtensionId while app is in ' 'background; deferring challenge until the app is foregrounded', ); - unawaited(_notificationService.showVerificationRequired()); + try { + await _notificationService.showVerificationRequired(); + } catch (error) { + _log.w( + 'Failed to show the verification-required notification: $error', + ); + } await _waitForForeground(); + try { + await _notificationService.cancelVerificationRequired(); + } catch (error) { + _log.w( + 'Failed to clear the verification-required notification: $error', + ); + } } }, ); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 8ce023ca..724422f3 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -30,9 +30,14 @@ class NotificationService { static const int downloadProgressId = 1; static const int updateDownloadId = 2; static const int libraryScanId = 3; + static const int verificationRequiredId = 4; static const String channelId = 'download_progress'; static const String channelName = 'Download Progress'; static const String channelDescription = 'Shows download progress for tracks'; + static const String alertChannelId = 'download_alerts_v1'; + static const String alertChannelName = 'Download Alerts'; + static const String alertChannelDescription = + 'Important download status and actions that need attention'; static const String libraryChannelId = 'library_scan'; static const String libraryChannelName = 'Library Scan'; static const String libraryChannelDescription = @@ -57,7 +62,7 @@ class NotificationService { await _notifications.initialize(settings: initSettings); - if (Platform.isAndroid) { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { final androidImpl = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin @@ -73,6 +78,16 @@ class NotificationService { enableVibration: false, ), ); + await androidImpl?.createNotificationChannel( + const AndroidNotificationChannel( + alertChannelId, + alertChannelName, + description: alertChannelDescription, + importance: Importance.defaultImportance, + playSound: true, + enableVibration: true, + ), + ); await androidImpl?.createNotificationChannel( const AndroidNotificationChannel( libraryChannelId, @@ -147,14 +162,30 @@ class NotificationService { bool presentBadge = false, bool presentSound = false, }) { + assert(!library || !playSound); final inProgress = progress != null; + assert(!inProgress || !playSound); + final androidChannelId = library + ? libraryChannelId + : playSound + ? alertChannelId + : channelId; + final androidChannelName = library + ? libraryChannelName + : playSound + ? alertChannelName + : channelName; + final androidChannelDescription = library + ? libraryChannelDescription + : playSound + ? alertChannelDescription + : channelDescription; + return NotificationDetails( android: AndroidNotificationDetails( - library ? libraryChannelId : channelId, - library ? libraryChannelName : channelName, - channelDescription: library - ? libraryChannelDescription - : channelDescription, + androidChannelId, + androidChannelName, + channelDescription: androidChannelDescription, importance: inProgress ? Importance.low : Importance.defaultImportance, priority: inProgress ? Priority.low : Priority.defaultPriority, showProgress: inProgress, @@ -285,7 +316,7 @@ class NotificationService { 'Open the app to complete verification and resume downloads'; await _showSafely( - id: downloadProgressId, + id: verificationRequiredId, title: title, body: body, details: _details( @@ -296,6 +327,10 @@ class NotificationService { ); } + Future cancelVerificationRequired() async { + await _notifications.cancel(id: verificationRequiredId); + } + Future showQueueCanceled({required int canceledCount}) async { if (!_isInitialized) await initialize(); if (canceledCount <= 0) return; diff --git a/test/notification_service_test.dart b/test/notification_service_test.dart new file mode 100644 index 00000000..45a6144d --- /dev/null +++ b/test/notification_service_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + AndroidFlutterLocalNotificationsPlugin.registerWith(); + + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final methodCalls = []; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + methodCalls.add(call); + if (call.method == 'initialize') return true; + return null; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + methodCalls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('verification uses a distinct audible Android alert channel', () async { + final notificationService = NotificationService(); + await notificationService.showVerificationRequired(); + + final alertChannelCall = methodCalls.firstWhere( + (call) => + call.method == 'createNotificationChannel' && + (call.arguments as Map)['id'] == + NotificationService.alertChannelId, + ); + final alertChannel = alertChannelCall.arguments as Map; + + expect(alertChannel['importance'], Importance.defaultImportance.value); + expect(alertChannel['playSound'], isTrue); + expect(alertChannel['enableVibration'], isTrue); + + final showCall = methodCalls.lastWhere((call) => call.method == 'show'); + final showArguments = showCall.arguments as Map; + final androidDetails = + showArguments['platformSpecifics'] as Map; + + expect( + NotificationService.verificationRequiredId, + isNot(NotificationService.downloadProgressId), + ); + expect(showArguments['id'], NotificationService.verificationRequiredId); + expect(androidDetails['channelId'], NotificationService.alertChannelId); + expect(androidDetails['importance'], Importance.defaultImportance.value); + expect(androidDetails['playSound'], isTrue); + expect(androidDetails['enableVibration'], isTrue); + + await notificationService.cancelVerificationRequired(); + expect( + methodCalls.last, + isMethodCall( + 'cancel', + arguments: { + 'id': NotificationService.verificationRequiredId, + 'tag': null, + }, + ), + ); + }); +}