Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>
Expand Down
132 changes: 68 additions & 64 deletions lib/src/services/firebase_push_notification_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,30 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
final Logger _log;

@override
Future<void> sendNotification({
Future<PushNotificationResult> 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<void> sendBulkNotifications({
Future<PushNotificationResult> sendBulkNotifications({
required List<String> 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".',
Expand All @@ -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 = <String>[];
final allFailedTokens = <String>[];

for (var i = 0; i < deviceTokens.length; i += batchSize) {
final batch = deviceTokens.sublist(
i,
Expand All @@ -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<void> _sendBatch({
/// This method processes the results to distinguish between successful and
/// failed sends, returning a [PushNotificationResult].
Future<PushNotificationResult> _sendBatch({
required int batchNumber,
required int totalBatches,
required List<String> deviceTokens,
Expand Down Expand Up @@ -114,63 +126,55 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
return _httpClient.post<void>(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<dynamic>(
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<dynamic>(
sendFutures,
eagerError: false,
);

final failedResults = results.whereType<Exception>().toList();
final sentTokens = <String>[];
final failedTokens = <String>[];

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) {
if (result is NotFoundException) {
// 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,
);
}
// 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,
);
}
}
68 changes: 57 additions & 11 deletions lib/src/services/onesignal_push_notification_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,25 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
final Logger _log;

@override
Future<void> sendNotification({
Future<PushNotificationResult> 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<void> sendBulkNotifications({
Future<PushNotificationResult> sendBulkNotifications({
required List<String> 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(
Expand All @@ -55,6 +55,9 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {

// OneSignal has a limit of 2000 player_ids per API request.
const batchSize = 2000;
final allSentTokens = <String>[];
final allFailedTokens = <String>[];

for (var i = 0; i < deviceTokens.length; i += batchSize) {
final batch = deviceTokens.sublist(
i,
Expand All @@ -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<void> _sendBatch({
///
/// This method processes the API response to distinguish between successful
/// and failed sends, returning a [PushNotificationResult].
Future<PushNotificationResult> _sendBatch({
required List<String> deviceTokens,
required PushNotificationPayload payload,
}) async {
Expand All @@ -95,17 +109,49 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
);

try {
await _httpClient.post<void>(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<Map<String, dynamic>>(
url,
data: requestBody,
);

final sentTokens = <String>{...deviceTokens};
final failedTokens = <String>{};

// 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<String>.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.',
Expand Down
32 changes: 30 additions & 2 deletions lib/src/services/push_notification_client.dart
Original file line number Diff line number Diff line change
@@ -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<String> sentTokens;

/// A list of device tokens to which the notification failed to be sent.
final List<String> failedTokens;

@override
List<Object> get props => [sentTokens, failedTokens];
}

/// An abstract interface for push notification clients.
///
Expand All @@ -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<void> sendNotification({
Future<PushNotificationResult> sendNotification({
required String deviceToken,
required PushNotificationPayload payload,
});
Expand All @@ -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<void> sendBulkNotifications({
Future<PushNotificationResult> sendBulkNotifications({
required List<String> deviceTokens,
required PushNotificationPayload payload,
});
Expand Down
Loading
Loading