Skip to content

Commit b7557ef

Browse files
authored
Merge pull request #102 from flutter-news-app-full-source-code/feat/Implement-Server-Side-Push-Token-Cleanup
Feat/implement server side push token cleanup
2 parents 6709962 + bd7fb4c commit b7557ef

File tree

5 files changed

+230
-86
lines changed

5 files changed

+230
-86
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ A complete, multi-provider notification engine that empowers you to engage users
7979
- **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.
8080
- **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests.
8181
- **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.
82+
- **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.
8283
> **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard.
8384
8485
</details>

lib/src/services/firebase_push_notification_client.dart

Lines changed: 68 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -27,28 +27,30 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
2727
final Logger _log;
2828

2929
@override
30-
Future<void> sendNotification({
30+
Future<PushNotificationResult> sendNotification({
3131
required String deviceToken,
3232
required PushNotificationPayload payload,
33-
}) async {
33+
}) {
3434
// For consistency, the single send method now delegates to the bulk
3535
// method with a list containing just one token.
36-
await sendBulkNotifications(
36+
return sendBulkNotifications(
3737
deviceTokens: [deviceToken],
3838
payload: payload,
3939
);
4040
}
4141

4242
@override
43-
Future<void> sendBulkNotifications({
43+
Future<PushNotificationResult> sendBulkNotifications({
4444
required List<String> deviceTokens,
4545
required PushNotificationPayload payload,
4646
}) async {
4747
if (deviceTokens.isEmpty) {
4848
_log.info('No device tokens provided for Firebase bulk send. Aborting.');
49-
return;
49+
return const PushNotificationResult(
50+
sentTokens: [],
51+
failedTokens: [],
52+
);
5053
}
51-
5254
_log.info(
5355
'Sending Firebase bulk notification to ${deviceTokens.length} devices '
5456
'for project "$projectId".',
@@ -57,6 +59,9 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
5759
// The FCM v1 batch API has a limit of 500 messages per request.
5860
// We must chunk the tokens into batches of this size.
5961
const batchSize = 500;
62+
final allSentTokens = <String>[];
63+
final allFailedTokens = <String>[];
64+
6065
for (var i = 0; i < deviceTokens.length; i += batchSize) {
6166
final batch = deviceTokens.sublist(
6267
i,
@@ -66,22 +71,29 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
6671
);
6772

6873
// Send each chunk as a separate batch request.
69-
await _sendBatch(
74+
final batchResult = await _sendBatch(
7075
batchNumber: (i ~/ batchSize) + 1,
7176
totalBatches: (deviceTokens.length / batchSize).ceil(),
7277
deviceTokens: batch,
7378
payload: payload,
7479
);
80+
81+
allSentTokens.addAll(batchResult.sentTokens);
82+
allFailedTokens.addAll(batchResult.failedTokens);
7583
}
84+
85+
return PushNotificationResult(
86+
sentTokens: allSentTokens,
87+
failedTokens: allFailedTokens,
88+
);
7689
}
7790

7891
/// Sends a batch of notifications by dispatching individual requests in
7992
/// parallel.
8093
///
81-
/// This approach is simpler and more robust than using the `batch` endpoint,
82-
/// as it avoids the complexity of constructing a multipart request body and
83-
/// provides clearer error handling for individual message failures.
84-
Future<void> _sendBatch({
94+
/// This method processes the results to distinguish between successful and
95+
/// failed sends, returning a [PushNotificationResult].
96+
Future<PushNotificationResult> _sendBatch({
8597
required int batchNumber,
8698
required int totalBatches,
8799
required List<String> deviceTokens,
@@ -114,63 +126,55 @@ class FirebasePushNotificationClient implements IPushNotificationClient {
114126
return _httpClient.post<void>(url, data: requestBody);
115127
}).toList();
116128

117-
try {
118-
// `eagerError: false` ensures that all futures complete, even if some
119-
// fail. The results list will contain Exception objects for failures.
120-
final results = await Future.wait<dynamic>(
121-
sendFutures,
122-
eagerError: false,
123-
);
129+
// `eagerError: false` ensures that all futures complete, even if some
130+
// fail. The results list will contain Exception objects for failures.
131+
final results = await Future.wait<dynamic>(
132+
sendFutures,
133+
eagerError: false,
134+
);
124135

125-
final failedResults = results.whereType<Exception>().toList();
136+
final sentTokens = <String>[];
137+
final failedTokens = <String>[];
126138

127-
if (failedResults.isEmpty) {
128-
_log.info(
129-
'Successfully sent Firebase batch of ${deviceTokens.length} '
130-
'notifications for project "$projectId".',
131-
);
132-
} else {
133-
_log.warning(
134-
'Batch $batchNumber/$totalBatches: '
135-
'${failedResults.length} of ${deviceTokens.length} Firebase '
136-
'notifications failed to send for project "$projectId".',
137-
);
138-
for (final error in failedResults) {
139-
if (error is HttpException) {
140-
// Downgrade log level for invalid tokens (NotFoundException), which
141-
// is an expected occurrence. Other HTTP errors are still severe.
142-
if (error is NotFoundException) {
143-
_log.info(
144-
'Batch $batchNumber/$totalBatches: Failed to send to an '
145-
'invalid/unregistered token: ${error.message}',
146-
);
147-
} else {
148-
_log.severe(
149-
'Batch $batchNumber/$totalBatches: HTTP error sending '
150-
'Firebase notification: ${error.message}',
151-
error,
152-
);
153-
}
154-
} else {
155-
_log.severe(
156-
'Unexpected error sending Firebase notification.',
157-
error,
158-
);
159-
}
139+
for (var i = 0; i < results.length; i++) {
140+
final result = results[i];
141+
final token = deviceTokens[i];
142+
143+
if (result is Exception) {
144+
if (result is NotFoundException) {
145+
// This is the only case where we treat the token as permanently
146+
// invalid and mark it for cleanup.
147+
failedTokens.add(token);
148+
_log.info(
149+
'Batch $batchNumber/$totalBatches: Failed to send to an '
150+
'invalid/unregistered token: ${result.message}',
151+
);
152+
} else if (result is HttpException) {
153+
// For other HTTP errors (e.g., 500), we log it as severe but do
154+
// not mark the token for deletion as the error may be transient.
155+
_log.severe(
156+
'Batch $batchNumber/$totalBatches: HTTP error sending '
157+
'Firebase notification to token "$token": ${result.message}',
158+
result,
159+
);
160+
} else {
161+
// For any other unexpected exception.
162+
_log.severe(
163+
'Unexpected error sending Firebase notification to token "$token".',
164+
result,
165+
);
160166
}
161-
// Throw an exception to indicate that the batch send was not fully successful.
162-
throw OperationFailedException(
163-
'Failed to send ${failedResults.length} Firebase notifications.',
164-
);
167+
} else {
168+
// If there's no exception, the send was successful.
169+
sentTokens.add(token);
165170
}
166-
} catch (e, s) {
167-
_log.severe(
168-
'Unexpected error processing Firebase batch $batchNumber/$totalBatches '
169-
'results.',
170-
e,
171-
s,
172-
);
173-
throw OperationFailedException('Failed to process Firebase batch: $e');
174171
}
172+
_log.info(
173+
'Firebase batch $batchNumber/$totalBatches complete. Success: ${sentTokens.length}, Failed: ${failedTokens.length}.',
174+
);
175+
return PushNotificationResult(
176+
sentTokens: sentTokens,
177+
failedTokens: failedTokens,
178+
);
175179
}
176180
}

lib/src/services/onesignal_push_notification_client.dart

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,25 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
2727
final Logger _log;
2828

2929
@override
30-
Future<void> sendNotification({
30+
Future<PushNotificationResult> sendNotification({
3131
required String deviceToken,
3232
required PushNotificationPayload payload,
33-
}) async {
33+
}) {
3434
// For consistency, delegate to the bulk sending method with a single token.
35-
await sendBulkNotifications(
35+
return sendBulkNotifications(
3636
deviceTokens: [deviceToken],
3737
payload: payload,
3838
);
3939
}
4040

4141
@override
42-
Future<void> sendBulkNotifications({
42+
Future<PushNotificationResult> sendBulkNotifications({
4343
required List<String> deviceTokens,
4444
required PushNotificationPayload payload,
4545
}) async {
4646
if (deviceTokens.isEmpty) {
4747
_log.info('No device tokens provided for OneSignal bulk send. Aborting.');
48-
return;
48+
return const PushNotificationResult();
4949
}
5050

5151
_log.info(
@@ -55,6 +55,9 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
5555

5656
// OneSignal has a limit of 2000 player_ids per API request.
5757
const batchSize = 2000;
58+
final allSentTokens = <String>[];
59+
final allFailedTokens = <String>[];
60+
5861
for (var i = 0; i < deviceTokens.length; i += batchSize) {
5962
final batch = deviceTokens.sublist(
6063
i,
@@ -63,15 +66,26 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
6366
: i + batchSize,
6467
);
6568

66-
await _sendBatch(
69+
final batchResult = await _sendBatch(
6770
deviceTokens: batch,
6871
payload: payload,
6972
);
73+
74+
allSentTokens.addAll(batchResult.sentTokens);
75+
allFailedTokens.addAll(batchResult.failedTokens);
7076
}
77+
78+
return PushNotificationResult(
79+
sentTokens: allSentTokens,
80+
failedTokens: allFailedTokens,
81+
);
7182
}
7283

7384
/// Sends a single batch of notifications to the OneSignal API.
74-
Future<void> _sendBatch({
85+
///
86+
/// This method processes the API response to distinguish between successful
87+
/// and failed sends, returning a [PushNotificationResult].
88+
Future<PushNotificationResult> _sendBatch({
7589
required List<String> deviceTokens,
7690
required PushNotificationPayload payload,
7791
}) async {
@@ -95,17 +109,49 @@ class OneSignalPushNotificationClient implements IPushNotificationClient {
95109
);
96110

97111
try {
98-
await _httpClient.post<void>(url, data: requestBody);
112+
// The OneSignal API returns a JSON object with details about the send,
113+
// including errors for invalid player IDs.
114+
final response = await _httpClient.post<Map<String, dynamic>>(
115+
url,
116+
data: requestBody,
117+
);
118+
119+
final sentTokens = <String>{...deviceTokens};
120+
final failedTokens = <String>{};
121+
122+
// Check for errors in the response body.
123+
if (response.containsKey('errors') && response['errors'] != null) {
124+
final errors = response['errors'];
125+
if (errors is Map && errors.containsKey('invalid_player_ids')) {
126+
final invalidIds = List<String>.from(
127+
errors['invalid_player_ids'] as List,
128+
);
129+
if (invalidIds.isNotEmpty) {
130+
_log.info(
131+
'OneSignal reported ${invalidIds.length} invalid player IDs. '
132+
'These will be marked as failed.',
133+
);
134+
failedTokens.addAll(invalidIds);
135+
sentTokens.removeAll(invalidIds);
136+
}
137+
}
138+
}
139+
99140
_log.info(
100-
'Successfully sent OneSignal batch of ${deviceTokens.length} '
101-
'notifications for app ID "$appId".',
141+
'OneSignal batch complete. Success: ${sentTokens.length}, '
142+
'Failed: ${failedTokens.length}.',
143+
);
144+
return PushNotificationResult(
145+
sentTokens: sentTokens.toList(),
146+
failedTokens: failedTokens.toList(),
102147
);
103148
} on HttpException catch (e) {
104149
_log.severe(
105150
'HTTP error sending OneSignal batch notification: ${e.message}',
106151
e,
107152
);
108-
rethrow;
153+
// If the entire request fails, all tokens in this batch are considered failed.
154+
return PushNotificationResult(failedTokens: deviceTokens);
109155
} catch (e, s) {
110156
_log.severe(
111157
'Unexpected error sending OneSignal batch notification.',

lib/src/services/push_notification_client.dart

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,32 @@
11
import 'package:core/core.dart';
2+
import 'package:equatable/equatable.dart';
3+
import 'package:meta/meta.dart';
4+
5+
/// {@template push_notification_result}
6+
/// Encapsulates the result of a bulk push notification send operation.
7+
///
8+
/// This class provides structured feedback on which notifications were sent
9+
/// successfully and which ones failed, including the specific device tokens
10+
/// for each category. This is crucial for implementing self-healing mechanisms,
11+
/// such as cleaning up invalid or unregistered device tokens from the database.
12+
/// {@endtemplate}
13+
@immutable
14+
class PushNotificationResult extends Equatable {
15+
/// {@macro push_notification_result}
16+
const PushNotificationResult({
17+
this.sentTokens = const [],
18+
this.failedTokens = const [],
19+
});
20+
21+
/// A list of device tokens to which the notification was successfully sent.
22+
final List<String> sentTokens;
23+
24+
/// A list of device tokens to which the notification failed to be sent.
25+
final List<String> failedTokens;
26+
27+
@override
28+
List<Object> get props => [sentTokens, failedTokens];
29+
}
230

331
/// An abstract interface for push notification clients.
432
///
@@ -9,7 +37,7 @@ abstract class IPushNotificationClient {
937
///
1038
/// [deviceToken]: The unique token identifying the target device.
1139
/// [payload]: The data payload to be sent with the notification.
12-
Future<void> sendNotification({
40+
Future<PushNotificationResult> sendNotification({
1341
required String deviceToken,
1442
required PushNotificationPayload payload,
1543
});
@@ -21,7 +49,7 @@ abstract class IPushNotificationClient {
2149
///
2250
/// [deviceTokens]: A list of unique tokens identifying the target devices.
2351
/// [payload]: The data payload to be sent with the notification.
24-
Future<void> sendBulkNotifications({
52+
Future<PushNotificationResult> sendBulkNotifications({
2553
required List<String> deviceTokens,
2654
required PushNotificationPayload payload,
2755
});

0 commit comments

Comments
 (0)