diff --git a/lib/src/model/challenge/challenge_service.dart b/lib/src/model/challenge/challenge_service.dart index e0b98c65fa..72b4ec473e 100644 --- a/lib/src/model/challenge/challenge_service.dart +++ b/lib/src/model/challenge/challenge_service.dart @@ -94,14 +94,22 @@ class ChallengeService { ); // new incoming challenges - await Future.wait( - _current?.inward.whereNot((challenge) => prevInwardIds.contains(challenge.id)).map(( - challenge, - ) async { - return await notificationService.show(ChallengeNotification(challenge)); - }) ?? - >[], - ); + // only display the notifications if the app is in the foregroud because fcm already + // shows notifications when the app is in the background + // TODO find a better solution to avoid duplicate notifications + final state = WidgetsBinding.instance.lifecycleState; + final isForeground = + state == null || state == AppLifecycleState.resumed || state == AppLifecycleState.inactive; + if (isForeground) { + await Future.wait( + _current?.inward.whereNot((challenge) => prevInwardIds.contains(challenge.id)).map(( + challenge, + ) async { + return await notificationService.show(ChallengeNotification(challenge)); + }) ?? + >[], + ); + } } /// Stop listening to challenge events from the server. diff --git a/lib/src/model/notifications/notifications.dart b/lib/src/model/notifications/notifications.dart index daf45b6b20..0089d96c41 100644 --- a/lib/src/model/notifications/notifications.dart +++ b/lib/src/model/notifications/notifications.dart @@ -464,8 +464,8 @@ class ChallengeAcceptedNotification extends LocalNotification { /// A notification for a challenge creation. /// -/// This notification is shown when a challenge is created on the server while the user is not connected to lichess (e.g., app is in background). -/// If the user is connected, challenges are handled by Websocket and a [ChallengeNotification] is shown instead. +/// This notification is shown when a challenge is created on the server while the app is in the background. +/// If the app is in the foreground, challenges are handled by WebSocket and a [ChallengeNotification] is shown instead. class ChallengeCreatedNotification extends LocalNotification { const ChallengeCreatedNotification(this.challengeId, String title, String body) : _title = title, diff --git a/test/model/challenge/challenge_service_test.dart b/test/model/challenge/challenge_service_test.dart index fa0e402097..d993184895 100644 --- a/test/model/challenge/challenge_service_test.dart +++ b/test/model/challenge/challenge_service_test.dart @@ -62,7 +62,7 @@ class _ShowDeclineDialogWidget extends ConsumerWidget { } void main() { - TestWidgetsFlutterBinding.ensureInitialized(); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { registerFallbackValue(const ChallengeId('')); @@ -73,6 +73,8 @@ void main() { tearDown(() { reset(notificationDisplayMock); + // reset lifecycle state between tests + binding.resetInternalState(); }); test('exposes a challenges stream', () async { @@ -124,6 +126,9 @@ void main() { }); test('Listen to socket and show a notification for any new challenge', () async { + // notifications from socket are only displayed if app is in foreground + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + when( () => notificationDisplayMock.show( id: any(named: 'id'), @@ -208,7 +213,71 @@ void main() { }); }); + test('Does not show local notification when app is in background', () async { + // when app is in background, socket notifications should not be displayed + binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + + when( + () => notificationDisplayMock.show( + id: any(named: 'id'), + title: any(named: 'title'), + body: any(named: 'body'), + notificationDetails: any(named: 'notificationDetails'), + payload: any(named: 'payload'), + ), + ).thenAnswer((_) => Future.value()); + + final container = await makeContainer( + authUser: fakeAuthUser, + overrides: { + notificationDisplayProvider: notificationDisplayProvider.overrideWithValue( + notificationDisplayMock, + ), + }, + ); + + final notificationService = container.read(notificationServiceProvider); + final challengeService = container.read(challengeServiceProvider); + + fakeAsync((async) { + final socketClient = makeTestSocketClient(); + socketClient.connect(); + notificationService.start(); + challengeService.start(); + + // wait for the socket to connect + async.elapse(const Duration(milliseconds: 100)); + async.flushMicrotasks(); + + sendServerSocketMessages(Uri(path: kDefaultSocketRoute), [ + ''' +{"t": "challenges", "d": {"in": [ { "socketVersion": 0, "id": "H9fIRZUk", "url": "https://lichess.org/H9fIRZUk", "status": "created", "challenger": { "id": "bot1", "name": "Bot1", "rating": 1500, "title": "BOT", "provisional": true, "online": true, "lag": 4 }, "destUser": { "id": "bobby", "name": "Bobby", "rating": 1635, "title": "GM", "provisional": true, "online": true, "lag": 4 }, "variant": { "key": "standard", "name": "Standard", "short": "Std" }, "rated": true, "speed": "rapid", "timeControl": { "type": "clock", "limit": 600, "increment": 0, "show": "10+0" }, "color": "random", "finalColor": "black", "perf": { "icon": "", "name": "Rapid" }, "direction": "in" } ] }, "v": 0 } +''', + ]); + + async.flushMicrotasks(); + + // ensure no notification is shown while app is backgrounded + verifyNever( + () => notificationDisplayMock.show( + id: any(named: 'id'), + title: any(named: 'title'), + body: any(named: 'body'), + notificationDetails: any(named: 'notificationDetails'), + payload: any(named: 'payload'), + ), + ); + + // closing the socket client to be able to flush the timers + socketClient.close(); + async.flushTimers(); + }); + }); + test('Cancels the notification for any missing challenge', () async { + // notifications from socket are only displayed if app is in foreground + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + when( () => notificationDisplayMock.show( id: any(named: 'id'),