-
Notifications
You must be signed in to change notification settings - Fork 253
fix: stop push notifications after session expires without explicit logout #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop/1.9.0
Are you sure you want to change the base?
Changes from 1 commit
3e81c97
04f3f07
cef25b4
d7496b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,9 @@ class Login extends _$Login { | |
| log('handle user loaded: ${_tbClient.getAuthUser()?.userId}'); | ||
|
|
||
| if (!_tbClient.isAuthenticated()) { | ||
| if (getIt<IFirebaseService>().apps.isNotEmpty) { | ||
| await getIt<NotificationService>().handleSessionExpired(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Awaiting here puts the push cleanup in front of Having traced what the cold-start path actually does, most of it is cheap: Not dramatic, but nothing in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reordered in 04f3f07: the state is set first and the cleanup is now |
||
| } | ||
| state = const LoginState(isUserLoaded: false); | ||
| return; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,8 @@ import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_ser | |
| import 'package:thingsboard_app/utils/utils.dart'; | ||
|
|
||
| class NotificationService { | ||
| static const _pushRegisteredKey = 'push_notifications_registered'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rest of the app funnels persisted keys through Adding
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved in 04f3f07: the key is |
||
|
|
||
| static FirebaseMessaging _messaging = FirebaseMessaging.instance; | ||
| late NotificationDetails _notificationDetails; | ||
| final TbLogger _log = getIt(); | ||
|
|
@@ -98,9 +100,17 @@ class NotificationService { | |
| getIt<TbLogger>().debug( | ||
| 'NotificationService::logout() removeMobileSession', | ||
| ); | ||
| _tbClient.getUserControllerApi().removeMobileSession( | ||
| xMobileToken: _fcmToken!, | ||
| ); | ||
| try { | ||
| await _tbClient.getUserControllerApi().removeMobileSession( | ||
| xMobileToken: _fcmToken!, | ||
| ); | ||
| } catch (e) { | ||
| // Best effort: the session may already be invalid (e.g. expired JWT). | ||
| // Deleting the local FCM token below still stops the notifications. | ||
| getIt<TbLogger>().debug( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two small things here. The class already has a More substantively,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changed in 04f3f07: the catch logs via |
||
| 'NotificationService::logout() removeMobileSession failed: $e', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| await _foregroundMessageSubscription?.cancel(); | ||
|
|
@@ -110,6 +120,28 @@ class NotificationService { | |
| await _messaging.setAutoInitEnabled(false); | ||
| await flutterLocalNotificationsPlugin.cancelAll(); | ||
| await _localService.clearNotificationBadgeCount(); | ||
| await getIt<TbStorage>().deleteItem(_pushRegisteredKey); | ||
| } | ||
|
|
||
| /// Cleans up the push registration after a session that ended without an | ||
| /// explicit logout (e.g. the refresh token expired while the app was | ||
| /// closed, #304). The JWT is already invalid at this point, so the | ||
| /// server-side mobile session usually can't be removed here; deleting the | ||
| /// local FCM token makes further pushes bounce, and the platform purges | ||
| /// the session on the next delivery attempt. | ||
| Future<void> handleSessionExpired() async { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name promises more than the call site can actually establish: Something like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed to |
||
| final registered = | ||
| await getIt<TbStorage>().getItem(_pushRegisteredKey) as String?; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 04f3f07: the three inline lookups are gone — the storage coupling is a single |
||
| if (registered != 'true') { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced in 04f3f07: the read is |
||
| return; | ||
| } | ||
|
|
||
| _log.debug('NotificationService::handleSessionExpired()'); | ||
| try { | ||
| await logout(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Delegating to the full Extracting the local teardown into a private
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Split in 04f3f07 as described: |
||
| } catch (e) { | ||
| _log.debug('NotificationService::handleSessionExpired() failed: $e'); | ||
| } | ||
| } | ||
|
|
||
| Future<void> _configFirebaseMessaging() async { | ||
|
|
@@ -198,6 +230,8 @@ class NotificationService { | |
| if (fcmToken != null) { | ||
| await _saveToken(fcmToken); | ||
| } | ||
| } else { | ||
| await _markPushRegistered(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The flag is now written from two sites reachable through three paths in this method — Since every path here that leaves a usable registration ends with a non-null
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Collapsed in 04f3f07 to the shape you suggested: |
||
| } | ||
| } else { | ||
| await _saveToken(fcmToken); | ||
|
|
@@ -212,6 +246,11 @@ class NotificationService { | |
| (b) => b..fcmTokenTimestamp = DateTime.now().millisecondsSinceEpoch, | ||
| ), | ||
| ); | ||
| await _markPushRegistered(); | ||
| } | ||
|
|
||
| Future<void> _markPushRegistered() { | ||
| return getIt<TbStorage>().setItem(_pushRegisteredKey, 'true'); | ||
| } | ||
|
|
||
| Future<void> showNotification(RemoteMessage message) async { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:mocktail/mocktail.dart'; | ||
| import 'package:thingsboard_app/core/logger/tb_logger.dart'; | ||
| import 'package:thingsboard_app/locator.dart'; | ||
| import 'package:thingsboard_app/thingsboard_client.dart'; | ||
| import 'package:thingsboard_app/utils/services/notification_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; | ||
|
|
||
| class MockTbStorage extends Mock implements TbStorage {} | ||
|
|
||
| class MockTbClientService extends Mock implements ITbClientService {} | ||
|
|
||
| class MockThingsboardClient extends Mock implements ThingsboardClient {} | ||
|
|
||
| class TestableNotificationService extends NotificationService { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Overriding the collaborator under test means these tests only cover the flag predicate — the behaviour this PR is actually about (deleting the local FCM token, deleting the flag, tolerating a failed As the repo's first test file this also sets the pattern for everything that follows, and "subclass the SUT to neuter its real method" stops working the moment the interesting logic lives inside the overridden method — which is already the case here. If
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rewritten in 04f3f07 with no subclass: |
||
| int logoutCalls = 0; | ||
|
|
||
| @override | ||
| Future<void> logout() async { | ||
| logoutCalls++; | ||
| } | ||
| } | ||
|
|
||
| void main() { | ||
| late MockTbStorage storage; | ||
|
|
||
| setUp(() { | ||
| storage = MockTbStorage(); | ||
| final clientService = MockTbClientService(); | ||
| when(() => clientService.client).thenReturn(MockThingsboardClient()); | ||
|
|
||
| getIt | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every future service test in this repo will need this same locator bootstrap, and it's worth noting that two of the three registrations exist purely so the constructor's field initializers don't throw — Since this is the first test file, pulling this into a shared
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted in 04f3f07 to |
||
| ..registerLazySingleton(() => TbLogger()) | ||
| ..registerLazySingleton<TbStorage>(() => storage) | ||
| ..registerLazySingleton<ITbClientService>(() => clientService); | ||
| }); | ||
|
|
||
| tearDown(() => getIt.reset()); | ||
|
|
||
| group('NotificationService.handleSessionExpired', () { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coverage gaps worth closing while the file is fresh:
Since
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mostly closed in 04f3f07: (1) the string sentinel no longer exists — the flag is a bool checked via |
||
| test( | ||
| 'does nothing when push notifications were never registered', | ||
| () async { | ||
| when(() => storage.getItem(any())).thenAnswer((_) async => null); | ||
| final service = TestableNotificationService(); | ||
|
|
||
| await service.handleSessionExpired(); | ||
|
|
||
| expect(service.logoutCalls, 0); | ||
| }, | ||
| ); | ||
|
|
||
| test( | ||
| 'cleans up the registration when the session expired after a login', | ||
| () async { | ||
| when(() => storage.getItem(any())).thenAnswer((_) async => 'true'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stubbing with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The flag read moved behind |
||
| final service = TestableNotificationService(); | ||
|
|
||
| await service.handleSessionExpired(); | ||
|
|
||
| expect(service.logoutCalls, 1); | ||
| }, | ||
| ); | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This guard reads
IFirebaseService.apps, which is only populated after the unawaitedinitializeApp(...)inmain.dart:37— and on this particular path there is exactly one chance to get it right.What's verifiable in the repo:
main.dart:37startsinitializeApp()withoutawait, andFirebaseService.initializeApponly does_apps.add(name)afterawait Firebase.initializeApp(...)(firebase_service.dart:34-35).RefreshListenableregisters a permanent_ref.listen(loginProvider, ...)(refresh_listenable.dart:9) from insideRouter.build(), whichThingsboardApp.buildwatches on the first frame. So the autoDispose notifier is created once and kept alive —Login.build(), and therefore theFuture(() => handleUserLoaded())at :38, runs exactly once per launch.handleUserLoadedhas only two live call sites: that one-shotFutureand theUserLoadedEventlistener.CommunicationServicewraps a plainEventBuswith no replay, so theUserLoadedEventthe client fires while clearing the expired token — insidesetUpRootDependencies(), beforerunAppand before that listener exists — is dropped.Put together: if
appsis still empty when that singleFutureruns, the cleanup is skipped and nothing re-triggers it for the rest of the session — the fix silently no-ops on exactly the reported scenario.What I can't tell from reading is whether Firebase actually loses that race. It's [native init started just before
runApp] versus [oneawait AppLinks().getInitialLink()+runApp+ first frame + one event-loop turn], which is device-dependent and probably fine most of the time — theisCustomEndpoint()check insideinitializeAppis cheap since_cachedEndpointis already warm fromTbClientService.init(). So I'm not claiming this is broken.But
awaitinginitializeApp()inmain()costs nothing (it returnsnullrather than throwing on failure), removes the question entirely, and makes the three otherapps.isNotEmptyguards deterministic too. Worth doing before merge — and worth one manual check on a genuinely cold start (app killed, token expired days ago) rather than with the app already running, since that's the only configuration where this single-shot path is exercised.Separately, and much more minor: this is now the fourth copy of the
getIt<IFirebaseService>().apps.isNotEmptyguard wrapped around aNotificationServicecall (Login.logoutat :43,Login._onFullyLoggedInat :119,TbContext.logoutat :306, and here). It reads like an invariant of the service rather than something each call site should have to remember — which is another argument for pushing the check down intoNotificationService.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 04f3f07 on both fronts.
main()now awaitsinitializeApp()(it swallows its own errors and returns null, so the surrounding try/catch behavior is unchanged), which removes the race on the single-shot path. And theapps.isNotEmptyguard moved intoNotificationServiceitself —init(),logout()and the renamedcleanUpStalePushRegistration()early-return when Firebase isn't configured — so all four call-site copies are gone (Login.logoutkeeps only itsisFullyAuthenticated()check). Verified manually on a genuinely cold start with an expired refresh token: the cleanup runs and pushes stop.