From 05809451058d7cf4b73435c701372fbf741b3e46 Mon Sep 17 00:00:00 2001 From: Julien <120588494+julien4215@users.noreply.github.com> Date: Wed, 13 May 2026 23:15:55 +0200 Subject: [PATCH] Support the same notifications as the Play Store mobile app --- lib/firebase_stubs.dart | 24 +++- .../notifications/notification_service.dart | 105 ++++++++++++++---- 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/lib/firebase_stubs.dart b/lib/firebase_stubs.dart index ae234982e..f067fe5f7 100644 --- a/lib/firebase_stubs.dart +++ b/lib/firebase_stubs.dart @@ -1,14 +1,28 @@ -// File used to remove Firebase dependencies. +// ignore_for_file: avoid_classes_with_only_static_members + import 'package:flutter/foundation.dart'; class RemoteMessage { - Map data = const {}; - RemoteNotification? notification; + final Map data; + final RemoteNotification? notification; + + RemoteMessage(this.data, this.notification); + + factory RemoteMessage.fromJson(Map json) { + final data = json['data'] as Map; + final notification = json['notification'] as Map?; + final remoteNotification = notification != null + ? RemoteNotification(notification['title'] as String, notification['body'] as String) + : null; + return RemoteMessage(data, remoteNotification); + } } class RemoteNotification { - String? title; - String? body; + final String? title; + final String? body; + + RemoteNotification(this.title, this.body); } class Firebase { diff --git a/lib/src/model/notifications/notification_service.dart b/lib/src/model/notifications/notification_service.dart index 65fc03759..451f87078 100644 --- a/lib/src/model/notifications/notification_service.dart +++ b/lib/src/model/notifications/notification_service.dart @@ -1,15 +1,15 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/firebase_stubs.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/binding.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/localizations.dart'; import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; -import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; import 'package:lichess_mobile/src/model/notifications/notifications.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -79,9 +79,6 @@ class NotificationService { /// The stream subscription for notification responses. StreamSubscription? _responseStreamSubscription; - /// Whether the device has been registered for push notifications. - bool _registeredDevice = false; - AppLocalizations get _l10n => _ref.read(localizationsProvider).strings; FlutterLocalNotificationsPlugin get _notificationDisplay => @@ -106,8 +103,6 @@ class NotificationService { final isRegistered = await UnifiedPush.initialize( onNewEndpoint: onNewEndpoint, - onRegistrationFailed: onRegistrationFailed, - onUnregistered: onUnregistered, onMessage: onMessage, ); if (isRegistered) { @@ -167,6 +162,75 @@ class NotificationService { _responseStreamController.add((response, notification)); } + /// Process a message received from the Firebase Cloud Messaging service. + /// + /// If the message contains a [RemoteMessage.notification] field and if it is + /// received while the app was in foreground, the notification is by default not + /// shown to the user. + /// Depending on the message type, we may as well show a local notification. + /// + /// Some messages (whether or not they have an associated notification), have + /// a [RemoteMessage.data] field used to update the application state according + /// to the message type. + /// + /// A special data field, 'lichess.iosBadge', is used to update the iOS app's + /// badge count according to the value held by the server. + Future _processFcmMessage( + RemoteMessage message, { + + /// Whether the message was received while the app was in the background. + required bool fromBackground, + }) async { + _logger.fine( + 'Processing a FCM message from ${fromBackground ? 'background' : 'foreground'}: ${message.data}', + ); + + final parsedMessage = FcmMessage.fromRemoteMessage(message); + + _fcmMessageStreamController.add((message: parsedMessage, fromBackground: fromBackground)); + + switch (parsedMessage) { + case CorresGameUpdateFcmMessage(fullId: final fullId, notification: final notification): + if (notification != null) { + await show(CorresGameUpdateNotification(fullId, notification.title!, notification.body!)); + } + + case NewMessageFcmMessage(conversationId: final userId, notification: final notification): + if (notification != null) { + await show(NewMessageNotification(userId, notification.title!, notification.body!)); + } + + case ChallengeCreateFcmMessage(id: final id, notification: final notification): + // nothing to do here in foreground as it should be handled by the socket + if (fromBackground == true && notification != null) { + await show(ChallengeCreatedNotification(id, notification.title!, notification.body!)); + } + + case ChallengeAcceptFcmMessage(fullId: final fullId, notification: final notification): + if (notification != null) { + await show( + ChallengeAcceptedNotification(fullId, notification.title!, notification.body!), + ); + } + + case UnhandledFcmMessage(data: final data): + _logger.warning('Received unhandled FCM notification type: ${data['lichess.type']}'); + + case MalformedFcmMessage(data: final data): + _logger.severe('Received malformed FCM message: $data'); + } + + // update badge + final badge = message.data['lichess.iosBadge'] as String?; + if (badge != null) { + try { + await BadgeService.instance.setBadge(int.parse(badge)); + } catch (e) { + _logger.severe('Could not parse badge: $badge'); + } + } + } + /// Register the device for push notifications. /// /// Returns true if the device was successfully registered, false otherwise. @@ -220,24 +284,17 @@ class NotificationService { }); } - void onRegistrationFailed(FailedReason reason, String instance) { - _logger.warning('registration failed for reason: $reason'); - } - - void onUnregistered(String instance) { - _logger.info('device unregistered'); - } - void onMessage(PushMessage message, String instance) { + _logger.info('received a push message'); final content = jsonDecode(utf8.decode(message.content)) as Map; - final channel = content['tag'] as String; - _notificationDisplay.show( - id: DateTime.now().microsecondsSinceEpoch % 100000000, - title: content['title'] as String, - body: content['body'] as String, - notificationDetails: NotificationDetails( - android: AndroidNotificationDetails(channel, channel), - ), - ); + final remoteMessage = RemoteMessage.fromJson(content); + + final AppLifecycleState? appState = WidgetsBinding.instance.lifecycleState; + if (appState == null) { + _logger.warning('could not detect app lifecycle state'); + } + final inForeground = appState == AppLifecycleState.resumed; + + _processFcmMessage(remoteMessage, fromBackground: !inForeground); } }