fixed notification taps being ignored when they cold-started the app
This commit is contained in:
+13
-16
@@ -13,6 +13,7 @@ import 'main.dart';
|
|||||||
import 'model/account_data.dart';
|
import 'model/account_data.dart';
|
||||||
import 'model/data_cleaner.dart';
|
import 'model/data_cleaner.dart';
|
||||||
import 'notification/notification_controller.dart';
|
import 'notification/notification_controller.dart';
|
||||||
|
import 'notification/notification_service.dart';
|
||||||
import 'notification/notification_tasks.dart';
|
import 'notification/notification_tasks.dart';
|
||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
import 'push/push_tap_router.dart';
|
import 'push/push_tap_router.dart';
|
||||||
@@ -113,18 +114,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPushTapPending() {
|
void _onPushTargetPending() {
|
||||||
final token = PushTapRouter.pendingChatToken.value;
|
final target = PushTapRouter.pendingTarget.value;
|
||||||
if (token == null || !mounted) return;
|
if (target == null || !mounted) return;
|
||||||
PushTapRouter.pendingChatToken.value = null;
|
PushTapRouter.pendingTarget.value = null;
|
||||||
NotificationTasks.navigateToTalk(context, chatToken: token);
|
NotificationTasks.openPushTarget(context, target);
|
||||||
}
|
|
||||||
|
|
||||||
void _onNewsletterTapPending() {
|
|
||||||
final id = PushTapRouter.pendingNewsletterId.value;
|
|
||||||
if (id == null || !mounted) return;
|
|
||||||
PushTapRouter.pendingNewsletterId.value = null;
|
|
||||||
AppRoutes.openNewsletterById(context, id: id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePendingWidgetNavigation() async {
|
Future<void> _handlePendingWidgetNavigation() async {
|
||||||
@@ -237,8 +231,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
// Android renders pushes locally, so a tap arrives via the local
|
// Android renders pushes locally, so a tap arrives via the local
|
||||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
|
||||||
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
|
unawaited(
|
||||||
|
PushTapRouter.handleAppLaunch(
|
||||||
|
NotificationService().flutterLocalNotificationsPlugin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -271,8 +269,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_onMessageSub?.cancel();
|
_onMessageSub?.cancel();
|
||||||
_onMessageOpenedAppSub?.cancel();
|
_onMessageOpenedAppSub?.cancel();
|
||||||
_fcmTokenRefreshSub?.cancel();
|
_fcmTokenRefreshSub?.cancel();
|
||||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
|
||||||
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
|
|
||||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ abstract final class SessionWipe {
|
|||||||
_step('share folder cache', resetTalkShareFolderCache);
|
_step('share folder cache', resetTalkShareFolderCache);
|
||||||
_step('pending navigation', () {
|
_step('pending navigation', () {
|
||||||
AppRoutes.pendingChatToken.value = null;
|
AppRoutes.pendingChatToken.value = null;
|
||||||
PushTapRouter.pendingChatToken.value = null;
|
PushTapRouter.pendingTarget.value = null;
|
||||||
PushTapRouter.pendingNewsletterId.value = null;
|
|
||||||
});
|
});
|
||||||
_step('file clipboard', FileClipboard.instance.clear);
|
_step('file clipboard', FileClipboard.instance.clear);
|
||||||
_step('downloads', DownloadManager.instance.clearAll);
|
_step('downloads', DownloadManager.instance.clearAll);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../push/push_message_handler.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/bloc/chat_bloc.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../utils/debouncer.dart';
|
import '../utils/debouncer.dart';
|
||||||
@@ -51,18 +51,15 @@ class NotificationController {
|
|||||||
RemoteMessage message,
|
RemoteMessage message,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) async {
|
||||||
final newsletterId = _extractNewsletterId(message);
|
final target = resolvePushTarget(message.data);
|
||||||
if (newsletterId != null) {
|
if (target != null) {
|
||||||
AppRoutes.openNewsletterById(
|
NotificationTasks.openPushTarget(
|
||||||
context,
|
context,
|
||||||
id: newsletterId,
|
target,
|
||||||
title: message.notification?.title,
|
title: message.notification?.title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
NotificationTasks.navigateToTalk(
|
NotificationTasks.navigateToTalk(context);
|
||||||
context,
|
|
||||||
chatToken: _extractChatToken(message),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
unawaited(NotificationTasks.refreshBadge());
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../push/chat_thread_store.dart';
|
import '../push/chat_thread_store.dart';
|
||||||
|
import '../push/push_target.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'notification_service.dart';
|
import 'notification_service.dart';
|
||||||
@@ -72,6 +73,20 @@ class NotificationTasks {
|
|||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().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
|
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
||||||
/// the matching chat to be opened automatically once the chat list view
|
/// the matching chat to be opened automatically once the chat list view
|
||||||
/// resolves the token (handled inside [ChatList]).
|
/// resolves the token (handled inside [ChatList]).
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import 'push_actions.dart';
|
import 'push_actions.dart';
|
||||||
|
import 'push_target.dart';
|
||||||
|
|
||||||
/// Routes foreground notification interactions from the single
|
/// Routes foreground notification interactions from the single
|
||||||
/// flutter_local_notifications response callback. Action responses (reply /
|
/// flutter_local_notifications response callback. Action responses (reply /
|
||||||
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
|
/// 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 {
|
class PushTapRouter {
|
||||||
PushTapRouter._();
|
PushTapRouter._();
|
||||||
|
|
||||||
/// Chat token of the most recently tapped Talk notification, or null. [App]
|
/// Target of the most recently tapped notification, or null. [App] listens
|
||||||
/// listens to this and opens the chat, then resets it to null.
|
/// to this and navigates, then resets it to null.
|
||||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
static final ValueNotifier<PushTarget?> pendingTarget = 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<String?> pendingNewsletterId = ValueNotifier(null);
|
|
||||||
|
|
||||||
static void handleResponse(NotificationResponse response) {
|
static void handleResponse(NotificationResponse response) {
|
||||||
final actionId = response.actionId;
|
final actionId = response.actionId;
|
||||||
@@ -29,13 +27,28 @@ class PushTapRouter {
|
|||||||
}
|
}
|
||||||
final map = _payloadMap(response.payload);
|
final map = _payloadMap(response.payload);
|
||||||
if (map == null) return;
|
if (map == null) return;
|
||||||
final newsletterId = _stringValue(map, 'newsletterId');
|
final target = resolvePushTarget(map);
|
||||||
if (newsletterId != null) {
|
if (target != null) pendingTarget.value = target;
|
||||||
pendingNewsletterId.value = newsletterId;
|
}
|
||||||
return;
|
|
||||||
|
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<void> 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<String, dynamic>? _payloadMap(String? payload) {
|
static Map<String, dynamic>? _payloadMap(String? payload) {
|
||||||
@@ -46,9 +59,4 @@ class PushTapRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _stringValue(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
return value is String && value.isNotEmpty ? value : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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;
|
||||||
|
}
|
||||||
@@ -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<NewsletterTarget>().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<ChatTarget>().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<NewsletterTarget>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores broken payloads', () {
|
||||||
|
PushTapRouter.handleResponse(tap('not json'));
|
||||||
|
expect(PushTapRouter.pendingTarget.value, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user