Skip to content

Commit f3550ce

Browse files
authored
Merge pull request #554 from DevKor-github/feature/issue-532-notification-init-idempotent
fix: make notification initialization idempotent
2 parents 74c5d09 + a0b5ecd commit f3550ce

3 files changed

Lines changed: 219 additions & 49 deletions

File tree

docs/Notification-Behavior.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This document describes the current notification behavior in OnTime across push
66

77
- App startup checks current notification permission first.
88
- `NotificationService.initialize()` runs at startup only when permission is already `AuthorizationStatus.authorized`.
9+
- `NotificationService.initialize()` is service-level idempotent: repeated sequential or concurrent calls share the same completed or in-flight setup and do not register duplicate FCM listeners.
910
- If startup permission is not authorized, the app does not initialize notification handlers/tokens from `main.dart`.
1011
- During authenticated redirects from sign-in/onboarding entry routes, router logic checks permission and redirects to `/allowNotification` when permission is not authorized.
1112
- Permission can later be requested from:
@@ -21,20 +22,20 @@ Implementation notes:
2122

2223
### Foreground
2324

24-
- `FirebaseMessaging.onMessage` listener is registered in `NotificationService._setupMessageHandlers()`.
25+
- `FirebaseMessaging.onMessage` listener is registered once per `NotificationService` lifecycle.
2526
- Incoming push messages are converted into in-app local notifications through `showNotification(message)`.
2627
- Title/body resolution supports both `message.notification` and `message.data` keys (e.g., `title`, `content`, `body`, plus case variants).
2728

2829
### Background (App in background, opened from notification)
2930

30-
- `FirebaseMessaging.onMessageOpenedApp` triggers `_handleBackgroundMessage(message)`.
31+
- `FirebaseMessaging.onMessageOpenedApp` listener is registered once per `NotificationService` lifecycle and triggers `_handleBackgroundMessage(message)`.
3132
- Navigation is decided from payload fields:
3233
- `type` contains `5min` -> push `/scheduleStart` with `extra: {'isFiveMinutesBefore': true}`
3334
- `type` starts with `schedule_` or `preparation_`, or `scheduleId` exists -> push `/alarmScreen`
3435

3536
### Terminated (Cold start from notification tap)
3637

37-
- `FirebaseMessaging.getInitialMessage()` is read on initialization.
38+
- `FirebaseMessaging.getInitialMessage()` is read once during notification service initialization.
3839
- If present, it is passed through the same `_handleBackgroundMessage(message)` routing logic as background open.
3940

4041
### Background isolate handler
@@ -78,7 +79,7 @@ Implementation notes:
7879
- `FirebaseMessaging.getToken()` is called.
7980
- If token exists, app posts to backend endpoint `/firebase-token` with payload `{ "firebaseToken": "<token>" }`.
8081
- On token refresh:
81-
- `FirebaseMessaging.onTokenRefresh.listen(...)` posts refreshed token to the same endpoint.
82+
- A single `FirebaseMessaging.onTokenRefresh` subscription posts refreshed token to the same endpoint.
8283
- Failures are logged and do not crash app flow.
8384

8485
## 6. Platform-Specific Notes (Android/iOS/Web)

lib/core/services/notification_service.dart

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,16 @@ class NotificationService {
4343
FlutterLocalNotificationsPlugin? localNotifications,
4444
String Function()? localeProvider,
4545
bool? isIOSOverride,
46+
Stream<RemoteMessage>? onMessage,
47+
Stream<RemoteMessage>? onMessageOpenedApp,
4648
}) : _messaging = messaging ?? FirebaseMessaging.instance,
4749
_localNotifications =
4850
localNotifications ?? FlutterLocalNotificationsPlugin(),
4951
_localeProvider = localeProvider,
50-
_isIOSOverride = isIOSOverride;
52+
_isIOSOverride = isIOSOverride,
53+
_onMessage = onMessage ?? FirebaseMessaging.onMessage,
54+
_onMessageOpenedApp =
55+
onMessageOpenedApp ?? FirebaseMessaging.onMessageOpenedApp;
5156

5257
@visibleForTesting
5358
NotificationService.test({
@@ -57,10 +62,15 @@ class NotificationService {
5762
bool isFlutterLocalNotificationsInitialized = false,
5863
bool isTimezoneInitialized = false,
5964
bool? isIOSOverride,
65+
Stream<RemoteMessage>? onMessage,
66+
Stream<RemoteMessage>? onMessageOpenedApp,
6067
}) : _messaging = messaging,
6168
_localNotifications = localNotifications,
6269
_localeProvider = localeProvider,
6370
_isIOSOverride = isIOSOverride,
71+
_onMessage = onMessage ?? FirebaseMessaging.onMessage,
72+
_onMessageOpenedApp =
73+
onMessageOpenedApp ?? FirebaseMessaging.onMessageOpenedApp,
6474
_isFlutterLocalNotificationsInitialized =
6575
isFlutterLocalNotificationsInitialized,
6676
_isTimezoneInitialized = isTimezoneInitialized;
@@ -74,8 +84,16 @@ class NotificationService {
7484
final FlutterLocalNotificationsPlugin _localNotifications;
7585
final String Function()? _localeProvider;
7686
final bool? _isIOSOverride;
87+
final Stream<RemoteMessage> _onMessage;
88+
final Stream<RemoteMessage> _onMessageOpenedApp;
7789
bool _isFlutterLocalNotificationsInitialized = false;
7890
bool _isTimezoneInitialized = false;
91+
bool _isInitialized = false;
92+
bool _initialMessageHandled = false;
93+
Future<void>? _initializationFuture;
94+
StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;
95+
StreamSubscription<RemoteMessage>? _openedAppMessageSubscription;
96+
StreamSubscription<String>? _tokenRefreshSubscription;
7997

8098
bool get _isIOS => !kIsWeb && (_isIOSOverride ?? Platform.isIOS);
8199

@@ -92,29 +110,50 @@ class NotificationService {
92110
}
93111
}
94112

95-
Future<void> initialize() async {
96-
try {
97-
FirebaseMessaging.onBackgroundMessage(
98-
_firebaseMessagingBackgroundHandler,
99-
);
100-
AppLogger.debug('[FCM] Background message handler 등록 완료');
101-
} catch (e) {
102-
AppLogger.debug('[FCM] Background message handler 등록 실패: $e');
113+
Future<void> initialize() {
114+
if (_isInitialized) {
115+
return Future.value();
103116
}
104117

105-
await _requestPermission();
106-
await setupFlutterNotifications();
107-
await _setupMessageHandlers();
118+
final initializationFuture = _initializationFuture;
119+
if (initializationFuture != null) {
120+
return initializationFuture;
121+
}
108122

109-
await requestNotificationToken();
123+
final future = _initialize();
124+
_initializationFuture = future;
125+
return future;
126+
}
110127

111-
if (_isIOS) {
112-
await _messaging.setForegroundNotificationPresentationOptions(
113-
alert: true,
114-
badge: true,
115-
sound: true,
116-
);
117-
AppLogger.debug('[FCM] iOS 포그라운드 알림 표시 옵션 설정 완료');
128+
Future<void> _initialize() async {
129+
try {
130+
try {
131+
FirebaseMessaging.onBackgroundMessage(
132+
_firebaseMessagingBackgroundHandler,
133+
);
134+
AppLogger.debug('[FCM] Background message handler 등록 완료');
135+
} catch (e) {
136+
AppLogger.debug('[FCM] Background message handler 등록 실패: $e');
137+
}
138+
139+
await _requestPermission();
140+
await setupFlutterNotifications();
141+
await _setupMessageHandlers();
142+
143+
await requestNotificationToken();
144+
145+
if (_isIOS) {
146+
await _messaging.setForegroundNotificationPresentationOptions(
147+
alert: true,
148+
badge: true,
149+
sound: true,
150+
);
151+
AppLogger.debug('[FCM] iOS 포그라운드 알림 표시 옵션 설정 완료');
152+
}
153+
154+
_isInitialized = true;
155+
} finally {
156+
_initializationFuture = null;
118157
}
119158
}
120159

@@ -229,7 +268,9 @@ class NotificationService {
229268
}
230269
}
231270

232-
_messaging.onTokenRefresh.listen((newToken) {
271+
_tokenRefreshSubscription ??= _messaging.onTokenRefresh.listen((
272+
newToken,
273+
) {
233274
AppLogger.debug(
234275
'[FCM] token refreshed token=${AppLogger.redactToken(newToken)}',
235276
);
@@ -598,34 +639,41 @@ class NotificationService {
598639

599640
Future<void> _setupMessageHandlers() async {
600641
//foreground message
601-
FirebaseMessaging.onMessage.listen(
602-
(message) {
603-
try {
604-
showNotification(message);
605-
} catch (error) {
606-
AppLogger.debug(
607-
'[FCM Foreground] notification display failed '
608-
'errorType=${error.runtimeType}',
609-
);
610-
}
611-
},
612-
onError: (error) {
613-
AppLogger.debug('[FCM Foreground] 리스너 오류: $error');
614-
},
615-
cancelOnError: false,
616-
);
617-
AppLogger.debug('[FCM] Foreground message handler 등록 완료');
642+
if (_foregroundMessageSubscription == null) {
643+
_foregroundMessageSubscription = _onMessage.listen(
644+
(message) {
645+
try {
646+
showNotification(message);
647+
} catch (error) {
648+
AppLogger.debug(
649+
'[FCM Foreground] notification display failed '
650+
'errorType=${error.runtimeType}',
651+
);
652+
}
653+
},
654+
onError: (error) {
655+
AppLogger.debug('[FCM Foreground] 리스너 오류: $error');
656+
},
657+
cancelOnError: false,
658+
);
659+
AppLogger.debug('[FCM] Foreground message handler 등록 완료');
660+
}
618661

619662
// background message
620-
FirebaseMessaging.onMessageOpenedApp.listen((message) {
621-
_handleBackgroundMessage(message);
622-
});
623-
AppLogger.debug('[FCM] Background message handler 등록 완료');
663+
if (_openedAppMessageSubscription == null) {
664+
_openedAppMessageSubscription = _onMessageOpenedApp.listen((message) {
665+
_handleBackgroundMessage(message);
666+
});
667+
AppLogger.debug('[FCM] Background message handler 등록 완료');
668+
}
624669

625670
// opened app
626-
final initialMessage = await _messaging.getInitialMessage();
627-
if (initialMessage != null) {
628-
_handleBackgroundMessage(initialMessage);
671+
if (!_initialMessageHandled) {
672+
final initialMessage = await _messaging.getInitialMessage();
673+
_initialMessageHandled = true;
674+
if (initialMessage != null) {
675+
_handleBackgroundMessage(initialMessage);
676+
}
629677
}
630678
}
631679

test/core/services/notification_service_test.dart

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,98 @@ void main() {
140140
},
141141
);
142142

143+
test(
144+
'repeated initialize handles each notification entry point once',
145+
() async {
146+
final messaging =
147+
_FakeFirebaseMessaging(AuthorizationStatus.notDetermined)
148+
..requestedAuthorizationStatus = AuthorizationStatus.authorized
149+
..initialMessage = const RemoteMessage(
150+
data: {'type': 'preparation_step', 'scheduleId': 'initial'},
151+
);
152+
final localNotifications = _RecordingLocalNotifications();
153+
final navigationService = _FakeNavigationService();
154+
final foregroundMessages = StreamController<RemoteMessage>.broadcast();
155+
final openedAppMessages = StreamController<RemoteMessage>.broadcast();
156+
const firebaseMessagingChannel = MethodChannel(
157+
'plugins.flutter.io/firebase_messaging',
158+
);
159+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
160+
.setMockMethodCallHandler(
161+
firebaseMessagingChannel,
162+
(_) async => null,
163+
);
164+
addTearDown(() {
165+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
166+
.setMockMethodCallHandler(firebaseMessagingChannel, null);
167+
foregroundMessages.close();
168+
openedAppMessages.close();
169+
});
170+
getIt.registerSingleton<NavigationService>(navigationService);
171+
final service = NotificationService.test(
172+
messaging: messaging,
173+
localNotifications: localNotifications,
174+
onMessage: foregroundMessages.stream,
175+
onMessageOpenedApp: openedAppMessages.stream,
176+
);
177+
178+
await service.initialize();
179+
await service.initialize();
180+
181+
foregroundMessages.add(
182+
const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}),
183+
);
184+
openedAppMessages.add(
185+
const RemoteMessage(
186+
data: {'type': 'preparation_step', 'scheduleId': 'opened'},
187+
),
188+
);
189+
await pumpEventQueue();
190+
191+
expect(messaging.getInitialMessageCount, 1);
192+
expect(localNotifications.shown, hasLength(1));
193+
expect(navigationService.pushedRoutes, ['/alarmScreen', '/alarmScreen']);
194+
},
195+
);
196+
197+
test('concurrent initialize shares one in-flight setup', () async {
198+
final permissionBlocker = Completer<void>();
199+
final messaging = _FakeFirebaseMessaging(AuthorizationStatus.notDetermined)
200+
..requestedAuthorizationStatus = AuthorizationStatus.authorized
201+
..requestPermissionBlocker = permissionBlocker
202+
..token = 'fcm-token';
203+
final localNotifications = _RecordingLocalNotifications();
204+
final remoteDataSource = _FakeNotificationRemoteDataSource();
205+
const firebaseMessagingChannel = MethodChannel(
206+
'plugins.flutter.io/firebase_messaging',
207+
);
208+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
209+
.setMockMethodCallHandler(firebaseMessagingChannel, (_) async => null);
210+
addTearDown(() {
211+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
212+
.setMockMethodCallHandler(firebaseMessagingChannel, null);
213+
});
214+
getIt
215+
..registerSingleton<AlarmRepository>(_FakeAlarmRepository())
216+
..registerSingleton<NotificationRemoteDataSource>(remoteDataSource);
217+
final service = NotificationService.test(
218+
messaging: messaging,
219+
localNotifications: localNotifications,
220+
);
221+
222+
final firstInitialize = service.initialize();
223+
await pumpEventQueue();
224+
final secondInitialize = service.initialize();
225+
226+
permissionBlocker.complete();
227+
await Future.wait([firstInitialize, secondInitialize]);
228+
229+
expect(messaging.requestPermissionCount, 1);
230+
expect(messaging.getInitialMessageCount, 1);
231+
expect(localNotifications.initializeCount, 1);
232+
expect(remoteDataSource.registeredTokens, hasLength(1));
233+
});
234+
143235
test(
144236
'openNotificationSettings returns false when platform launch fails',
145237
() async {
@@ -212,6 +304,33 @@ void main() {
212304
},
213305
);
214306

307+
test(
308+
'repeated token requests keep one refresh registration callback',
309+
() async {
310+
final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized)
311+
..token = 'initial-token';
312+
final remoteDataSource = _FakeNotificationRemoteDataSource();
313+
getIt
314+
..registerSingleton<AlarmRepository>(_FakeAlarmRepository())
315+
..registerSingleton<NotificationRemoteDataSource>(remoteDataSource);
316+
final service = NotificationService.test(
317+
messaging: messaging,
318+
localNotifications: _RecordingLocalNotifications(),
319+
isFlutterLocalNotificationsInitialized: true,
320+
);
321+
322+
await service.requestNotificationToken();
323+
await service.requestNotificationToken();
324+
messaging.emitTokenRefresh('refreshed-token');
325+
await pumpEventQueue();
326+
327+
expect(
328+
remoteDataSource.registeredTokens.map((token) => token.firebaseToken),
329+
['initial-token', 'initial-token', 'refreshed-token'],
330+
);
331+
},
332+
);
333+
215334
test(
216335
'setupFlutterNotifications initializes local notifications once',
217336
() async {
@@ -522,6 +641,7 @@ class _FakeFirebaseMessaging implements FirebaseMessaging {
522641
AuthorizationStatus authorizationStatus;
523642
AuthorizationStatus requestedAuthorizationStatus =
524643
AuthorizationStatus.authorized;
644+
Completer<void>? requestPermissionBlocker;
525645
int requestPermissionCount = 0;
526646
int getTokenCount = 0;
527647
int getInitialMessageCount = 0;
@@ -547,6 +667,7 @@ class _FakeFirebaseMessaging implements FirebaseMessaging {
547667
}) async {
548668
requestPermissionCount += 1;
549669
authorizationStatus = requestedAuthorizationStatus;
670+
await requestPermissionBlocker?.future;
550671
return _settings(authorizationStatus);
551672
}
552673

0 commit comments

Comments
 (0)