From c9fa75c4e3287104ac6fee707395f1c3f32b43f2 Mon Sep 17 00:00:00 2001 From: Julien <120588494+julien4215@users.noreply.github.com> Date: Sat, 23 May 2026 18:32:35 +0200 Subject: [PATCH 1/2] Only display the challenge received notifications from the socket if the app is in the foregroud because fcm already shows the notification when the app is in the background. Before this fix, when the app is in the background, it might show the notification from the socket (when the socket is still alive in the background) which is later overriden by the notification from fcm resulting in a confusing experience for the user. --- .../model/challenge/challenge_service.dart | 24 ++++++++++++------- .../model/notifications/notifications.dart | 4 ++-- .../challenge/challenge_service_test.dart | 8 +++++++ 3 files changed, 26 insertions(+), 10 deletions(-) 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..9fe095586b 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 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..d3a065f6ba 100644 --- a/test/model/challenge/challenge_service_test.dart +++ b/test/model/challenge/challenge_service_test.dart @@ -134,6 +134,10 @@ void main() { ), ).thenAnswer((_) => Future.value()); + // notifications from socket are only displayed if app is in foreground + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final container = await makeContainer( authUser: fakeAuthUser, overrides: { @@ -223,6 +227,10 @@ void main() { () => notificationDisplayMock.cancel(id: any(named: 'id')), ).thenAnswer((_) => Future.value()); + // notifications from socket are only displayed if app is in foreground + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final container = await makeContainer( authUser: fakeAuthUser, overrides: { From 152051482c5b414523f82e76e184c8162498714f Mon Sep 17 00:00:00 2001 From: Julien <120588494+julien4215@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:27:46 +0200 Subject: [PATCH 2/2] Test that challenge local notification from socket is not displayed when the app is in the background --- .../model/notifications/notifications.dart | 4 +- .../challenge/challenge_service_test.dart | 79 ++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/lib/src/model/notifications/notifications.dart b/lib/src/model/notifications/notifications.dart index 9fe095586b..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 app is in background. -/// If the app is in the foreground, 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 d3a065f6ba..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'), @@ -134,10 +139,6 @@ void main() { ), ).thenAnswer((_) => Future.value()); - // notifications from socket are only displayed if app is in foreground - final binding = TestWidgetsFlutterBinding.ensureInitialized(); - binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - final container = await makeContainer( authUser: fakeAuthUser, overrides: { @@ -212,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'), @@ -227,10 +292,6 @@ void main() { () => notificationDisplayMock.cancel(id: any(named: 'id')), ).thenAnswer((_) => Future.value()); - // notifications from socket are only displayed if app is in foreground - final binding = TestWidgetsFlutterBinding.ensureInitialized(); - binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - final container = await makeContainer( authUser: fakeAuthUser, overrides: {