diff --git a/flutter_local_notifications_windows/lib/src/ffi/mock.dart b/flutter_local_notifications_windows/lib/src/ffi/mock.dart new file mode 100644 index 000000000..926bd2dd0 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/ffi/mock.dart @@ -0,0 +1,55 @@ +// Just a mock, doesn't need real types or safety. +// ignore_for_file: type_annotate_public_apis, always_specify_types + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import 'bindings.dart'; + +/// Mocked FFI bindings. +class MockBindings implements NotificationsPluginBindings { + @override + void cancelAll(_) {} + + @override + void cancelNotification(_, int id) {} + + @override + Pointer createPlugin() => malloc().cast(); + + @override + void disposePlugin(_) {} + + @override + void freeLaunchDetails(_) {} + + @override + void freeDetailsArray(ptr) => malloc.free(ptr); + + @override + Pointer getActiveNotifications(_, __) => + malloc(); + + @override + Pointer getPendingNotifications(_, __) => + malloc(); + + @override + bool hasPackageIdentity() => false; + + @override + bool init(_, __, ___, ____, _____, ______) => true; + + @override + bool isValidXml(ptr) => true; + + @override + bool scheduleNotification(a, b, c, d) => true; + + @override + bool showNotification(a, b, c, d) => true; + + @override + NativeUpdateResult updateNotification(a, b, c) => NativeUpdateResult.success; +} diff --git a/flutter_local_notifications_windows/lib/src/plugin/ffi.dart b/flutter_local_notifications_windows/lib/src/plugin/ffi.dart index c64a4bf22..b9ae058da 100644 --- a/flutter_local_notifications_windows/lib/src/plugin/ffi.dart +++ b/flutter_local_notifications_windows/lib/src/plugin/ffi.dart @@ -1,6 +1,8 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; +import 'package:meta/meta.dart'; +import 'package:xml/xml.dart'; import '../details.dart'; import '../details/notification_to_xml.dart'; @@ -22,10 +24,28 @@ extension on String { this[23] == '-'; } +/// Does a basic syntax check on XML. +bool _checkXml(String xml) { + try { + XmlDocument.parse(xml); + return true; + } on XmlFormatException { + return false; + } +} + /// The Windows implementation of `package:flutter_local_notifications`. class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { /// Creates an instance of the native plugin. - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows() + : _bindings = NotificationsPluginBindings( + DynamicLibrary.open('flutter_local_notifications_windows.dll')); + + /// Creates an instance of this plugin with the given (mocked) bindings. + @visibleForTesting + FlutterLocalNotificationsWindows.withBindings( + this._bindings, + ); /// Registers the Windows implementation with Flutter. static void registerWith() { @@ -37,11 +57,7 @@ class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { static FlutterLocalNotificationsWindows? instance; /// The FFI generated bindings to the native code. - late final NotificationsPluginBindings _bindings = - NotificationsPluginBindings(_library); - - final DynamicLibrary _library = - DynamicLibrary.open('flutter_local_notifications_windows.dll'); + final NotificationsPluginBindings _bindings; /// A pointer to the C++ handler class. late final Pointer _plugin; @@ -281,6 +297,9 @@ class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { @override bool isValidXml(String xml) => using((Arena arena) { + if (!_checkXml(xml)) { + return false; + } final Pointer nativeXml = xml.toNativeUtf8(allocator: arena); return _bindings.isValidXml(nativeXml); }); diff --git a/flutter_local_notifications_windows/lib/src/plugin/stub.dart b/flutter_local_notifications_windows/lib/src/plugin/stub.dart index cafce4f8b..72af045d5 100644 --- a/flutter_local_notifications_windows/lib/src/plugin/stub.dart +++ b/flutter_local_notifications_windows/lib/src/plugin/stub.dart @@ -1,8 +1,22 @@ +import 'package:meta/meta.dart'; + import '../details.dart'; +import '../ffi/bindings.dart'; import 'base.dart'; /// The Windows implementation of `package:flutter_local_notifications`. class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { + /// Creates an instance of this plugin. + FlutterLocalNotificationsWindows(); + + /// Creates an instance of this plugin with the given (mocked) bindings. + @visibleForTesting + FlutterLocalNotificationsWindows.withBindings( + // Needed in this file due to conditional imports + // ignore: avoid_unused_constructor_parameters + NotificationsPluginBindings bindings, + ); + @override Future initialize( WindowsInitializationSettings settings, { diff --git a/flutter_local_notifications_windows/test/bindings_test.dart b/flutter_local_notifications_windows/test/bindings_test.dart deleted file mode 100644 index 0f1c921ab..000000000 --- a/flutter_local_notifications_windows/test/bindings_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; -import 'package:test/test.dart'; - -const WindowsInitializationSettings settings = WindowsInitializationSettings( - appName: 'Test app', - appUserModelId: 'com.test.test', - guid: 'a8c22b55-049e-422f-b30f-863694de08c8', -); - -const Map bindings = { - 'title': 'Bindings title', - 'body': 'Bindings body', -}; - -void main() => group('Bindings', () { - final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); - setUpAll(() => plugin.initialize(settings)); - tearDownAll(() async { - await plugin.cancelAll(); - plugin.dispose(); - }); - - test('work in simple cases', () async { - await plugin.show(500, '{title}', '{body}'); - final NotificationUpdateResult result = - await plugin.updateBindings(id: 500, bindings: bindings); - expect(result, NotificationUpdateResult.success); - }); - - test('fail when ID is not found in simple cases', () async { - await plugin.show(501, '{title}', '{body}'); - final NotificationUpdateResult result = - await plugin.updateBindings(id: 599, bindings: bindings); - expect(result, NotificationUpdateResult.notFound); - }); - - test('are included in show()', () async { - await plugin.show( - 502, - '{title}', - '{body}', - details: const WindowsNotificationDetails(bindings: bindings), - ); - }); - - test('fail when notification has been cancelled', () async { - await Future.delayed(const Duration(milliseconds: 200)); - await plugin.show(503, '{title}', '{body}'); - final NotificationUpdateResult result = - await plugin.updateBindings(id: 503, bindings: bindings); - expect(result, NotificationUpdateResult.success); - await plugin.cancelAll(); - final NotificationUpdateResult result2 = - await plugin.updateBindings(id: 503, bindings: bindings); - expect(result2, NotificationUpdateResult.notFound); - }); - }); diff --git a/flutter_local_notifications_windows/test/details_test.dart b/flutter_local_notifications_windows/test/details_test.dart index ea90159cd..80f41f95a 100644 --- a/flutter_local_notifications_windows/test/details_test.dart +++ b/flutter_local_notifications_windows/test/details_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; import 'package:flutter_local_notifications_windows/src/details/notification_to_xml.dart'; +import 'package:flutter_local_notifications_windows/src/ffi/mock.dart'; import 'package:test/test.dart'; +import 'package:xml/xml.dart'; const WindowsInitializationSettings settings = WindowsInitializationSettings( appName: 'Test app', @@ -8,25 +10,45 @@ const WindowsInitializationSettings settings = WindowsInitializationSettings( guid: 'a8c22b55-049e-422f-b30f-863694de08c8', ); -extension PluginUtils on FlutterLocalNotificationsWindows { - static int id = 15; - - void testDetails(WindowsNotificationDetails details) => expect( - isValidXml( - notificationToXml( - title: 'title', - body: 'body', - payload: 'payload', - details: details, - ), - ), - isTrue, +extension on WindowsNotificationDetails { + String toXml() => notificationToXml( + title: 'title', + body: 'body', + payload: 'payload', + details: this, ); + + void check(List keywords) { + final String xml = toXml(); + expect( + () => XmlDocument.parse(toXml()), + returnsNormally, + reason: 'Was not a valid XML doc', + ); + for (final String keyword in keywords) { + expect(xml.contains(keyword), isTrue, + reason: 'Could not find $keyword in $xml'); + } + } + + void count(String keyword, int x) { + final String xml = toXml(); + expect( + () => XmlDocument.parse(toXml()), + returnsNormally, + reason: 'Was not a valid XML doc', + ); + expect( + keyword.allMatches(xml).length, + x, + reason: 'Could not find $keyword $x times in $xml', + ); + } } void main() => group('Details:', () { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); setUpAll(() => plugin.initialize(settings)); tearDownAll(() async { await plugin.cancelAll(); @@ -41,20 +63,24 @@ void main() => group('Details:', () { expect(plugin.show(-1, 'Negative ID', 'Body'), completes); }); - test( - 'Simple details', - () async => plugin - ..testDetails(const WindowsNotificationDetails()) - ..testDetails( - const WindowsNotificationDetails(subtitle: 'Subtitle')) - ..testDetails(const WindowsNotificationDetails( - duration: WindowsNotificationDuration.long)) - ..testDetails(const WindowsNotificationDetails( - scenario: WindowsNotificationScenario.reminder)) - ..testDetails(WindowsNotificationDetails(timestamp: DateTime.now())) - ..testDetails(const WindowsNotificationDetails( - subtitle: '{message}', - bindings: {'message': 'Hello, Mr. Person'}))); + test('Simple details', () { + const WindowsNotificationDetails().check([]); + const WindowsNotificationDetails(subtitle: 'Subtitle') + .check(['Subtitle']); + const WindowsNotificationDetails( + duration: WindowsNotificationDuration.long, + ).check(['long']); + const WindowsNotificationDetails( + scenario: WindowsNotificationScenario.reminder, + ).check(['reminder']); + final DateTime now = DateTime.now(); + WindowsNotificationDetails(timestamp: now) + .check([now.toIso8601String().split('.').first]); + const WindowsNotificationDetails( + subtitle: '{message}', + bindings: {'message': 'Hello, Mr. Person'}, + ).check(['binding', 'message']); + }); test('Actions', () { const WindowsAction simpleAction = @@ -68,13 +94,23 @@ void main() => group('Details:', () { tooltip: 'tooltip', imageUri: WindowsImage.getAssetUri('test/icon.png'), ); - plugin - ..testDetails(const WindowsNotificationDetails( - actions: [simpleAction])) - ..testDetails(WindowsNotificationDetails( - actions: [complexAction])) - ..testDetails(WindowsNotificationDetails( - actions: List.filled(5, simpleAction))); + const WindowsNotificationDetails( + actions: [simpleAction], + ).check(['Press me', '123']); + WindowsNotificationDetails( + actions: [complexAction], + ).check([ + 'content', + 'args', + 'pendingUpdate', + 'Success', + 'input-id', + 'tooltip', + 'test/icon.png', + ]); + WindowsNotificationDetails( + actions: List.filled(5, simpleAction), + ); expect( () => notificationToXml( details: WindowsNotificationDetails( @@ -84,14 +120,15 @@ void main() => group('Details:', () { ); }); - test( - 'Audio', - () => plugin - ..testDetails(WindowsNotificationDetails( - audio: WindowsNotificationAudio.silent())) - ..testDetails(WindowsNotificationDetails( - audio: WindowsNotificationAudio.preset( - sound: WindowsNotificationSound.call10)))); + test('Audio', () { + WindowsNotificationDetails( + audio: WindowsNotificationAudio.silent(), + ).check(['silent']); + WindowsNotificationDetails( + audio: WindowsNotificationAudio.preset( + sound: WindowsNotificationSound.call10), + ).check(['Call10']); + }); test('Rows', () { const WindowsColumn emptyColumn = @@ -107,19 +144,26 @@ void main() => group('Details:', () { final WindowsRow bigRow = WindowsRow( List.filled(5, simpleColumn), ); - plugin - ..testDetails(const WindowsNotificationDetails()) - ..testDetails(const WindowsNotificationDetails( - rows: [WindowsRow([])])) - ..testDetails(const WindowsNotificationDetails(rows: [ + const WindowsNotificationDetails( + rows: [WindowsRow([])], + ).check(['group']); + const WindowsNotificationDetails( + rows: [ WindowsRow([emptyColumn]) - ])) - ..testDetails(WindowsNotificationDetails(rows: [ + ], + ).check(['group', 'subgroup']); + WindowsNotificationDetails( + rows: [ WindowsRow([simpleColumn]) - ])) - ..testDetails(WindowsNotificationDetails(rows: [bigRow])) - ..testDetails(WindowsNotificationDetails( - rows: List.filled(5, bigRow))); + ], + ).check( + ['group', 'subgroup', 'test/icon.png', 'an icon', 'Text']); + WindowsNotificationDetails( + rows: [bigRow], + ).count('.filled(5, bigRow), + ).count(' group('Details:', () { arguments: 'args1', activation: WindowsHeaderActivation.foreground, ); - plugin - ..testDetails(const WindowsNotificationDetails(header: header)) - ..testDetails(const WindowsNotificationDetails(header: header)); + + const WindowsNotificationDetails(header: header) + .check(['header1', 'Header 1', 'args1', 'foreground']); }); - test('Images', () async { + test('Images', () { final WindowsImage simpleImage = WindowsImage( WindowsImage.getAssetUri('asset.png'), altText: 'an icon', ); final WindowsImage complexImage = WindowsImage( Uri.parse('https://picsum.photos/500'), - altText: 'an icon', + altText: 'an icon2', addQueryParams: true, crop: WindowsImageCrop.circle, placement: WindowsImagePlacement.appLogoOverride, ); - plugin - ..testDetails( - WindowsNotificationDetails(images: [simpleImage])) - ..testDetails(WindowsNotificationDetails( - images: [simpleImage, complexImage])) - ..testDetails( - WindowsNotificationDetails( - images: List.filled(6, simpleImage), - ), - ); + + WindowsNotificationDetails(images: [simpleImage]) + .check(['asset.png', 'an icon']); + WindowsNotificationDetails( + images: [simpleImage, complexImage], + ).check( + ['asset.png', 'an icon', 'picsum.photos/500', 'an icon2']); + + WindowsNotificationDetails( + images: List.filled(6, simpleImage), + ).count('asset.png', 6); }); - test('Inputs', () async { + test('Inputs', () { const WindowsTextInput textInput = WindowsTextInput( id: 'input', placeHolderContent: 'Text hint', @@ -177,22 +222,24 @@ void main() => group('Details:', () { arguments: 'submit', inputId: 'input', ); - plugin - ..testDetails(const WindowsNotificationDetails( - inputs: [textInput])) - ..testDetails(const WindowsNotificationDetails( - inputs: [selection])) - ..testDetails( - WindowsNotificationDetails( - inputs: List.filled(5, textInput), - ), - ) - ..testDetails(const WindowsNotificationDetails( - inputs: [textInput], - actions: [action])) - ..testDetails(const WindowsNotificationDetails( - inputs: [selection, textInput], - actions: [action])); + const WindowsNotificationDetails( + inputs: [textInput], + ).check(['input', 'Text hint', 'Text title']); + const WindowsNotificationDetails( + inputs: [selection], + ).check(['input', 'item1', 'item2', 'item3']); + + WindowsNotificationDetails( + inputs: List.filled(5, textInput), + ).count('Text hint', 5); + const WindowsNotificationDetails( + inputs: [textInput], + actions: [action], + ).check(['Text hint', 'Submit']); + const WindowsNotificationDetails( + inputs: [selection, textInput], + actions: [action], + ).check(['Text hint', 'item1', 'Submit']); expect( () => notificationToXml( details: WindowsNotificationDetails( @@ -203,7 +250,7 @@ void main() => group('Details:', () { ); }); - test('Progress', () async { + test('Progress', () { final WindowsProgressBar simple = WindowsProgressBar( id: 'simple', status: 'Testing...', @@ -216,42 +263,21 @@ void main() => group('Details:', () { label: 'Progress label', title: 'Progress title', ); - final WindowsProgressBar dynamic = WindowsProgressBar( - id: 'dynamic', - status: 'Testing...', - value: 0, - ); - plugin - ..testDetails(WindowsNotificationDetails( - progressBars: [simple])) - ..testDetails(WindowsNotificationDetails( - progressBars: [complex])) - ..testDetails(WindowsNotificationDetails( - progressBars: [simple, complex])) - ..testDetails( - WindowsNotificationDetails( - progressBars: List.filled(6, simple), - ), - ); - await plugin.show( - 201, - null, - null, - details: WindowsNotificationDetails( - progressBars: [dynamic], - ), - ); - for (double i = 0; i <= 1.5; i += 0.05) { - dynamic.value = i; - final NotificationUpdateResult result = await plugin - .updateProgressBar(notificationId: 201, progressBar: dynamic); - expect(result, NotificationUpdateResult.success); - await Future.delayed(const Duration(milliseconds: 10)); - } - expect( - await plugin.updateProgressBar( - notificationId: 202, progressBar: dynamic), - NotificationUpdateResult.notFound, - ); + WindowsNotificationDetails(progressBars: [simple]) + .check(['simple', 'Testing', 'simple-progressValue']); + WindowsNotificationDetails(progressBars: [complex]) + .check([ + 'complex', + 'Testing...', + 'complex-progressValue', + 'complex-progressString', + 'Progress title' + ]); + WindowsNotificationDetails( + progressBars: [simple, complex], + ).check(['simple', 'complex']); + WindowsNotificationDetails( + progressBars: List.filled(6, simple), + ).count('simple', 6); }); }); diff --git a/flutter_local_notifications_windows/test/plugin_test.dart b/flutter_local_notifications_windows/test/plugin_test.dart index 7564ec322..7a7f20806 100644 --- a/flutter_local_notifications_windows/test/plugin_test.dart +++ b/flutter_local_notifications_windows/test/plugin_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:flutter_local_notifications_windows/src/ffi/mock.dart'; import 'package:test/test.dart'; import 'package:timezone/data/latest_all.dart'; import 'package:timezone/standalone.dart'; @@ -22,7 +23,7 @@ void main() => group('Plugin', () { test('initializes safely', () async { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); final bool result = await plugin.initialize(goodSettings); expect(result, isTrue); plugin.dispose(); @@ -30,14 +31,14 @@ void main() => group('Plugin', () { test('catches bad GUIDs', () async { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); expect(plugin.initialize(badSettings), throwsArgumentError); plugin.dispose(); }); test('cannot be used before initializing', () async { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); final WindowsProgressBar progress = WindowsProgressBar(id: 'progress', status: 'Testing', value: 0); final TZDateTime now = TZDateTime.local(2024, 7, 18); @@ -65,7 +66,7 @@ void main() => group('Plugin', () { test('cannot be used after disposed', () async { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); final WindowsProgressBar progress = WindowsProgressBar(id: 'progress', status: 'Testing', value: 0); final TZDateTime now = TZDateTime.local(2024, 7, 18); @@ -93,7 +94,7 @@ void main() => group('Plugin', () { test('does not support repeating notifications', () async { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); await plugin.initialize(goodSettings); expect( plugin.periodicallyShow(0, null, null, RepeatInterval.everyMinute), diff --git a/flutter_local_notifications_windows/test/scheduled_test.dart b/flutter_local_notifications_windows/test/scheduled_test.dart index 08e2cbbf9..2ba8fd788 100644 --- a/flutter_local_notifications_windows/test/scheduled_test.dart +++ b/flutter_local_notifications_windows/test/scheduled_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:flutter_local_notifications_windows/src/ffi/mock.dart'; import 'package:test/test.dart'; import 'package:timezone/data/latest_all.dart'; import 'package:timezone/standalone.dart'; @@ -10,7 +11,7 @@ const WindowsInitializationSettings settings = WindowsInitializationSettings( void main() => group('Schedules', () { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); setUpAll(initializeTimeZones); setUpAll(() => plugin.initialize(settings)); tearDownAll(() async { @@ -18,18 +19,19 @@ void main() => group('Schedules', () { plugin.dispose(); }); - Future countPending() async => - (await plugin.pendingNotificationRequests()).length; late final Location location = getLocation('US/Eastern'); test('do not work with earlier time', () async { final TZDateTime now = TZDateTime.now(location); final TZDateTime earlier = now.subtract(const Duration(days: 1)); - await plugin.cancelAll(); - expect(await countPending(), 0); - expect(plugin.zonedSchedule(302, null, null, now, null), - throwsArgumentError); - expect(plugin.zonedSchedule(302, null, null, earlier, null), - throwsArgumentError); + final TZDateTime later = now.add(const Duration(days: 1)); + expect( + plugin.zonedSchedule(302, null, null, earlier, null), + throwsArgumentError, + ); + expect( + plugin.zonedSchedule(302, null, null, later, null), + completes, + ); }); }); diff --git a/flutter_local_notifications_windows/test/xml_test.dart b/flutter_local_notifications_windows/test/xml_test.dart index e2998f190..f9fdcb5e4 100644 --- a/flutter_local_notifications_windows/test/xml_test.dart +++ b/flutter_local_notifications_windows/test/xml_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:flutter_local_notifications_windows/src/ffi/mock.dart'; import 'package:test/test.dart'; const WindowsInitializationSettings settings = WindowsInitializationSettings( @@ -61,7 +62,7 @@ const String complexXml = ''' void main() { group('XML', () { final FlutterLocalNotificationsWindows plugin = - FlutterLocalNotificationsWindows(); + FlutterLocalNotificationsWindows.withBindings(MockBindings()); setUpAll(() => plugin.initialize(settings)); tearDownAll(() async {