Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/core/auth/login/provider/login_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
state = const LoginState(isUserLoaded: false);
return;
}
Expand Down
45 changes: 42 additions & 3 deletions lib/utils/services/notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
'NotificationService::logout() removeMobileSession failed: $e',
);
}
}

await _foregroundMessageSubscription?.cancel();
Expand All @@ -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 {
final registered =
await getIt<TbStorage>().getItem(_pushRegisteredKey) as String?;
if (registered != 'true') {
return;
}

_log.debug('NotificationService::handleSessionExpired()');
try {
await logout();
} catch (e) {
_log.debug('NotificationService::handleSessionExpired() failed: $e');
}
}

Future<void> _configFirebaseMessaging() async {
Expand Down Expand Up @@ -198,6 +230,8 @@ class NotificationService {
if (fcmToken != null) {
await _saveToken(fcmToken);
}
} else {
await _markPushRegistered();
}
} else {
await _saveToken(fcmToken);
Expand All @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions test/utils/services/notification_service_test.dart
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 {
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
..registerLazySingleton(() => TbLogger())
..registerLazySingleton<TbStorage>(() => storage)
..registerLazySingleton<ITbClientService>(() => 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);
},
);
});
}