-
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 all commits
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 |
|---|---|---|
|
|
@@ -13,7 +13,6 @@ import 'package:thingsboard_app/locator.dart'; | |
| import 'package:thingsboard_app/utils/services/communication/events/user_loaded_event.dart'; | ||
| import 'package:thingsboard_app/utils/services/communication/i_communication_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/notification_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; | ||
|
|
@@ -40,8 +39,7 @@ class Login extends _$Login { | |
| } | ||
|
|
||
| Future<void> logout() async { | ||
| if (getIt<IFirebaseService>().apps.isNotEmpty && | ||
| state.isFullyAuthenticated()) { | ||
| if (state.isFullyAuthenticated()) { | ||
| await getIt<NotificationService>().logout(); | ||
| } | ||
| await _tbClient.logout(requestConfig: RequestConfig(ignoreErrors: true)); | ||
|
|
@@ -52,6 +50,12 @@ class Login extends _$Login { | |
|
|
||
| if (!_tbClient.isAuthenticated()) { | ||
| state = const LoginState(isUserLoaded: false); | ||
| // Fire-and-forget: the cleanup swallows its own errors, and the | ||
| // registration flag is deleted last, so an interrupted attempt is | ||
| // retried on the next launch without delaying the login screen. | ||
| // NotificationService.init() waits for it, so a fast auto-login | ||
| // (QR code, OAuth2) cannot register a token while it is being deleted. | ||
| unawaited(getIt<NotificationService>().cleanUpStalePushRegistration()); | ||
|
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. Not re-opening this — your costing was right, and a Riverpod container plus three more service mocks is more than this PR should carry. Noting it for whoever picks up the follow-up, because the valuable assertion is narrower than a full provider test: it isn't the whole login flow, it's the branch choice. Unauthenticated -> cleanup started; authenticated -> One assumption worth writing down while it's fresh: the ordering claim in the comment you added holds only because
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. Agreed on the shape of the follow-up test — the branch choice, not the flow. Recorded the singleton assumption in d7496b3 as a comment next to the |
||
| return; | ||
| } | ||
| if (_tbClient.isPreVerificationToken() || | ||
|
|
@@ -113,9 +117,7 @@ class Login extends _$Login { | |
|
|
||
| Future<void> _onFullyLoggedIn() async { | ||
| await loadUser(); | ||
| if (getIt<IFirebaseService>().apps.isNotEmpty) { | ||
| await getIt<NotificationService>().init(); | ||
| } | ||
| await getIt<NotificationService>().init(); | ||
| } | ||
|
|
||
| Future<void> twoFaConfirmed(LoginResponse response) async { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,6 @@ import 'package:thingsboard_app/thingsboard_client.dart'; | |
| import 'package:thingsboard_app/utils/services/version_service/version_info.dart'; | ||
| import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/endpoint/i_endpoint_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/notification_service.dart'; | ||
| import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; | ||
| import 'package:thingsboard_app/utils/utils.dart'; | ||
|
|
@@ -303,9 +302,7 @@ class TbContext implements PopEntry { | |
| log.debug('TbContext::logout($requestConfig, $notifyUser)'); | ||
| _handleRootState = true; | ||
|
|
||
| if (getIt<IFirebaseService>().apps.isNotEmpty) { | ||
| await getIt<NotificationService>().init(); | ||
| } | ||
| await getIt<NotificationService>().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. Fixing the That said, this method still has no callers anywhere 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. Leaving this one as is. |
||
|
|
||
| await tbClient.logout(requestConfig: requestConfig, notifyUser: notifyUser); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,7 @@ Future<void> main() async { | |
| WidgetsFlutterBinding.ensureInitialized(); | ||
| FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); | ||
| await Hive.initFlutter(); | ||
| SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); | ||
| SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); | ||
| Hive.registerAdapter(RegionAdapter()); | ||
| await setUpRootDependencies(); | ||
| if (UniversalPlatform.isAndroid) { | ||
|
|
@@ -34,7 +34,7 @@ Future<void> main() async { | |
| } | ||
|
|
||
| try { | ||
| getIt<IFirebaseService>().initializeApp( | ||
| await getIt<IFirebaseService>().initializeApp( | ||
|
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 this is the right call and I'd keep it — it's what makes every One small thing on this line, though:
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. Keeping the try/catch. The committed |
||
| options: DefaultFirebaseOptions.currentPlatform, | ||
| ); | ||
| } catch (e) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,4 +45,20 @@ class LocalDatabaseService implements ILocalDatabaseService { | |
| Future<void> deleteInitialAppLink() { | ||
| return storage.deleteItem(DatabaseKeys.initialAppLink); | ||
| } | ||
|
|
||
| @override | ||
| Future<bool> isPushRegistered() async { | ||
| return await storage.getItem(DatabaseKeys.pushNotificationsRegistered) == | ||
| true; | ||
| } | ||
|
|
||
| @override | ||
| Future<void> setPushRegistered() { | ||
| return storage.setItem(DatabaseKeys.pushNotificationsRegistered, 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. Optional. A line of comment saying presence is the contract is probably the right-sized fix; reading the value (
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. Went with reading the value in cef25b4: |
||
| } | ||
|
|
||
| @override | ||
| Future<void> clearPushRegistered() { | ||
| return storage.deleteItem(DatabaseKeys.pushNotificationsRegistered); | ||
| } | ||
| } | ||
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.
Making this fire-and-forget fixes the latency, but it also means the cleanup can now overlap
init()on the sameNotificationServicesingleton, which the awaited version couldn't.The interleaving: this starts
_cleanupPushRegistration(), which blocks on_messaging.deleteToken()(the one genuinely slow step). If the client authenticates while that's still pending, it firesUserLoadedEvent→ the listener at:34→handleUserLoaded()→_onFullyLoggedIn()→NotificationService.init(), which acquires a token,saveMobileSessions it, sets the flag and callssetAutoInitEnabled(true). The cleanup then resumes against that new state:_fcmToken = null,setAutoInitEnabled(false),clearPushRegistered()— and ifgetToken()resolved before the pendingdeleteToken()landed, the token that was just registered is the one that gets deleted. I checked the server side of that outcome:MobileAppNotificationChannel.java:122-128only purges the orphaned session when a push actually bounces, and nothing re-registers from the platform side, so the recovery is the nextinit()— i.e. no push for the rest of that session.On how reachable it is, I'd calibrate this lower than it first looks. The window is however long
deleteToken()takes, andinit()reachesgetToken()only aftergetInitialMessage()andrequestPermission(). A human typing credentials (≥1-2s) loses that race comfortably, so the ordinary login path is fine. The cases where both sides are network-paced rather than human-paced are the ones to think about: a QR/deep-link launch that lands back on the default endpoint (noauth_provider'sreset()→_initDefaultFbApp()re-populatesapps, then the client logs in without user input) and returning from OAuth2. On a custom endpoint there's no race at all — both sides early-return on_isFirebaseConfigured.So: an edge case rather than a blocker, but the fix is about three lines — hold the future in the service and
awaitit at the top ofinit(). That also covers a smaller version of the same thing: everyUserLoadedEventarriving unauthenticated fires anotherunawaited(...), and since the flag is only cleared at the end, concurrent calls all pass theisPushRegistered()check and run the teardown in parallel.Separately, and reasonable to defer: this file is where the fix actually manifests, and none of it is covered — every test targets
NotificationServicein isolation. A provider-level test (unauthenticated mock client → cleanup ran; authenticated →init()ran instead) is what would catch a future refactor dropping this call, though it does need a Riverpod container plusIDeviceInfoService/IOverlayService/ICommunicationServiceadded totest/helpers, so it's a bigger lift than the service-level tests.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 cef25b4, in the service rather than the provider:
cleanUpStalePushRegistration()stores its future in_staleCleanup(shared by concurrent callers, reset inwhenComplete), andinit()awaits that future right after the Firebase guard, before touching the token. That also covers the smaller version — repeated unauthenticatedUserLoadedEvents now share one teardown instead of running several in parallel.Tests:
waits for a pending stale cleanup, so the token it registers is not the one being deletedholdsdeleteToken()on aCompleter, assertsgetToken()has not been called while it is pending, then checks theclearPushRegistered → getToken → setPushRegisteredorder;runs a single teardown for concurrent callscovers the dedup. The provider-level test is deferred as you suggested.