diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index f63e48ee..6e9aaab9 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -51,6 +51,9 @@ class Login extends _$Login { log('handle user loaded: ${_tbClient.getAuthUser()?.userId}'); if (!_tbClient.isAuthenticated()) { + if (getIt().apps.isNotEmpty) { + await getIt().handleSessionExpired(); + } state = const LoginState(isUserLoaded: false); return; } diff --git a/lib/utils/services/notification_service.dart b/lib/utils/services/notification_service.dart index 18980c5e..8364e18d 100644 --- a/lib/utils/services/notification_service.dart +++ b/lib/utils/services/notification_service.dart @@ -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'; + static FirebaseMessaging _messaging = FirebaseMessaging.instance; late NotificationDetails _notificationDetails; final TbLogger _log = getIt(); @@ -98,9 +100,17 @@ class NotificationService { getIt().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().debug( + '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().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 handleSessionExpired() async { + final registered = + await getIt().getItem(_pushRegisteredKey) as String?; + if (registered != 'true') { + return; + } + + _log.debug('NotificationService::handleSessionExpired()'); + try { + await logout(); + } catch (e) { + _log.debug('NotificationService::handleSessionExpired() failed: $e'); + } } Future _configFirebaseMessaging() async { @@ -198,6 +230,8 @@ class NotificationService { if (fcmToken != null) { await _saveToken(fcmToken); } + } else { + await _markPushRegistered(); } } else { await _saveToken(fcmToken); @@ -212,6 +246,11 @@ class NotificationService { (b) => b..fcmTokenTimestamp = DateTime.now().millisecondsSinceEpoch, ), ); + await _markPushRegistered(); + } + + Future _markPushRegistered() { + return getIt().setItem(_pushRegisteredKey, 'true'); } Future showNotification(RemoteMessage message) async { diff --git a/test/utils/services/notification_service_test.dart b/test/utils/services/notification_service_test.dart new file mode 100644 index 00000000..0c347192 --- /dev/null +++ b/test/utils/services/notification_service_test.dart @@ -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 { + int logoutCalls = 0; + + @override + Future logout() async { + logoutCalls++; + } +} + +void main() { + late MockTbStorage storage; + + setUp(() { + storage = MockTbStorage(); + final clientService = MockTbClientService(); + when(() => clientService.client).thenReturn(MockThingsboardClient()); + + getIt + ..registerLazySingleton(() => TbLogger()) + ..registerLazySingleton(() => storage) + ..registerLazySingleton(() => clientService); + }); + + tearDown(() => getIt.reset()); + + group('NotificationService.handleSessionExpired', () { + 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'); + final service = TestableNotificationService(); + + await service.handleSessionExpired(); + + expect(service.logoutCalls, 1); + }, + ); + }); +}