From e426ab5e1ed8a417c19abf6a882911d139016fff Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:03:56 +0100 Subject: [PATCH 1/8] feat(push_notification_client): add PushNotificationResult and update method return types - Introduce PushNotificationResult class to encapsulate the result of bulk push notification send operations - Update sendNotification and sendBulkNotifications methods to return PushNotificationResult instead of void - These changes allow for better error handling and cleanup of invalid device tokens --- .../services/push_notification_client.dart | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart index 7277684..19d3173 100644 --- a/lib/src/services/push_notification_client.dart +++ b/lib/src/services/push_notification_client.dart @@ -1,4 +1,32 @@ import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// {@template push_notification_result} +/// Encapsulates the result of a bulk push notification send operation. +/// +/// This class provides structured feedback on which notifications were sent +/// successfully and which ones failed, including the specific device tokens +/// for each category. This is crucial for implementing self-healing mechanisms, +/// such as cleaning up invalid or unregistered device tokens from the database. +/// {@endtemplate} +@immutable +class PushNotificationResult extends Equatable { + /// {@macro push_notification_result} + const PushNotificationResult({ + this.sentTokens = const [], + this.failedTokens = const [], + }); + + /// A list of device tokens to which the notification was successfully sent. + final List sentTokens; + + /// A list of device tokens to which the notification failed to be sent. + final List failedTokens; + + @override + List get props => [sentTokens, failedTokens]; +} /// An abstract interface for push notification clients. /// @@ -9,7 +37,7 @@ abstract class IPushNotificationClient { /// /// [deviceToken]: The unique token identifying the target device. /// [payload]: The data payload to be sent with the notification. - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, }); @@ -21,7 +49,7 @@ abstract class IPushNotificationClient { /// /// [deviceTokens]: A list of unique tokens identifying the target devices. /// [payload]: The data payload to be sent with the notification. - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }); From 6dd93e82c51e2cfda2dafb01b52a9e2f91ca868f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:04:08 +0100 Subject: [PATCH 2/8] feat(firebase_push_notification_client): improve batch processing and result handling - Refactor sendNotification and sendBulkNotifications to return PushNotificationResult - Enhance _sendBatch to provide detailed success/failure information - Log invalid tokens at info level for cleanup purposes - Reduce log spam by downgrading some log levels - Simplify error handling and reporting --- .../firebase_push_notification_client.dart | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 09013fd..b6b6d14 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -18,8 +18,8 @@ class FirebasePushNotificationClient implements IPushNotificationClient { required this.projectId, required HttpClient httpClient, required Logger log, - }) : _httpClient = httpClient, - _log = log; + }) : _httpClient = httpClient, + _log = log; /// The Firebase Project ID for push notifications. final String projectId; @@ -27,28 +27,30 @@ class FirebasePushNotificationClient implements IPushNotificationClient { final Logger _log; @override - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - }) async { + }) { // For consistency, the single send method now delegates to the bulk // method with a list containing just one token. - await sendBulkNotifications( + return sendBulkNotifications( deviceTokens: [deviceToken], payload: payload, ); } @override - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }) async { if (deviceTokens.isEmpty) { _log.info('No device tokens provided for Firebase bulk send. Aborting.'); - return; + return const PushNotificationResult( + sentTokens: [], + failedTokens: [], + ); } - _log.info( 'Sending Firebase bulk notification to ${deviceTokens.length} devices ' 'for project "$projectId".', @@ -57,6 +59,9 @@ class FirebasePushNotificationClient implements IPushNotificationClient { // The FCM v1 batch API has a limit of 500 messages per request. // We must chunk the tokens into batches of this size. const batchSize = 500; + final allSentTokens = []; + final allFailedTokens = []; + for (var i = 0; i < deviceTokens.length; i += batchSize) { final batch = deviceTokens.sublist( i, @@ -66,22 +71,29 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); // Send each chunk as a separate batch request. - await _sendBatch( + final batchResult = await _sendBatch( batchNumber: (i ~/ batchSize) + 1, totalBatches: (deviceTokens.length / batchSize).ceil(), deviceTokens: batch, payload: payload, ); + + allSentTokens.addAll(batchResult.sentTokens); + allFailedTokens.addAll(batchResult.failedTokens); } + + return PushNotificationResult( + sentTokens: allSentTokens, + failedTokens: allFailedTokens, + ); } /// Sends a batch of notifications by dispatching individual requests in /// parallel. /// - /// This approach is simpler and more robust than using the `batch` endpoint, - /// as it avoids the complexity of constructing a multipart request body and - /// provides clearer error handling for individual message failures. - Future _sendBatch({ + /// This method processes the results to distinguish between successful and + /// failed sends, returning a [PushNotificationResult]. + Future _sendBatch({ required int batchNumber, required int totalBatches, required List deviceTokens, @@ -114,63 +126,52 @@ class FirebasePushNotificationClient implements IPushNotificationClient { return _httpClient.post(url, data: requestBody); }).toList(); - try { - // `eagerError: false` ensures that all futures complete, even if some - // fail. The results list will contain Exception objects for failures. - final results = await Future.wait( - sendFutures, - eagerError: false, - ); + // `eagerError: false` ensures that all futures complete, even if some + // fail. The results list will contain Exception objects for failures. + final results = await Future.wait( + sendFutures, + eagerError: false, + ); - final failedResults = results.whereType().toList(); + final sentTokens = []; + final failedTokens = []; - if (failedResults.isEmpty) { - _log.info( - 'Successfully sent Firebase batch of ${deviceTokens.length} ' - 'notifications for project "$projectId".', - ); - } else { - _log.warning( - 'Batch $batchNumber/$totalBatches: ' - '${failedResults.length} of ${deviceTokens.length} Firebase ' - 'notifications failed to send for project "$projectId".', - ); - for (final error in failedResults) { - if (error is HttpException) { - // Downgrade log level for invalid tokens (NotFoundException), which - // is an expected occurrence. Other HTTP errors are still severe. - if (error is NotFoundException) { - _log.info( - 'Batch $batchNumber/$totalBatches: Failed to send to an ' - 'invalid/unregistered token: ${error.message}', - ); - } else { - _log.severe( - 'Batch $batchNumber/$totalBatches: HTTP error sending ' - 'Firebase notification: ${error.message}', - error, - ); - } - } else { - _log.severe( - 'Unexpected error sending Firebase notification.', - error, - ); - } + for (var i = 0; i < results.length; i++) { + final result = results[i]; + final token = deviceTokens[i]; + + if (result is Exception) { + failedTokens.add(token); + if (result is NotFoundException) { + // This is an expected failure when a token is unregistered (e.g., + // app uninstalled). Log it as info for cleanup purposes. + _log.info( + 'Batch $batchNumber/$totalBatches: Failed to send to an ' + 'invalid/unregistered token: ${result.message}', + ); + } else if (result is HttpException) { + _log.severe( + 'Batch $batchNumber/$totalBatches: HTTP error sending ' + 'Firebase notification to token "$token": ${result.message}', + result, + ); + } else { + _log.severe( + 'Unexpected error sending Firebase notification to token "$token".', + result, + ); } - // Throw an exception to indicate that the batch send was not fully successful. - throw OperationFailedException( - 'Failed to send ${failedResults.length} Firebase notifications.', - ); + } else { + // If there's no exception, the send was successful. + sentTokens.add(token); } - } catch (e, s) { - _log.severe( - 'Unexpected error processing Firebase batch $batchNumber/$totalBatches ' - 'results.', - e, - s, - ); - throw OperationFailedException('Failed to process Firebase batch: $e'); } + _log.info( + 'Firebase batch $batchNumber/$totalBatches complete. Success: ${sentTokens.length}, Failed: ${failedTokens.length}.', + ); + return PushNotificationResult( + sentTokens: sentTokens, + failedTokens: failedTokens, + ); } } From a7ae884564df2fb833fb386086860b20fa235259 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:04:20 +0100 Subject: [PATCH 3/8] feat(notifications): enhance OneSignal integration with detailed send results - Update sendNotification and sendBulkNotifications to return PushNotificationResult - Implement logic to handle failed notifications within batches - Process OneSignal API response to identify invalid player IDs - Refactor _sendBatch to return detailed result of the send operation - Improve --- .../onesignal_push_notification_client.dart | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart index 6265eeb..47f3251 100644 --- a/lib/src/services/onesignal_push_notification_client.dart +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -27,25 +27,25 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { final Logger _log; @override - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - }) async { + }) { // For consistency, delegate to the bulk sending method with a single token. - await sendBulkNotifications( + return sendBulkNotifications( deviceTokens: [deviceToken], payload: payload, ); } @override - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }) async { if (deviceTokens.isEmpty) { _log.info('No device tokens provided for OneSignal bulk send. Aborting.'); - return; + return const PushNotificationResult(); } _log.info( @@ -55,6 +55,9 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { // OneSignal has a limit of 2000 player_ids per API request. const batchSize = 2000; + final allSentTokens = []; + final allFailedTokens = []; + for (var i = 0; i < deviceTokens.length; i += batchSize) { final batch = deviceTokens.sublist( i, @@ -63,15 +66,26 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { : i + batchSize, ); - await _sendBatch( + final batchResult = await _sendBatch( deviceTokens: batch, payload: payload, ); + + allSentTokens.addAll(batchResult.sentTokens); + allFailedTokens.addAll(batchResult.failedTokens); } + + return PushNotificationResult( + sentTokens: allSentTokens, + failedTokens: allFailedTokens, + ); } /// Sends a single batch of notifications to the OneSignal API. - Future _sendBatch({ + /// + /// This method processes the API response to distinguish between successful + /// and failed sends, returning a [PushNotificationResult]. + Future _sendBatch({ required List deviceTokens, required PushNotificationPayload payload, }) async { @@ -95,17 +109,49 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { ); try { - await _httpClient.post(url, data: requestBody); + // The OneSignal API returns a JSON object with details about the send, + // including errors for invalid player IDs. + final response = await _httpClient.post>( + url, + data: requestBody, + ); + + final sentTokens = {...deviceTokens}; + final failedTokens = {}; + + // Check for errors in the response body. + if (response.containsKey('errors') && response['errors'] != null) { + final errors = response['errors']; + if (errors is Map && errors.containsKey('invalid_player_ids')) { + final invalidIds = List.from( + errors['invalid_player_ids'] as List, + ); + if (invalidIds.isNotEmpty) { + _log.info( + 'OneSignal reported ${invalidIds.length} invalid player IDs. ' + 'These will be marked as failed.', + ); + failedTokens.addAll(invalidIds); + sentTokens.removeAll(invalidIds); + } + } + } + _log.info( - 'Successfully sent OneSignal batch of ${deviceTokens.length} ' - 'notifications for app ID "$appId".', + 'OneSignal batch complete. Success: ${sentTokens.length}, ' + 'Failed: ${failedTokens.length}.', + ); + return PushNotificationResult( + sentTokens: sentTokens.toList(), + failedTokens: failedTokens.toList(), ); } on HttpException catch (e) { _log.severe( 'HTTP error sending OneSignal batch notification: ${e.message}', e, ); - rethrow; + // If the entire request fails, all tokens in this batch are considered failed. + return PushNotificationResult(failedTokens: deviceTokens); } catch (e, s) { _log.severe( 'Unexpected error sending OneSignal batch notification.', From 792dbc8d1abe7c42e3fafe578fb12ba86a53fa0a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:04:46 +0100 Subject: [PATCH 4/8] feat(push_notifications): add cleanup --- .../services/push_notification_service.dart | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 85bf059..24e00a5 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -222,11 +222,24 @@ class DefaultPushNotificationService implements IPushNotificationService { if (userDeviceTokens.isNotEmpty) { // Add the send operation to the list of futures. + // The result of this future will contain information about which + // tokens succeeded and which failed. sendFutures.add( - client.sendBulkNotifications( - deviceTokens: userDeviceTokens, - payload: notification.payload, - ), + client + .sendBulkNotifications( + deviceTokens: userDeviceTokens, + payload: notification.payload, + ) + .then((result) { + // After the send completes, trigger the cleanup process for + // any failed tokens. This is a fire-and-forget operation. + unawaited( + _cleanupInvalidDevices( + result.failedTokens, + primaryProvider, + ), + ); + }), ); } } catch (e, s) { @@ -255,4 +268,50 @@ class DefaultPushNotificationService implements IPushNotificationService { ); } } + + /// Deletes device registrations associated with a list of invalid tokens. + /// + /// This method is called after a push notification send operation to prune + /// the database of tokens that the provider has identified as unregistered + /// or invalid (e.g., because the app was uninstalled). + /// + /// - [invalidTokens]: A list of device tokens that failed to be delivered. + /// - [provider]: The push notification provider that reported the tokens as + /// invalid. + Future _cleanupInvalidDevices( + List invalidTokens, + PushNotificationProvider provider, + ) async { + if (invalidTokens.isEmpty) { + return; // Nothing to clean up. + } + + _log.info( + 'Cleaning up ${invalidTokens.length} invalid device tokens for provider "$provider".', + ); + + // Retrieve the list of devices that match the filter criteria. + final devicesToDelete = await _pushNotificationDeviceRepository.readAll( + filter: { + 'providerTokens.${provider.name}': {r'$in': invalidTokens}, + }, + ); + + try { + // Delete the devices one by one. + // While this is less efficient than a bulk delete, it is necessary + // as `DataRepository` does not have a `deleteAll` method. + await Future.forEach(devicesToDelete.items, ( + device, + ) async { + await _pushNotificationDeviceRepository.delete(id: device.id); + }); + + _log.info('Successfully cleaned up invalid device tokens.'); + } catch (e, s) { + _log.severe('Failed to clean up invalid device tokens.', e, s); + // We log the error but do not rethrow, as this is a background + // cleanup task and should not crash the main application flow. + } + } } From b6d55f08a9b7313ec23e879e369d44ff9105d953 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:09:11 +0100 Subject: [PATCH 5/8] perf(push-notification): optimize push notification dispatch by grouping device tokens This commit improves the performance of the push notification dispatch process by grouping device tokens by user ID before sending notifications. This change avoids iterating through all devices for each user, which reduces the time complexity from O(N^2) to O(N), significantly speeding up the dispatch process. --- .../services/push_notification_service.dart | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 24e00a5..66c84cb 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -187,6 +187,21 @@ class DefaultPushNotificationService implements IPushNotificationService { 'Found ${targetedDevices.length} devices to target via $primaryProvider.', ); + // 6. Group device tokens by user ID for efficient lookup. + // This avoids iterating through all devices for each user. + final userDeviceTokensMap = >{}; + for (final device in targetedDevices) { + final token = device.providerTokens[primaryProvider]; + if (token != null) { + // If the user's list doesn't exist, create it. + if (!userDeviceTokensMap.containsKey(device.userId)) { + userDeviceTokensMap[device.userId] = []; + } + // Add the token to the user's list. + userDeviceTokensMap[device.userId]!.add(token); + } + } + // 7. Iterate through each subscribed user to create and send a // personalized notification. final sendFutures = >[]; @@ -214,11 +229,8 @@ class DefaultPushNotificationService implements IPushNotificationService { try { await _inAppNotificationRepository.create(item: notification); - // Find all device tokens for the current user. - final userDeviceTokens = targetedDevices - .where((d) => d.userId == userId) - .map((d) => d.providerTokens[primaryProvider]!) - .toList(); + // Efficiently retrieve the tokens for the current user from the map. + final userDeviceTokens = userDeviceTokensMap[userId] ?? []; if (userDeviceTokens.isNotEmpty) { // Add the send operation to the list of futures. From aa846d9ea88eb32f2fd6ec4ca02565df38bed8de Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:12:00 +0100 Subject: [PATCH 6/8] fix(firebase_push_notification): improve push notification error handling - Refactor error handling logic for better clarity and consistency - Add detailed comments to explain error handling decisions - Introduce nuanced error handling for different exception types: - Treat NotFoundException as a permanent failure and mark token for cleanup - Log other HTTP errors as severe but do not mark for deletion - Improve log messages with more specific information and context --- .../services/firebase_push_notification_client.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index b6b6d14..d4135e6 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -18,8 +18,8 @@ class FirebasePushNotificationClient implements IPushNotificationClient { required this.projectId, required HttpClient httpClient, required Logger log, - }) : _httpClient = httpClient, - _log = log; + }) : _httpClient = httpClient, + _log = log; /// The Firebase Project ID for push notifications. final String projectId; @@ -141,21 +141,24 @@ class FirebasePushNotificationClient implements IPushNotificationClient { final token = deviceTokens[i]; if (result is Exception) { - failedTokens.add(token); if (result is NotFoundException) { - // This is an expected failure when a token is unregistered (e.g., - // app uninstalled). Log it as info for cleanup purposes. + // This is the only case where we treat the token as permanently + // invalid and mark it for cleanup. + failedTokens.add(token); _log.info( 'Batch $batchNumber/$totalBatches: Failed to send to an ' 'invalid/unregistered token: ${result.message}', ); } else if (result is HttpException) { + // For other HTTP errors (e.g., 500), we log it as severe but do + // not mark the token for deletion as the error may be transient. _log.severe( 'Batch $batchNumber/$totalBatches: HTTP error sending ' 'Firebase notification to token "$token": ${result.message}', result, ); } else { + // For any other unexpected exception. _log.severe( 'Unexpected error sending Firebase notification to token "$token".', result, From 25823498c4bcaf1a8d3d0f62ee337916c29d5265 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 08:20:39 +0100 Subject: [PATCH 7/8] docs(README): add intelligent self-healing delivery feature description - Add a new bullet point describing the notification engine's ability to automatically detect and prune invalid device tokens - This feature ensures long-term reliability and efficient notification delivery --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7aaf2bd..1a8ba32 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ A complete, multi-provider notification engine that empowers you to engage users - **User-Crafted Notification Streams:** Users can create and save persistent **Saved Headline Filters** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that filter, receiving alerts only for the news they care about. - **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests. - **Provider Agnostic:** The engine is built to be provider-agnostic, with out-of-the-box support for Firebase (FCM) and OneSignal. The active provider can be switched remotely without any code changes. +- **Intelligent, Self-Healing Delivery:** The notification engine is designed for long-term reliability. It automatically detects and prunes invalid device tokens—for example, when a user uninstalls the app—ensuring the system remains efficient and notifications are only sent to active devices. > **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard. From bd7fb4c735bf71266425237a7988b1a3fa76df5f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 09:01:54 +0100 Subject: [PATCH 8/8] refactor(push-notification): improve device token map population and deletion process - Use `putIfAbsent` for concise and efficient population of userDeviceTokensMap - Replace sequential device deletion with parallel deletion using `Future.wait` for improved performance --- .../services/push_notification_service.dart | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 66c84cb..cb1fe65 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -193,12 +193,9 @@ class DefaultPushNotificationService implements IPushNotificationService { for (final device in targetedDevices) { final token = device.providerTokens[primaryProvider]; if (token != null) { - // If the user's list doesn't exist, create it. - if (!userDeviceTokensMap.containsKey(device.userId)) { - userDeviceTokensMap[device.userId] = []; - } - // Add the token to the user's list. - userDeviceTokensMap[device.userId]!.add(token); + // The `putIfAbsent` method provides a concise way to ensure the list + // exists before adding the token to it. + userDeviceTokensMap.putIfAbsent(device.userId, () => []).add(token); } } @@ -310,14 +307,11 @@ class DefaultPushNotificationService implements IPushNotificationService { ); try { - // Delete the devices one by one. - // While this is less efficient than a bulk delete, it is necessary - // as `DataRepository` does not have a `deleteAll` method. - await Future.forEach(devicesToDelete.items, ( - device, - ) async { - await _pushNotificationDeviceRepository.delete(id: device.id); - }); + // Delete the devices in parallel for better performance. + final deleteFutures = devicesToDelete.items.map( + (device) => _pushNotificationDeviceRepository.delete(id: device.id), + ); + await Future.wait(deleteFutures); _log.info('Successfully cleaned up invalid device tokens.'); } catch (e, s) {