diff --git a/lib/constants/database_keys.dart b/lib/constants/database_keys.dart index 856a2273..c68756e3 100644 --- a/lib/constants/database_keys.dart +++ b/lib/constants/database_keys.dart @@ -2,4 +2,5 @@ abstract final class DatabaseKeys { static const thingsBoardApiEndpointKey = 'thingsBoardApiEndpoint'; static const initialAppLink = 'initialAppLink'; static const selectedRegion = 'selectedRegion'; + static const pushNotificationsRegistered = 'pushNotificationsRegistered'; } diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index 9fe25462..527017cc 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -15,7 +15,6 @@ import 'package:thingsboard_app/utils/services/communication/events/user_loaded_ import 'package:thingsboard_app/utils/services/communication/i_communication_service.dart'; import 'package:thingsboard_app/utils/services/custom_translation/i_custom_translation_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'; @@ -41,8 +40,7 @@ class Login extends _$Login { } Future logout() async { - if (getIt().apps.isNotEmpty && - state.isFullyAuthenticated()) { + if (state.isFullyAuthenticated()) { await getIt().logout(); } getIt().clear(); @@ -54,6 +52,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().cleanUpStalePushRegistration()); return; } if (_tbClient.isPreVerificationToken() || @@ -123,9 +127,7 @@ class Login extends _$Login { Future _onFullyLoggedIn() async { await loadUser(); - if (getIt().apps.isNotEmpty) { - await getIt().init(); - } + await getIt().init(); } Future twoFaConfirmed(LoginResponse response) async { diff --git a/lib/core/auth/login/provider/login_provider.g.dart b/lib/core/auth/login/provider/login_provider.g.dart index ac0fea32..b799464f 100644 --- a/lib/core/auth/login/provider/login_provider.g.dart +++ b/lib/core/auth/login/provider/login_provider.g.dart @@ -6,7 +6,7 @@ part of 'login_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$loginHash() => r'20ee432e945b09f090b81425646ba542354035f1'; +String _$loginHash() => r'b98ad24360c72faf279379feefdcb13f6ae20f68'; /// See also [Login]. @ProviderFor(Login) diff --git a/lib/core/context/tb_context.dart b/lib/core/context/tb_context.dart index 2719ab00..7ad52c95 100644 --- a/lib/core/context/tb_context.dart +++ b/lib/core/context/tb_context.dart @@ -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/services/wl_provider.dart'; @@ -298,9 +297,7 @@ class TbContext implements PopEntry { log.debug('TbContext::logout($requestConfig, $notifyUser)'); _handleRootState = true; - if (getIt().apps.isNotEmpty) { - await getIt().init(); - } + await getIt().logout(); await tbClient.logout(requestConfig: requestConfig, notifyUser: notifyUser); diff --git a/lib/main.dart b/lib/main.dart index 8f6d1223..32376cd0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,7 @@ Future 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 main() async { } try { - getIt().initializeApp( + await getIt().initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); } catch (e) { diff --git a/lib/utils/services/local_database/i_local_database_service.dart b/lib/utils/services/local_database/i_local_database_service.dart index c142767e..0c218b3b 100644 --- a/lib/utils/services/local_database/i_local_database_service.dart +++ b/lib/utils/services/local_database/i_local_database_service.dart @@ -17,4 +17,10 @@ abstract interface class ILocalDatabaseService { Future getInitialAppLink(); Future deleteInitialAppLink(); + + Future isPushRegistered(); + + Future setPushRegistered(); + + Future clearPushRegistered(); } diff --git a/lib/utils/services/local_database/local_database_service.dart b/lib/utils/services/local_database/local_database_service.dart index 998bf10d..51006899 100644 --- a/lib/utils/services/local_database/local_database_service.dart +++ b/lib/utils/services/local_database/local_database_service.dart @@ -45,4 +45,20 @@ class LocalDatabaseService implements ILocalDatabaseService { Future deleteInitialAppLink() { return storage.deleteItem(DatabaseKeys.initialAppLink); } + + @override + Future isPushRegistered() async { + return await storage.getItem(DatabaseKeys.pushNotificationsRegistered) == + true; + } + + @override + Future setPushRegistered() { + return storage.setItem(DatabaseKeys.pushNotificationsRegistered, true); + } + + @override + Future clearPushRegistered() { + return storage.deleteItem(DatabaseKeys.pushNotificationsRegistered); + } } diff --git a/lib/utils/services/notification_service.dart b/lib/utils/services/notification_service.dart index 18980c5e..864f2be3 100644 --- a/lib/utils/services/notification_service.dart +++ b/lib/utils/services/notification_service.dart @@ -11,28 +11,57 @@ import 'package:thingsboard_app/locator.dart'; import 'package:thingsboard_app/modules/notification/service/i_notifications_local_service.dart'; import 'package:thingsboard_app/modules/notification/service/notifications_local_service.dart'; import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; import 'package:thingsboard_app/utils/utils.dart'; class NotificationService { - static FirebaseMessaging _messaging = FirebaseMessaging.instance; + NotificationService({ + FirebaseMessaging? messaging, + FlutterLocalNotificationsPlugin? localNotificationsPlugin, + INotificationsLocalService? localService, + }) : _injectedMessaging = messaging, + flutterLocalNotificationsPlugin = + localNotificationsPlugin ?? FlutterLocalNotificationsPlugin(), + _localService = localService ?? NotificationsLocalService(); + + final FirebaseMessaging? _injectedMessaging; late NotificationDetails _notificationDetails; final TbLogger _log = getIt(); final ThingsboardClient _tbClient = getIt().client; - final INotificationsLocalService _localService = NotificationsLocalService(); + final ILocalDatabaseService _localDatabase = getIt(); + final INotificationsLocalService _localService; StreamSubscription? _foregroundMessageSubscription; StreamSubscription? _onMessageOpenedAppSubscription; StreamSubscription? _onTokenRefreshSubscription; + Future? _staleCleanup; String? _fcmToken; - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; + + /// Resolved lazily: `FirebaseMessaging.instance` needs an initialized + /// Firebase app, and the locator constructs this service before `main()` + /// initializes Firebase. + FirebaseMessaging get _messaging => + _injectedMessaging ?? FirebaseMessaging.instance; + + bool get _isFirebaseConfigured => getIt().apps.isNotEmpty; Future init() async { + if (!_isFirebaseConfigured) { + return; + } + // A stale cleanup started while unauthenticated may still be deleting the + // FCM token; registering concurrently would delete the token just saved. + // This relies on the service being a locator singleton: the login + // provider started that cleanup on this same instance. + await _staleCleanup; + _log.debug('NotificationService::init()'); - final message = await FirebaseMessaging.instance.getInitialMessage(); + final message = await _messaging.getInitialMessage(); if (message != null) { NotificationService.handleClickOnNotification(message.data); } @@ -42,7 +71,7 @@ class NotificationService { NotificationService.handleClickOnNotification(message.data); }); - final settings = await _requestPermission(); + final settings = await _messaging.requestPermission(provisional: true); _log.debug( 'Notification authorizationStatus: ${settings.authorizationStatus}', ); @@ -50,20 +79,31 @@ class NotificationService { settings.authorizationStatus == AuthorizationStatus.provisional) { await _getAndSaveToken(); - _onTokenRefreshSubscription = FirebaseMessaging.instance.onTokenRefresh - .listen((token) { - if (_fcmToken != null) { - _tbClient - .getUserControllerApi() - .removeMobileSession(xMobileToken: _fcmToken!) - .then((_) { - _fcmToken = token; - if (_fcmToken != null) { - _saveToken(_fcmToken!); - } - }); - } - }); + _onTokenRefreshSubscription = _messaging.onTokenRefresh.listen(( + token, + ) async { + final previousToken = _fcmToken; + _fcmToken = token; + if (previousToken != null) { + try { + await _tbClient.getUserControllerApi().removeMobileSession( + xMobileToken: previousToken, + ); + } catch (e) { + _log.warn( + 'NotificationService: failed to remove the mobile session of ' + 'the previous FCM token: $e', + ); + } + } + try { + await _saveToken(token); + } catch (e) { + _log.warn( + 'NotificationService: failed to save the refreshed FCM token: $e', + ); + } + }); await _initFlutterLocalNotificationsPlugin(); await _configFirebaseMessaging(); @@ -73,9 +113,7 @@ class NotificationService { } Future updateNotificationsCount() async { - final localService = NotificationsLocalService(); - - await localService.updateNotificationsCount( + await _localService.updateNotificationsCount( await _getNotificationsCountRemote(), ); } @@ -93,23 +131,88 @@ class NotificationService { } Future logout() async { - getIt().debug('NotificationService::logout()'); + if (!_isFirebaseConfigured) { + return; + } + + _log.debug('NotificationService::logout()'); if (_fcmToken != null) { - getIt().debug( - 'NotificationService::logout() removeMobileSession', - ); - _tbClient.getUserControllerApi().removeMobileSession( - xMobileToken: _fcmToken!, - ); + _log.debug('NotificationService::logout() removeMobileSession'); + 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. + _log.warn( + 'NotificationService::logout() removeMobileSession failed: $e', + ); + } } - await _foregroundMessageSubscription?.cancel(); - await _onMessageOpenedAppSubscription?.cancel(); - await _onTokenRefreshSubscription?.cancel(); - await _messaging.deleteToken(); - await _messaging.setAutoInitEnabled(false); - await flutterLocalNotificationsPlugin.cancelAll(); - await _localService.clearNotificationBadgeCount(); + await _tearDownLocalPushState(); + } + + /// Cleans up a stale push registration left by 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. + /// + /// Idempotent, never throws, and safe to call whenever the client is + /// unauthenticated. Concurrent calls share a single run, and [init] waits + /// for a run that is already in flight. The reverse direction is not + /// ordered: a cleanup that starts while [init] is mid-flight can delete the + /// token [init] just registered, which only costs pushes until the next + /// launch registers a fresh one. + Future cleanUpStalePushRegistration() async { + if (!_isFirebaseConfigured) { + return; + } + _staleCleanup ??= _tearDownIfRegistered().whenComplete( + () => _staleCleanup = null, + ); + await _staleCleanup; + } + + Future _tearDownIfRegistered() async { + try { + // The flag keeps this a no-op on devices that never registered, so a + // fresh install sitting on the login screen never calls into FCM: + // deleteToken() is an unconditional platform round trip either way. + if (!await _localDatabase.isPushRegistered()) { + return; + } + _log.debug('NotificationService::_tearDownIfRegistered()'); + await _tearDownLocalPushState(); + } catch (e) { + _log.warn('NotificationService::_tearDownIfRegistered() failed: $e'); + } + } + + /// Deleting the FCM token is what stops delivery; the rest drops the local + /// push state. Failures are swallowed so a logout still completes offline: + /// the registration flag is cleared last, so an interrupted teardown is + /// retried by [cleanUpStalePushRegistration] on the next launch. On iOS + /// `deleteToken()` throws `apns-token-not-set` until the APNS token the + /// plugin requests at launch has arrived (e.g. an offline launch); that is + /// one such interruption. + Future _tearDownLocalPushState() async { + try { + await _foregroundMessageSubscription?.cancel(); + await _onMessageOpenedAppSubscription?.cancel(); + await _onTokenRefreshSubscription?.cancel(); + await _messaging.deleteToken(); + _fcmToken = null; + await _messaging.setAutoInitEnabled(false); + await flutterLocalNotificationsPlugin.cancelAll(); + await _localService.clearNotificationBadgeCount(); + await _localDatabase.clearPushRegistered(); + } catch (e) { + _log.warn('NotificationService: push teardown failed: $e'); + } } Future _configFirebaseMessaging() async { @@ -160,20 +263,17 @@ class NotificationService { ); } - Future _requestPermission() async { - _messaging = FirebaseMessaging.instance; - final result = await _messaging.requestPermission(provisional: true); - - if (result.authorizationStatus == AuthorizationStatus.denied) { - return result; - } - - return result; - } - Future _resetToken(String? token) async { if (token != null) { - _tbClient.getUserControllerApi().removeMobileSession(xMobileToken: token); + try { + await _tbClient.getUserControllerApi().removeMobileSession( + xMobileToken: token, + ); + } catch (e) { + _log.warn( + 'NotificationService::_resetToken() removeMobileSession failed: $e', + ); + } } await _messaging.deleteToken(); @@ -181,28 +281,34 @@ class NotificationService { } Future _getAndSaveToken() async { - String? fcmToken = await getToken(); + final fcmToken = await getToken(); _log.debug('FCM token: $fcmToken'); - if (fcmToken != null) { - final mobileInfo = - (await _tbClient.getUserControllerApi().getMobileSession( - xMobileToken: fcmToken, - )).data; - if (mobileInfo != null) { - final int timeAfterCreatedToken = - DateTime.now().millisecondsSinceEpoch - - (mobileInfo.fcmTokenTimestamp ?? 0); - if (timeAfterCreatedToken > const Duration(days: 30).inMilliseconds) { - fcmToken = await _resetToken(fcmToken); - if (fcmToken != null) { - await _saveToken(fcmToken); - } - } - } else { - await _saveToken(fcmToken); + if (fcmToken == null) { + return; + } + + final mobileInfo = + (await _tbClient.getUserControllerApi().getMobileSession( + xMobileToken: fcmToken, + )).data; + if (mobileInfo == null) { + await _saveToken(fcmToken); + return; + } + + final tokenAge = + DateTime.now().millisecondsSinceEpoch - + (mobileInfo.fcmTokenTimestamp ?? 0); + if (tokenAge > const Duration(days: 30).inMilliseconds) { + final freshToken = await _resetToken(fcmToken); + if (freshToken != null) { + await _saveToken(freshToken); } + return; } + + await _localDatabase.setPushRegistered(); } Future _saveToken(String token) async { @@ -212,6 +318,7 @@ class NotificationService { (b) => b..fcmTokenTimestamp = DateTime.now().millisecondsSinceEpoch, ), ); + await _localDatabase.setPushRegistered(); } Future showNotification(RemoteMessage message) async { diff --git a/pubspec.lock b/pubspec.lock index c4d159f9..84721360 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -490,7 +490,7 @@ packages: source: hosted version: "0.4.1" dio: - dependency: transitive + dependency: "direct main" description: name: dio sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 diff --git a/pubspec.yaml b/pubspec.yaml index 55f2637d..f01f0b1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,6 +95,7 @@ dependencies: barcode: ^2.2.9 riverpod_annotation: ^2.6.1 path_provider: ^2.1.5 + dio: ^5.7.0 dev_dependencies: integration_test: sdk: flutter diff --git a/test/helpers/test_dependencies.dart b/test/helpers/test_dependencies.dart new file mode 100644 index 00000000..f7d993a4 --- /dev/null +++ b/test/helpers/test_dependencies.dart @@ -0,0 +1,37 @@ +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/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class MockTbLogger extends Mock implements TbLogger {} + +class MockTbClientService extends Mock implements ITbClientService {} + +class MockThingsboardClient extends Mock implements ThingsboardClient {} + +class MockLocalDatabaseService extends Mock implements ILocalDatabaseService {} + +class MockFirebaseService extends Mock implements IFirebaseService {} + +/// Registers the locator dependencies that services resolve in their field +/// initializers, so a test can construct them without booting the real app. +/// The locator is reset automatically when the current test finishes. +void registerTestDependencies({ + required ThingsboardClient tbClient, + required ILocalDatabaseService localDatabase, + required IFirebaseService firebaseService, +}) { + final clientService = MockTbClientService(); + when(() => clientService.client).thenReturn(tbClient); + + getIt + ..registerLazySingleton(() => MockTbLogger()) + ..registerLazySingleton(() => clientService) + ..registerLazySingleton(() => localDatabase) + ..registerLazySingleton(() => firebaseService); + addTearDown(getIt.reset); +} diff --git a/test/utils/services/local_database_service_test.dart b/test/utils/services/local_database_service_test.dart new file mode 100644 index 00000000..59097792 --- /dev/null +++ b/test/utils/services/local_database_service_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/local_database/local_database_service.dart'; + +import '../../helpers/test_dependencies.dart'; + +class MockTbStorage extends Mock implements TbStorage {} + +void main() { + late MockTbStorage storage; + late LocalDatabaseService service; + + setUp(() { + storage = MockTbStorage(); + service = LocalDatabaseService(storage: storage, logger: MockTbLogger()); + }); + + // The literal key is asserted on purpose: it is the persisted contract, so + // renaming the constant must fail these tests rather than drift silently. + group('push registration flag', () { + test('setPushRegistered stores the flag under the persisted key', () async { + when(() => storage.setItem(any(), any())).thenAnswer((_) async {}); + + await service.setPushRegistered(); + + verify( + () => storage.setItem('pushNotificationsRegistered', true), + ).called(1); + }); + + test('isPushRegistered reads the stored flag', () async { + when(() => storage.getItem(any())).thenAnswer((_) async => true); + + expect(await service.isPushRegistered(), isTrue); + + verify(() => storage.getItem('pushNotificationsRegistered')).called(1); + }); + + test('isPushRegistered is false when the flag was never stored', () async { + when(() => storage.getItem(any())).thenAnswer((_) async => null); + + expect(await service.isPushRegistered(), isFalse); + }); + + test('clearPushRegistered deletes the persisted key', () async { + when(() => storage.deleteItem(any())).thenAnswer((_) async {}); + + await service.clearPushRegistered(); + + verify(() => storage.deleteItem('pushNotificationsRegistered')).called(1); + }); + }); +} diff --git a/test/utils/services/notification_service_test.dart b/test/utils/services/notification_service_test.dart new file mode 100644 index 00000000..ca2d5b6d --- /dev/null +++ b/test/utils/services/notification_service_test.dart @@ -0,0 +1,521 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thingsboard_app/modules/notification/service/i_notifications_local_service.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/notification_service.dart'; + +import '../../helpers/test_dependencies.dart'; + +class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} + +class MockFlutterLocalNotificationsPlugin extends Mock + implements FlutterLocalNotificationsPlugin {} + +class MockNotificationsLocalService extends Mock + implements INotificationsLocalService {} + +class MockUserControllerApi extends Mock implements UserControllerApi {} + +class MockNotificationControllerApi extends Mock + implements NotificationControllerApi {} + +const fcmToken = 'fcm-token'; +const refreshedToken = 'refreshed-token'; + +Response response([T? data]) => + Response(requestOptions: RequestOptions(), data: data); + +NotificationSettings permission(AuthorizationStatus status) => + NotificationSettings( + authorizationStatus: status, + alert: AppleNotificationSetting.notSupported, + announcement: AppleNotificationSetting.notSupported, + badge: AppleNotificationSetting.notSupported, + carPlay: AppleNotificationSetting.notSupported, + criticalAlert: AppleNotificationSetting.notSupported, + lockScreen: AppleNotificationSetting.notSupported, + notificationCenter: AppleNotificationSetting.notSupported, + showPreviews: AppleShowPreviewSetting.notSupported, + sound: AppleNotificationSetting.notSupported, + timeSensitive: AppleNotificationSetting.notSupported, + providesAppNotificationSettings: AppleNotificationSetting.notSupported, + ); + +MobileSessionInfo sessionRegistered(Duration ago) => MobileSessionInfo( + (b) => + b + ..fcmTokenTimestamp = + DateTime.now().subtract(ago).millisecondsSinceEpoch, +); + +void main() { + late MockFirebaseMessaging messaging; + late MockFlutterLocalNotificationsPlugin localNotificationsPlugin; + late MockNotificationsLocalService localService; + late MockLocalDatabaseService localDatabase; + late MockFirebaseService firebaseService; + late MockUserControllerApi userApi; + late StreamController tokenRefreshes; + + setUpAll(() { + registerFallbackValue(const InitializationSettings()); + registerFallbackValue(MobileSessionInfo()); + }); + + NotificationService buildService() => NotificationService( + messaging: messaging, + localNotificationsPlugin: localNotificationsPlugin, + localService: localService, + ); + + /// `logout()` only removes the server-side session for a token the service + /// has already resolved, so prime the private token field first. + Future buildServiceWithToken() async { + final service = buildService(); + await service.getToken(); + return service; + } + + void stubMobileSession(MobileSessionInfo? session) => when( + () => userApi.getMobileSession(xMobileToken: any(named: 'xMobileToken')), + ).thenAnswer((_) async => response(session)); + + void verifyMobileSessionSaved(String token) => verify( + () => userApi.saveMobileSession( + xMobileToken: token, + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ).called(1); + + void verifyNoSessionSaved() => verifyNever( + () => userApi.saveMobileSession( + xMobileToken: any(named: 'xMobileToken'), + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ); + + void verifyNoSessionRemoval() => verifyNever( + () => userApi.removeMobileSession(xMobileToken: any(named: 'xMobileToken')), + ); + + void stubPushRegistered() => when( + () => localDatabase.isPushRegistered(), + ).thenAnswer((_) async => true); + + setUp(() { + messaging = MockFirebaseMessaging(); + localNotificationsPlugin = MockFlutterLocalNotificationsPlugin(); + localService = MockNotificationsLocalService(); + localDatabase = MockLocalDatabaseService(); + firebaseService = MockFirebaseService(); + userApi = MockUserControllerApi(); + tokenRefreshes = StreamController.broadcast(); + addTearDown(tokenRefreshes.close); + + final notificationApi = MockNotificationControllerApi(); + final tbClient = MockThingsboardClient(); + when(() => tbClient.getUserControllerApi()).thenReturn(userApi); + when( + () => tbClient.getNotificationControllerApi(), + ).thenReturn(notificationApi); + when( + () => notificationApi.getUnreadNotificationsCount( + deliveryMethod: any(named: 'deliveryMethod'), + ), + ).thenAnswer((_) async => response(0)); + when(() => firebaseService.apps).thenReturn(['[DEFAULT]']); + + when(() => messaging.getToken()).thenAnswer((_) async => fcmToken); + when(() => messaging.deleteToken()).thenAnswer((_) async {}); + when(() => messaging.setAutoInitEnabled(any())).thenAnswer((_) async {}); + when(() => messaging.getInitialMessage()).thenAnswer((_) async => null); + when( + () => messaging.requestPermission(provisional: true), + ).thenAnswer((_) async => permission(AuthorizationStatus.authorized)); + when( + () => messaging.onTokenRefresh, + ).thenAnswer((_) => tokenRefreshes.stream); + + when( + () => localNotificationsPlugin.initialize( + any(), + onDidReceiveNotificationResponse: any( + named: 'onDidReceiveNotificationResponse', + ), + ), + ).thenAnswer((_) async => true); + when(() => localNotificationsPlugin.cancelAll()).thenAnswer((_) async {}); + when( + () => localService.clearNotificationBadgeCount(), + ).thenAnswer((_) async {}); + when( + () => localService.updateNotificationsCount(any()), + ).thenAnswer((_) async {}); + + when(() => localDatabase.isPushRegistered()).thenAnswer((_) async => false); + when(() => localDatabase.setPushRegistered()).thenAnswer((_) async {}); + when(() => localDatabase.clearPushRegistered()).thenAnswer((_) async {}); + + stubMobileSession(null); + when( + () => userApi.saveMobileSession( + xMobileToken: any(named: 'xMobileToken'), + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ).thenAnswer((_) async => response()); + when( + () => + userApi.removeMobileSession(xMobileToken: any(named: 'xMobileToken')), + ).thenAnswer((_) async => response()); + + registerTestDependencies( + tbClient: tbClient, + localDatabase: localDatabase, + firebaseService: firebaseService, + ); + }); + + group('NotificationService.init', () { + test('skips entirely when Firebase is not configured', () async { + when(() => firebaseService.apps).thenReturn(const []); + + await buildService().init(); + + verifyNever(() => messaging.getToken()); + verifyNever(() => localDatabase.setPushRegistered()); + }); + + for (final status in [ + AuthorizationStatus.authorized, + AuthorizationStatus.provisional, + ]) { + test('registers the device and marks push as registered under ' + '$status when the server has no session for the token', () async { + when( + () => messaging.requestPermission(provisional: true), + ).thenAnswer((_) async => permission(status)); + + await buildService().init(); + + verifyMobileSessionSaved(fcmToken); + verify(() => localDatabase.setPushRegistered()).called(1); + }); + } + + test('marks push as registered without re-saving a fresh ' + 'server-side session', () async { + stubMobileSession(sessionRegistered(const Duration(days: 1))); + + await buildService().init(); + + verifyNoSessionSaved(); + verify(() => localDatabase.setPushRegistered()).called(1); + }); + + test('rotates a token older than 30 days and marks push as ' + 'registered', () async { + stubMobileSession(sessionRegistered(const Duration(days: 31))); + final tokens = [fcmToken, refreshedToken]; + when( + () => messaging.getToken(), + ).thenAnswer((_) async => tokens.removeAt(0)); + + await buildService().init(); + + verifyInOrder([ + () => userApi.removeMobileSession(xMobileToken: fcmToken), + () => messaging.deleteToken(), + () => userApi.saveMobileSession( + xMobileToken: refreshedToken, + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ]); + verify(() => localDatabase.setPushRegistered()).called(1); + }); + + test('leaves push unregistered when the rotation cannot obtain a fresh ' + 'token', () async { + stubMobileSession(sessionRegistered(const Duration(days: 31))); + final tokens = [fcmToken, null]; + when( + () => messaging.getToken(), + ).thenAnswer((_) async => tokens.removeAt(0)); + + await buildService().init(); + + verify(() => messaging.deleteToken()).called(1); + verifyNoSessionSaved(); + verifyNever(() => localDatabase.setPushRegistered()); + }); + + test('does not mark push as registered when no FCM token is ' + 'available', () async { + when(() => messaging.getToken()).thenThrow(Exception('offline')); + + await buildService().init(); + + verifyNever( + () => + userApi.getMobileSession(xMobileToken: any(named: 'xMobileToken')), + ); + verifyNever(() => localDatabase.setPushRegistered()); + }); + + test('does not register the device when notification permission ' + 'is denied', () async { + when( + () => messaging.requestPermission(provisional: true), + ).thenAnswer((_) async => permission(AuthorizationStatus.denied)); + + await buildService().init(); + + verifyNever(() => messaging.getToken()); + verifyNever(() => localDatabase.setPushRegistered()); + }); + + test('waits for a pending stale cleanup, so the token it registers ' + 'is not the one being deleted', () async { + stubPushRegistered(); + final deleteToken = Completer(); + when(() => messaging.deleteToken()).thenAnswer((_) => deleteToken.future); + final service = buildService(); + + final cleanup = service.cleanUpStalePushRegistration(); + final init = service.init(); + await pumpEventQueue(); + verifyNever(() => messaging.getToken()); + + deleteToken.complete(); + await Future.wait([cleanup, init]); + + verifyInOrder([ + () => localDatabase.clearPushRegistered(), + () => messaging.getToken(), + () => localDatabase.setPushRegistered(), + ]); + }); + }); + + group('NotificationService token refresh', () { + test('moves the mobile session to the refreshed token', () async { + await buildService().init(); + + tokenRefreshes.add(refreshedToken); + await pumpEventQueue(); + + verifyInOrder([ + () => userApi.removeMobileSession(xMobileToken: fcmToken), + () => userApi.saveMobileSession( + xMobileToken: refreshedToken, + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ]); + }); + + test('registers the refreshed token when the initial token could not ' + 'be obtained', () async { + when(() => messaging.getToken()).thenThrow(Exception('offline')); + await buildService().init(); + + tokenRefreshes.add(refreshedToken); + await pumpEventQueue(); + + verifyNoSessionRemoval(); + verifyMobileSessionSaved(refreshedToken); + verify(() => localDatabase.setPushRegistered()).called(1); + }); + + test('still saves the refreshed token when removing the previous ' + 'session fails', () async { + when( + () => userApi.removeMobileSession( + xMobileToken: any(named: 'xMobileToken'), + ), + ).thenThrow(Exception('401')); + await buildService().init(); + + tokenRefreshes.add(refreshedToken); + await pumpEventQueue(); + + verifyMobileSessionSaved(refreshedToken); + }); + + test('keeps listening when saving the refreshed token fails', () async { + when( + () => userApi.saveMobileSession( + xMobileToken: refreshedToken, + mobileSessionInfo: any(named: 'mobileSessionInfo'), + ), + ).thenThrow(Exception('500')); + await buildService().init(); + + tokenRefreshes.add(refreshedToken); + await pumpEventQueue(); + tokenRefreshes.add('second-refreshed-token'); + await pumpEventQueue(); + + verify( + () => userApi.removeMobileSession(xMobileToken: refreshedToken), + ).called(1); + verifyMobileSessionSaved('second-refreshed-token'); + }); + }); + + group('NotificationService.cleanUpStalePushRegistration', () { + test('skips entirely when Firebase is not configured', () async { + when(() => firebaseService.apps).thenReturn(const []); + + await buildService().cleanUpStalePushRegistration(); + + verifyNever(() => localDatabase.isPushRegistered()); + verifyNever(() => messaging.deleteToken()); + }); + + test( + 'does nothing when push notifications were never registered', + () async { + await buildService().cleanUpStalePushRegistration(); + + verify(() => localDatabase.isPushRegistered()).called(1); + verifyNever(() => messaging.deleteToken()); + verifyNever(() => localDatabase.clearPushRegistered()); + }, + ); + + test('cleans up the local registration when the session expired ' + 'after a login', () async { + stubPushRegistered(); + + await buildService().cleanUpStalePushRegistration(); + + verify(() => messaging.deleteToken()).called(1); + verify(() => messaging.setAutoInitEnabled(false)).called(1); + verify(() => localNotificationsPlugin.cancelAll()).called(1); + verify(() => localService.clearNotificationBadgeCount()).called(1); + verify(() => localDatabase.clearPushRegistered()).called(1); + verifyNoSessionRemoval(); + }); + + test('keeps the registration flag when the cleanup is interrupted, ' + 'so it is retried on the next launch', () async { + stubPushRegistered(); + when(() => messaging.deleteToken()).thenThrow(Exception('no network')); + + await buildService().cleanUpStalePushRegistration(); + + verifyNever(() => localDatabase.clearPushRegistered()); + }); + + test('runs a single teardown for concurrent calls', () async { + stubPushRegistered(); + final service = buildService(); + + await Future.wait([ + service.cleanUpStalePushRegistration(), + service.cleanUpStalePushRegistration(), + ]); + + verify(() => messaging.deleteToken()).called(1); + }); + + test('retries the teardown on the next call after an interrupted one ' + 'and clears the flag once it succeeds', () async { + stubPushRegistered(); + var deleteAttempts = 0; + when(() => messaging.deleteToken()).thenAnswer((_) async { + if (deleteAttempts++ == 0) { + throw Exception('no network'); + } + }); + final service = buildService(); + + await service.cleanUpStalePushRegistration(); + verifyNever(() => localDatabase.clearPushRegistered()); + + await service.cleanUpStalePushRegistration(); + + verify(() => messaging.deleteToken()).called(2); + verify(() => localDatabase.clearPushRegistered()).called(1); + }); + + test('completes when the registration flag cannot be read, so a later ' + 'init() still registers', () async { + when( + () => localDatabase.isPushRegistered(), + ).thenThrow(Exception('corrupt box')); + final service = buildService(); + + await expectLater(service.cleanUpStalePushRegistration(), completes); + await service.init(); + + verifyNever(() => messaging.deleteToken()); + verify(() => localDatabase.setPushRegistered()).called(1); + }); + }); + + group('NotificationService.logout', () { + test('skips entirely when Firebase is not configured', () async { + when(() => firebaseService.apps).thenReturn(const []); + + await buildService().logout(); + + verifyNever(() => messaging.deleteToken()); + }); + + test('removes the mobile session and cleans up the local ' + 'registration', () async { + final service = await buildServiceWithToken(); + + await service.logout(); + + verify( + () => userApi.removeMobileSession(xMobileToken: fcmToken), + ).called(1); + verify(() => messaging.deleteToken()).called(1); + verify(() => localDatabase.clearPushRegistered()).called(1); + }); + + test('still cleans up when removeMobileSession fails ' + '(e.g. the JWT already expired)', () async { + when( + () => userApi.removeMobileSession( + xMobileToken: any(named: 'xMobileToken'), + ), + ).thenThrow(Exception('401')); + final service = await buildServiceWithToken(); + + await service.logout(); + + verify(() => messaging.deleteToken()).called(1); + verify(() => localDatabase.clearPushRegistered()).called(1); + }); + + test('completes when the local teardown fails (e.g. offline) and keeps ' + 'the registration flag for the next launch', () async { + when(() => messaging.deleteToken()).thenThrow(Exception('no network')); + final service = await buildServiceWithToken(); + + await expectLater(service.logout(), completes); + + verifyNever(() => localDatabase.clearPushRegistered()); + }); + + test('does not reuse the deleted FCM token on a repeated logout', () async { + final service = await buildServiceWithToken(); + + await service.logout(); + await service.logout(); + + verify( + () => userApi.removeMobileSession( + xMobileToken: any(named: 'xMobileToken'), + ), + ).called(1); + }); + }); +}