Skip to content

Commit eba2604

Browse files
committed
feat(notifications): implement push notification token refresh handling
- Add AppPushNotificationTokenRefreshed event to app_bloc.dart - Implement token refresh listeners in Firebase and OneSignal services - Update device registration process to use delete-then-create pattern - Add error handling and logging for token refresh and device registration
1 parent 32f4958 commit eba2604

File tree

5 files changed

+125
-19
lines changed

5 files changed

+125
-19
lines changed

lib/app/bloc/app_bloc.dart

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ class AppBloc extends Bloc<AppEvent, AppState> {
8787
);
8888
on<AppInAppNotificationReceived>(_onAppInAppNotificationReceived);
8989
on<AppLogoutRequested>(_onLogoutRequested);
90+
on<AppPushNotificationTokenRefreshed>(_onAppPushNotificationTokenRefreshed);
91+
92+
// Listen to token refresh events from the push notification service.
93+
// When a token is refreshed, dispatch an event to trigger device
94+
// re-registration with the backend.
95+
_pushNotificationService.onTokenRefreshed.listen((_) {
96+
add(const AppPushNotificationTokenRefreshed());
97+
});
9098
}
9199

92100
final Logger _logger;
@@ -117,6 +125,14 @@ class AppBloc extends Bloc<AppEvent, AppState> {
117125
'[AppBloc] App started with user ${state.user!.id}. '
118126
'Attempting to register device for push notifications.',
119127
);
128+
// The PushNotificationService will handle getting the token and
129+
// calling the repository's create method. This call is now wrapped in a
130+
// try-catch to prevent unhandled exceptions from crashing the app if
131+
// registration fails.
132+
//
133+
// The `registerDevice` method now implements a "delete-then-create"
134+
// pattern to ensure idempotency and align with backend expectations.
135+
// It uses a composite ID of userId and provider name.
120136
try {
121137
await _pushNotificationService.registerDevice(userId: state.user!.id);
122138
add(const AppPushNotificationDeviceRegistered());
@@ -242,11 +258,19 @@ class AppBloc extends Bloc<AppEvent, AppState> {
242258
'for push notifications.',
243259
);
244260
try {
245-
// The PushNotificationService will handle getting the token and
246-
// calling the repository's update method.
261+
// The PushNotificationService will handle getting the token and calling
262+
// the repository's create method. This call is now wrapped in a
263+
// try-catch to prevent unhandled exceptions from crashing the app if
264+
// registration fails.
265+
//
266+
// The `registerDevice` method now implements a "delete-then-create"
267+
// pattern to ensure idempotency and align with backend expectations.
268+
// It uses a composite ID of userId and provider name.
247269
await _pushNotificationService.registerDevice(userId: user.id);
248270
add(const AppPushNotificationDeviceRegistered());
249271
} catch (e, s) {
272+
// Log the error but allow the app to continue functioning.
273+
// The user can still use the app even if push registration fails.
250274
_logger.severe(
251275
'[AppBloc] Failed to register push notification device for user ${user.id}.',
252276
e,
@@ -640,4 +664,26 @@ class AppBloc extends Bloc<AppEvent, AppState> {
640664
) {
641665
emit(state.copyWith(hasUnreadInAppNotifications: true));
642666
}
667+
668+
/// Handles the [AppPushNotificationTokenRefreshed] event.
669+
///
670+
/// This event is triggered when the underlying push notification provider
671+
/// (e.g., FCM, OneSignal) refreshes its device token. The AppBloc then
672+
/// attempts to re-register the device with the backend using the current
673+
/// user's ID.
674+
Future<void> _onAppPushNotificationTokenRefreshed(
675+
AppPushNotificationTokenRefreshed event,
676+
Emitter<AppState> emit,
677+
) async {
678+
if (state.user == null) {
679+
_logger.info(
680+
'[AppBloc] Skipping token re-registration: User is null.',
681+
);
682+
return;
683+
}
684+
_logger.info(
685+
'[AppBloc] Push notification token refreshed. Re-registering device.',
686+
);
687+
await _pushNotificationService.registerDevice(userId: state.user!.id);
688+
}
643689
}

lib/app/bloc/app_event.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,15 @@ class AppInAppNotificationReceived extends AppEvent {
205205
/// {@macro app_in_app_notification_received}
206206
const AppInAppNotificationReceived();
207207
}
208+
209+
/// {@template app_push_notification_token_refreshed}
210+
/// Dispatched when the underlying push notification provider refreshes its
211+
/// device token.
212+
///
213+
/// This event triggers the AppBloc to re-register the device with the backend
214+
/// using the current user's ID.
215+
/// {@endtemplate}
216+
class AppPushNotificationTokenRefreshed extends AppEvent {
217+
/// {@macro app_push_notification_token_refreshed}
218+
const AppPushNotificationTokenRefreshed();
219+
}

lib/notifications/services/firebase_push_notification_service.dart

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class FirebasePushNotificationService implements PushNotificationService {
2525
final _onMessageController = StreamController<PushNotificationPayload>();
2626
final _onMessageOpenedAppController =
2727
StreamController<PushNotificationPayload>();
28+
final _onTokenRefreshedController = StreamController<String>();
2829

2930
@override
3031
Stream<PushNotificationPayload> get onMessage => _onMessageController.stream;
@@ -33,16 +34,19 @@ class FirebasePushNotificationService implements PushNotificationService {
3334
Stream<PushNotificationPayload> get onMessageOpenedApp =>
3435
_onMessageOpenedAppController.stream;
3536

37+
@override
38+
Stream<String> get onTokenRefreshed => _onTokenRefreshedController.stream;
39+
3640
@override
3741
Future<void> initialize() async {
3842
_logger.info('Initializing FirebasePushNotificationService...');
3943

4044
// Listen for token refresh events from FCM. If the token changes while
4145
// the app is running, re-register the device with the new token to
4246
// ensure continued notification delivery.
43-
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
44-
_logger.info('FCM token refreshed. Re-registering device.');
45-
registerDevice(userId: ''); // userId will be updated by AppBloc
47+
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
48+
_logger.info('FCM token refreshed. Emitting new token.');
49+
_onTokenRefreshedController.add(newToken);
4650
});
4751

4852
// Handle messages that are tapped and open the app from a terminated state.
@@ -110,8 +114,25 @@ class FirebasePushNotificationService implements PushNotificationService {
110114
}
111115

112116
_logger.fine('FCM token received for registration: $token');
113-
final device = PushNotificationDevice(
114-
id: token, // Use token as a unique ID for the device
117+
// The device ID is now a composite key of userId and provider name to
118+
// ensure idempotency and align with the backend's delete-then-create
119+
// pattern.
120+
final deviceId = '${userId}_${PushNotificationProvider.firebase.name}';
121+
122+
// First, attempt to delete any existing device registration for this user
123+
// and provider. This ensures a clean state and handles token updates
124+
// by effectively performing a "delete-then-create".
125+
try {
126+
await _pushNotificationDeviceRepository.delete(id: deviceId);
127+
_logger.info('Existing device registration deleted for $deviceId.');
128+
} on NotFoundException {
129+
_logger.info('No existing device registration found for $deviceId. Proceeding with creation.');
130+
} catch (e, s) {
131+
_logger.warning('Failed to delete existing device registration for $deviceId. Proceeding with creation anyway. Error: $e', e, s);
132+
}
133+
134+
final newDevice = PushNotificationDevice(
135+
id: deviceId,
115136
userId: userId,
116137
platform: Platform.isIOS ? DevicePlatform.ios : DevicePlatform.android,
117138
providerTokens: {PushNotificationProvider.firebase: token},
@@ -120,9 +141,7 @@ class FirebasePushNotificationService implements PushNotificationService {
120141
updatedAt: DateTime.now(),
121142
);
122143

123-
// Use the standard `update` method from the repository for an
124-
// idempotent "upsert" operation. The device token is the resource ID.
125-
await _pushNotificationDeviceRepository.update(id: token, item: device);
144+
await _pushNotificationDeviceRepository.create(item: newDevice);
126145
_logger.info('Device successfully registered with backend.');
127146
} catch (e, s) {
128147
_logger.severe('Failed to register device.', e, s);
@@ -147,6 +166,7 @@ class FirebasePushNotificationService implements PushNotificationService {
147166
Future<void> close() async {
148167
await _onMessageController.close();
149168
await _onMessageOpenedAppController.close();
169+
await _onTokenRefreshedController.close();
150170
}
151171

152172
@override

lib/notifications/services/one_signal_push_notification_service.dart

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class OneSignalPushNotificationService extends PushNotificationService {
2727
final _onMessageController = StreamController<PushNotificationPayload>();
2828
final _onMessageOpenedAppController =
2929
StreamController<PushNotificationPayload>();
30+
final _onTokenRefreshedController = StreamController<String>();
3031

3132
// OneSignal doesn't have a direct equivalent of `getInitialMessage`.
3233
// We rely on the `setNotificationOpenedHandler`.
@@ -40,6 +41,9 @@ class OneSignalPushNotificationService extends PushNotificationService {
4041
Stream<PushNotificationPayload> get onMessageOpenedApp =>
4142
_onMessageOpenedAppController.stream;
4243

44+
@override
45+
Stream<String> get onTokenRefreshed => _onTokenRefreshedController.stream;
46+
4347
@override
4448
Future<void> initialize() async {
4549
_logger.info('Initializing OneSignalPushNotificationService...');
@@ -49,10 +53,12 @@ class OneSignalPushNotificationService extends PushNotificationService {
4953
// Listen for changes to the push subscription state. If the token (player
5054
// ID) changes, re-register the device with the new token to ensure
5155
// continued notification delivery.
52-
OneSignal.User.pushSubscription.addObserver((state) {
53-
if (state.current.id != state.previous.id) {
54-
_logger.info('OneSignal push subscription ID changed. Re-registering.');
55-
registerDevice(userId: ''); // userId will be updated by AppBloc
56+
OneSignal.User.pushSubscription.addObserver((state) async {
57+
if (state.current.id != state.previous.id && state.current.id != null) {
58+
_logger.info(
59+
'OneSignal push subscription ID changed. Emitting new token.',
60+
);
61+
_onTokenRefreshedController.add(state.current.id!);
5662
}
5763
});
5864

@@ -109,18 +115,33 @@ class OneSignalPushNotificationService extends PushNotificationService {
109115
}
110116

111117
_logger.fine('OneSignal Player ID received: $token');
112-
final device = PushNotificationDevice(
113-
id: token, // Use player ID as a unique ID for the device
118+
// The device ID is now a composite key of userId and provider name to
119+
// ensure idempotency and align with the backend's delete-then-create
120+
// pattern.
121+
final deviceId = '${userId}_${PushNotificationProvider.oneSignal.name}';
122+
123+
// First, attempt to delete any existing device registration for this user
124+
// and provider. This ensures a clean state and handles token updates
125+
// by effectively performing a "delete-then-create".
126+
try {
127+
await _pushNotificationDeviceRepository.delete(id: deviceId);
128+
_logger.info('Existing device registration deleted for $deviceId.');
129+
} on NotFoundException {
130+
_logger.info('No existing device registration found for $deviceId. Proceeding with creation.');
131+
} catch (e, s) {
132+
_logger.warning('Failed to delete existing device registration for $deviceId. Proceeding with creation anyway. Error: $e', e, s);
133+
}
134+
135+
final newDevice = PushNotificationDevice(
136+
id: deviceId,
114137
userId: userId,
115138
platform: Platform.isIOS ? DevicePlatform.ios : DevicePlatform.android,
116139
providerTokens: {PushNotificationProvider.oneSignal: token},
117140
createdAt: DateTime.now(),
118141
updatedAt: DateTime.now(),
119142
);
120143

121-
// Use the standard `update` method from the repository for an
122-
// idempotent "upsert" operation. The player ID is the resource ID.
123-
await _pushNotificationDeviceRepository.update(id: token, item: device);
144+
await _pushNotificationDeviceRepository.create(item: newDevice);
124145
_logger.info('Device successfully registered with backend.');
125146
} catch (e, s) {
126147
_logger.severe('Failed to register device.', e, s);
@@ -147,6 +168,7 @@ class OneSignalPushNotificationService extends PushNotificationService {
147168
Future<void> close() async {
148169
await _onMessageController.close();
149170
await _onMessageOpenedAppController.close();
171+
await _onTokenRefreshedController.close();
150172
}
151173

152174
@override

lib/notifications/services/push_notification_service.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ abstract class PushNotificationService extends Equatable {
4646
/// terminated.
4747
Stream<PushNotificationPayload> get onMessageOpenedApp;
4848

49+
/// A stream that emits a new device token when it is refreshed by the
50+
/// underlying push notification provider.
51+
///
52+
/// This is used by the AppBloc to trigger device re-registration.
53+
Stream<String> get onTokenRefreshed;
54+
4955
/// Gets the initial notification that caused the app to open from a
5056
/// terminated state.
5157
///

0 commit comments

Comments
 (0)