diff --git a/lib/app.dart b/lib/app.dart index a8d38f1..37d1fae 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,6 +13,7 @@ import 'main.dart'; import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; +import 'notification/notification_service.dart'; import 'notification/notification_tasks.dart'; import 'push/push_registration.dart'; import 'push/push_tap_router.dart'; @@ -113,18 +114,11 @@ class _AppState extends State with WidgetsBindingObserver { } } - void _onPushTapPending() { - final token = PushTapRouter.pendingChatToken.value; - if (token == null || !mounted) return; - PushTapRouter.pendingChatToken.value = null; - NotificationTasks.navigateToTalk(context, chatToken: token); - } - - void _onNewsletterTapPending() { - final id = PushTapRouter.pendingNewsletterId.value; - if (id == null || !mounted) return; - PushTapRouter.pendingNewsletterId.value = null; - AppRoutes.openNewsletterById(context, id: id); + void _onPushTargetPending() { + final target = PushTapRouter.pendingTarget.value; + if (target == null || !mounted) return; + PushTapRouter.pendingTarget.value = null; + NotificationTasks.openPushTarget(context, target); } Future _handlePendingWidgetNavigation() async { @@ -237,8 +231,12 @@ class _AppState extends State with WidgetsBindingObserver { // Android renders pushes locally, so a tap arrives via the local // notifications callback (PushTapRouter) rather than onMessageOpenedApp. - PushTapRouter.pendingChatToken.addListener(_onPushTapPending); - PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending); + PushTapRouter.pendingTarget.addListener(_onPushTargetPending); + unawaited( + PushTapRouter.handleAppLaunch( + NotificationService().flutterLocalNotificationsPlugin, + ), + ); _onMessageSub = FirebaseMessaging.onMessage.listen((message) { if (!mounted) return; @@ -271,8 +269,7 @@ class _AppState extends State with WidgetsBindingObserver { _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); - PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); - PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending); + PushTapRouter.pendingTarget.removeListener(_onPushTargetPending); ShareIntentListener.pending.removeListener(_handlePendingShare); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index 644f97a..5efa03e 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -46,8 +46,7 @@ abstract final class SessionWipe { _step('share folder cache', resetTalkShareFolderCache); _step('pending navigation', () { AppRoutes.pendingChatToken.value = null; - PushTapRouter.pendingChatToken.value = null; - PushTapRouter.pendingNewsletterId.value = null; + PushTapRouter.pendingTarget.value = null; }); _step('file clipboard', FileClipboard.instance.clear); _step('downloads', DownloadManager.instance.clearAll); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 817ce2f..dd35a00 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/push_message_handler.dart'; -import '../routing/app_routes.dart'; +import '../push/push_target.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../utils/debouncer.dart'; @@ -51,18 +51,15 @@ class NotificationController { RemoteMessage message, BuildContext context, ) async { - final newsletterId = _extractNewsletterId(message); - if (newsletterId != null) { - AppRoutes.openNewsletterById( + final target = resolvePushTarget(message.data); + if (target != null) { + NotificationTasks.openPushTarget( context, - id: newsletterId, + target, title: message.notification?.title, ); } else { - NotificationTasks.navigateToTalk( - context, - chatToken: _extractChatToken(message), - ); + NotificationTasks.navigateToTalk(context); } NotificationTasks.updateProviders(context); unawaited(NotificationTasks.refreshBadge()); @@ -77,17 +74,4 @@ class NotificationController { ); }); } - - static String? _extractChatToken(RemoteMessage message) { - for (final key in const ['chatToken', 'token', 'roomToken']) { - final value = message.data[key]; - if (value is String && value.isNotEmpty) return value; - } - return null; - } - - static String? _extractNewsletterId(RemoteMessage message) { - final value = message.data['newsletterId']; - return value is String && value.isNotEmpty ? value : null; - } } diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index e3eb449..57d2913 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -7,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/chat_thread_store.dart'; +import '../push/push_target.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'notification_service.dart'; @@ -72,6 +73,20 @@ class NotificationTasks { context.read().refresh(); } + /// [title] is the notification title, when the tap came with one. + static void openPushTarget( + BuildContext context, + PushTarget target, { + String? title, + }) { + switch (target) { + case NewsletterTarget(:final newsletterId): + AppRoutes.openNewsletterById(context, id: newsletterId, title: title); + case ChatTarget(:final chatToken): + navigateToTalk(context, chatToken: chatToken); + } + } + /// Switches to the Talk tab. If [chatToken] is provided, also schedules /// the matching chat to be opened automatically once the chat list view /// resolves the token (handled inside [ChatList]). diff --git a/lib/push/push_tap_router.dart b/lib/push/push_tap_router.dart index b2ff32e..f77102a 100644 --- a/lib/push/push_tap_router.dart +++ b/lib/push/push_tap_router.dart @@ -1,24 +1,22 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'push_actions.dart'; +import 'push_target.dart'; /// Routes foreground notification interactions from the single /// flutter_local_notifications response callback. Action responses (reply / /// mark-read) are dispatched straight to [PushActions]; a plain tap publishes -/// the target chat token via [pendingChatToken] for [App] to navigate to. +/// its target via [pendingTarget] for [App] to navigate to. class PushTapRouter { PushTapRouter._(); - /// Chat token of the most recently tapped Talk notification, or null. [App] - /// listens to this and opens the chat, then resets it to null. - static final ValueNotifier pendingChatToken = ValueNotifier(null); - - /// Newsletter id of the most recently tapped Marianum-Message notification, - /// or null. [App] listens to this and opens the message, then resets it. - static final ValueNotifier pendingNewsletterId = ValueNotifier(null); + /// Target of the most recently tapped notification, or null. [App] listens + /// to this and navigates, then resets it to null. + static final ValueNotifier pendingTarget = ValueNotifier(null); static void handleResponse(NotificationResponse response) { final actionId = response.actionId; @@ -29,13 +27,28 @@ class PushTapRouter { } final map = _payloadMap(response.payload); if (map == null) return; - final newsletterId = _stringValue(map, 'newsletterId'); - if (newsletterId != null) { - pendingNewsletterId.value = newsletterId; - return; + final target = resolvePushTarget(map); + if (target != null) pendingTarget.value = target; + } + + static bool _launchHandled = false; + + /// Routes the tap that cold-started the app. The plugin reports such a tap + /// only through its launch details, never through the response callback; + /// the details stay set for the whole process, hence the one-shot guard. + static Future handleAppLaunch( + FlutterLocalNotificationsPlugin plugin, + ) async { + if (_launchHandled) return; + _launchHandled = true; + try { + final details = await plugin.getNotificationAppLaunchDetails(); + final response = details?.notificationResponse; + if (details?.didNotificationLaunchApp != true || response == null) return; + handleResponse(response); + } on Object catch (e) { + log('Reading the notification launch details failed: $e'); } - final token = _stringValue(map, 'chatToken'); - if (token != null) pendingChatToken.value = token; } static Map? _payloadMap(String? payload) { @@ -46,9 +59,4 @@ class PushTapRouter { return null; } } - - static String? _stringValue(Map map, String key) { - final value = map[key]; - return value is String && value.isNotEmpty ? value : null; - } } diff --git a/lib/push/push_target.dart b/lib/push/push_target.dart new file mode 100644 index 0000000..11b24e0 --- /dev/null +++ b/lib/push/push_target.dart @@ -0,0 +1,32 @@ +/// Where a tapped notification leads. +sealed class PushTarget { + const PushTarget(); +} + +class NewsletterTarget extends PushTarget { + final String newsletterId; + const NewsletterTarget(this.newsletterId); +} + +class ChatTarget extends PushTarget { + final String chatToken; + const ChatTarget(this.chatToken); +} + +/// Resolves the data of a tapped notification — the payload of a locally +/// rendered one as well as the data of an FCM message. Null when it names no +/// known target. +PushTarget? resolvePushTarget(Map data) { + String? value(String key) { + final value = data[key]; + return value is String && value.isNotEmpty ? value : null; + } + + if (value('newsletterId') case final newsletterId?) { + return NewsletterTarget(newsletterId); + } + for (final key in const ['chatToken', 'token', 'roomToken']) { + if (value(key) case final chatToken?) return ChatTarget(chatToken); + } + return null; +} diff --git a/test/push/push_tap_router_test.dart b/test/push/push_tap_router_test.dart new file mode 100644 index 0000000..27fc8c7 --- /dev/null +++ b/test/push/push_tap_router_test.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_tap_router.dart'; +import 'package:marianum_mobile/push/push_target.dart'; + +void main() { + group('resolvePushTarget', () { + test('newsletter pushes route by newsletterId', () { + expect( + resolvePushTarget({'source': 'connect', 'newsletterId': 'n1'}), + isA().having((t) => t.newsletterId, 'id', 'n1'), + ); + }); + + test('chat pushes accept every token key in use', () { + for (final key in ['chatToken', 'token', 'roomToken']) { + expect( + resolvePushTarget({key: 'abc', 'nid': 5}), + isA().having((t) => t.chatToken, 'chatToken', 'abc'), + reason: key, + ); + } + }); + + test('empty ids and unrelated data name no target', () { + expect(resolvePushTarget({'newsletterId': ''}), isNull); + expect(resolvePushTarget({'type': 'widget-refresh'}), isNull); + expect(resolvePushTarget({'chatToken': 7}), isNull); + }); + }); + + group('PushTapRouter.handleResponse', () { + setUp(() => PushTapRouter.pendingTarget.value = null); + + NotificationResponse tap(String payload) => NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: payload, + ); + + test('publishes the target of a tapped notification', () { + PushTapRouter.handleResponse(tap(jsonEncode({'newsletterId': 'n1'}))); + expect(PushTapRouter.pendingTarget.value, isA()); + }); + + test('ignores broken payloads', () { + PushTapRouter.handleResponse(tap('not json')); + expect(PushTapRouter.pendingTarget.value, isNull); + }); + }); +}