From bb17c7d48a4dc0fd7586d5398265c7d1da9a4e49 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:50:50 +0800 Subject: [PATCH] fix(macos): request permission for download notifications (#1498) --- .../app_notification_controller.dart | 20 ++- .../app_notification_controller_test.dart | 139 ++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 ui/flutter/test/app/application/app_notification_controller_test.dart diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index bd09d189..cf8619cb 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier { StreamSubscription? _taskEventSubscription; var _notificationId = 0; + @visibleForTesting + Stream get taskEvents => LibgopeedBoot.instance.taskEvents; + @override Future build() async { final runtime = ref.watch(appRuntimeControllerProvider).value; if (runtime == null || kIsWeb || !Util.isDesktop()) { return const AppNotificationState(); } - await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); + await _initNotifications( + appLocalizationsFor(runtime.downloaderConfig.extra.locale), + requestPermissions: runtime.downloaderConfig.extra.desktopNotification, + ); _listenTaskEvents(); ref.onDispose(() { unawaited(_taskEventSubscription?.cancel()); @@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier { return const AppNotificationState(started: true); } - Future _initNotifications(AppLocalizations locale) async { - const darwin = DarwinInitializationSettings( - requestAlertPermission: false, + Future _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async { + // The runtime is watched, so enabling notifications in settings also + // requests authorization. macOS remembers previously granted/denied access. + final darwin = DarwinInitializationSettings( + requestAlertPermission: requestPermissions, requestBadgePermission: false, - requestSoundPermission: false, + requestSoundPermission: requestPermissions, ); final linux = LinuxInitializationSettings( defaultActionName: locale.open, @@ -92,7 +100,7 @@ class AppNotificationController extends AsyncNotifier { void _listenTaskEvents() { _taskEventSubscription?.cancel(); - _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async { + _taskEventSubscription = taskEvents.listen((event) async { final runtime = ref.read(appRuntimeControllerProvider).value; if (runtime?.downloaderConfig.extra.desktopNotification == false) return; final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart new file mode 100644 index 00000000..a74c60d4 --- /dev/null +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_notification_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/task_event.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final calls = []; + var permissionGranted = true; + late StreamController events; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + MacOSFlutterLocalNotificationsPlugin.registerWith(); + calls.clear(); + permissionGranted = true; + events = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return permissionGranted; + }); + }); + + tearDown(() { + unawaited(events.close()); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + Future start({required bool enabled}) async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)), + appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)), + ], + ); + addTearDown(container.dispose); + await container.read(appRuntimeControllerProvider.future); + await container.read(appNotificationControllerProvider.future); + return container; + } + + Map initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map; + + test('macOS requests alerts and sound when download notifications are enabled at startup', () async { + await start(enabled: true); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + expect(initialization()['requestBadgePermission'], isFalse); + }); + + test('disabled notifications defer authorization until the setting is enabled', () async { + final container = await start(enabled: false); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + }); + + test('denied macOS authorization does not fail notification controller startup', () async { + permissionGranted = false; + final container = await start(enabled: true); + + expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue); + }); + + test('terminal task events notify once after enabling and stop after disabling', () async { + final container = await start(enabled: false); + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip'); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), isEmpty); + + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip')); + await Future.delayed(Duration.zero); + final notifications = calls.where((call) => call.method == 'show').toList(); + expect(notifications, hasLength(2)); + expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + }); +} + +class _TestNotificationController extends AppNotificationController { + _TestNotificationController(this.taskEvents); + + @override + final Stream taskEvents; +} + +class _TestRuntimeController extends AppRuntimeController { + _TestRuntimeController(this.enabled); + + final bool enabled; + + @override + Future build() async => _runtime(enabled); + + void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled)); + + AppRuntimeState _runtime(bool enabled) => AppRuntimeState( + startConfig: StartConfig(), + apiServerState: ApiServerState.fromJson({}), + downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled, + ); +}