fluffychat/lib/utils/push_helper.dart
wcjord 6f32aab48b
fix: show "knock accepted" push notification body instead of "You have been invited" (#5823) (#5835)
* fix: show knock-accepted notification body when invite follows a knock (#5823)

When Synapse accepts a knock it sends an m.room.member invite event. The client
was displaying the generic 'You have been invited by X' push notification body
because the invite event alone doesn't carry prev_content on the push path.

Use KnockTracker (already used for auto-join) to detect knock-accepted invites
and show a dedicated 'Your join request was accepted!' notification body instead.

- Add knockAccepted string to intl_en.arb
- Extract condition into isKnockAcceptedInvite() pure util for testability
- Expose KnockTracker.getKnockedRoomIds() publicly (was _getKnockedRoomIds)
- Override notification body in push_helper.dart (background) and
  local_notifications_extension.dart (foreground/web)
- Unit tests for all isKnockAcceptedInvite() branches (9 tests)

* formatting

* fix up pangea comments

* fix: avoid race condition with knocked room account data updates and local push notification content

* translations

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
2026-02-27 12:29:50 -05:00

468 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_shortcuts_new/flutter_shortcuts_new.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/join_codes/knock_notification_utils.dart';
import 'package:fluffychat/utils/client_download_content_extension.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/notification_background_handler.dart';
import 'package:fluffychat/utils/platform_infos.dart';
const notificationAvatarDimension = 128;
Future<void> pushHelper(
PushNotification notification, {
Client? client,
L10n? l10n,
String? activeRoomId,
required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin,
// #Pangea
Map<String, dynamic>? additionalData,
// Pangea#
bool useNotificationActions = true,
}) async {
try {
await _tryPushHelper(
notification,
client: client,
l10n: l10n,
activeRoomId: activeRoomId,
flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin,
// #Pangea
additionalData: additionalData,
// Pangea#
useNotificationActions: useNotificationActions,
);
} catch (e, s) {
Logs().e('Push Helper has crashed! Writing into temporary file', e, s);
l10n ??= await lookupL10n(PlatformDispatcher.instance.locale);
flutterLocalNotificationsPlugin.show(
notification.roomId?.hashCode ?? 0,
// #Pangea
// l10n.newMessageInFluffyChat,
l10n.newMessageInPangeaChat,
// Pangea#
l10n.openAppToReadMessages,
NotificationDetails(
iOS: const DarwinNotificationDetails(),
android: AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
l10n.incomingMessages,
number: notification.counts?.unread,
ticker: l10n.unreadChatsInApp(
AppSettings.applicationName.value,
(notification.counts?.unread ?? 0).toString(),
),
importance: Importance.high,
priority: Priority.max,
shortcutId: notification.roomId,
),
),
);
rethrow;
}
}
Future<void> _tryPushHelper(
PushNotification notification, {
Client? client,
L10n? l10n,
String? activeRoomId,
required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin,
// #Pangea
Map<String, dynamic>? additionalData,
// Pangea#
bool useNotificationActions = true,
}) async {
final isBackgroundMessage = client == null;
Logs().v(
'Push helper has been started (background=$isBackgroundMessage).',
notification.toJson(),
);
if (notification.roomId != null &&
activeRoomId == notification.roomId &&
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
Logs().v('Room is in foreground. Stop push helper here.');
return;
}
client ??= (await ClientManager.getClients(
initialize: false,
store: await AppSettings.init(),
)).first;
final event = await client.getEventByPushNotification(
notification,
storeInDatabase: false,
);
if (event == null) {
Logs().v('Notification is a clearing indicator.');
if (notification.counts?.unread == null ||
notification.counts?.unread == 0) {
await flutterLocalNotificationsPlugin.cancelAll();
} else {
// Make sure client is fully loaded and synced before dismiss notifications:
await client.roomsLoading;
await client.oneShotSync();
final activeNotifications = await flutterLocalNotificationsPlugin
.getActiveNotifications();
for (final activeNotification in activeNotifications) {
final room = client.rooms.singleWhereOrNull(
(room) => room.id.hashCode == activeNotification.id,
);
if (room == null || !room.isUnreadOrInvited) {
flutterLocalNotificationsPlugin.cancel(activeNotification.id!);
}
}
}
return;
}
Logs().v('Push helper got notification event of type ${event.type}.');
if (event.type.startsWith('m.call')) {
// make sure bg sync is on (needed to update hold, unhold events)
// prevent over write from app life cycle change
client.backgroundSync = true;
}
if (event.type == EventTypes.CallHangup) {
client.backgroundSync = false;
}
if (event.type.startsWith('m.call') && event.type != EventTypes.CallInvite) {
Logs().v('Push message is a m.call but not invite. Do not display.');
return;
}
if ((event.type.startsWith('m.call') &&
event.type != EventTypes.CallInvite) ||
event.type == 'org.matrix.call.sdp_stream_metadata_changed') {
Logs().v('Push message was for a call, but not call invite.');
return;
}
l10n ??= await L10n.delegate.load(PlatformDispatcher.instance.locale);
final matrixLocals = MatrixLocals(l10n);
// Calculate the body
// #Pangea
// final body = event.type == EventTypes.Encrypted
// ? l10n.newMessageInFluffyChat
// : await event.calcLocalizedBody(
// matrixLocals,
// plaintextBody: true,
// withSenderNamePrefix: false,
// hideReply: true,
// hideEdit: true,
// removeMarkdown: true,
// );
final hasKnocked = isKnockAcceptedInviteForClient(
event: event,
client: client,
);
final body = hasKnocked
? l10n.knockAccepted
: event.type == EventTypes.Encrypted
? l10n.newMessageInPangeaChat
: await event.calcLocalizedBody(
matrixLocals,
plaintextBody: true,
withSenderNamePrefix: false,
hideReply: true,
hideEdit: true,
removeMarkdown: true,
);
// Pangea#
// The person object for the android message style notification
final avatar = event.room.avatar;
final senderAvatar = event.room.isDirectChat
? avatar
: event.senderFromMemoryOrFallback.avatarUrl;
Uint8List? roomAvatarFile, senderAvatarFile;
try {
roomAvatarFile = avatar == null
? null
: await client
.downloadMxcCached(
avatar,
thumbnailMethod: ThumbnailMethod.crop,
width: notificationAvatarDimension,
height: notificationAvatarDimension,
animated: false,
isThumbnail: true,
rounded: true,
)
.timeout(const Duration(seconds: 3));
} catch (e, s) {
Logs().e('Unable to get avatar picture', e, s);
// #Pangea
ErrorHandler.logError(e: e, s: s, data: {"avatarUri": avatar.toString()});
// Pangea#
}
try {
senderAvatarFile = event.room.isDirectChat
? roomAvatarFile
: senderAvatar == null
? null
: await client
.downloadMxcCached(
senderAvatar,
thumbnailMethod: ThumbnailMethod.crop,
width: notificationAvatarDimension,
height: notificationAvatarDimension,
animated: false,
isThumbnail: true,
rounded: true,
)
.timeout(const Duration(seconds: 3));
} catch (e, s) {
Logs().e('Unable to get avatar picture', e, s);
}
final id = notification.roomId.hashCode;
final senderName = event.senderFromMemoryOrFallback.calcDisplayname();
// Show notification
final newMessage = Message(
body,
event.originServerTs,
Person(
bot: event.messageType == MessageTypes.Notice,
key: event.senderId,
name: senderName,
icon: senderAvatarFile == null
? null
: ByteArrayAndroidIcon(senderAvatarFile),
),
);
final messagingStyleInformation = PlatformInfos.isAndroid
? await AndroidFlutterLocalNotificationsPlugin()
.getActiveNotificationMessagingStyle(id)
: null;
messagingStyleInformation?.messages?.add(newMessage);
final roomName = event.room.getLocalizedDisplayname(MatrixLocals(l10n));
final notificationGroupId = event.room.isDirectChat
? 'directChats'
: 'groupChats';
// #Pangea
// final groupName = event.room.isDirectChat ? l10n.directChats : l10n.groups;
final groupName = event.room.isDirectChat ? l10n.directChats : l10n.chats;
// Pangea#
final messageRooms = AndroidNotificationChannelGroup(
notificationGroupId,
groupName,
);
final roomsChannel = AndroidNotificationChannel(
event.room.id,
roomName,
groupId: notificationGroupId,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannelGroup(messageRooms);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(roomsChannel);
final androidPlatformChannelSpecifics = AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
l10n.incomingMessages,
number: notification.counts?.unread,
category: AndroidNotificationCategory.message,
shortcutId: event.room.id,
styleInformation:
messagingStyleInformation ??
MessagingStyleInformation(
Person(
name: senderName,
icon: roomAvatarFile == null
? null
: ByteArrayAndroidIcon(roomAvatarFile),
key: event.roomId,
important: event.room.isFavourite,
),
conversationTitle: event.room.isDirectChat ? null : roomName,
groupConversation: !event.room.isDirectChat,
messages: [newMessage],
),
ticker: event.calcLocalizedBodyFallback(
matrixLocals,
plaintextBody: true,
withSenderNamePrefix: !event.room.isDirectChat,
hideReply: true,
hideEdit: true,
removeMarkdown: true,
),
importance: Importance.high,
priority: Priority.max,
groupKey: event.room.spaceParents.firstOrNull?.roomId ?? 'rooms',
actions: event.type == EventTypes.RoomMember || !useNotificationActions
? null
: <AndroidNotificationAction>[
AndroidNotificationAction(
FluffyChatNotificationActions.reply.name,
l10n.reply,
inputs: [
AndroidNotificationActionInput(label: l10n.writeAMessage),
],
cancelNotification: false,
allowGeneratedReplies: true,
semanticAction: SemanticAction.reply,
),
AndroidNotificationAction(
FluffyChatNotificationActions.markAsRead.name,
l10n.markAsRead,
semanticAction: SemanticAction.markAsRead,
),
],
);
const iOSPlatformChannelSpecifics = DarwinNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics,
);
final title = event.room.getLocalizedDisplayname(MatrixLocals(l10n));
if (PlatformInfos.isAndroid && messagingStyleInformation == null) {
await _setShortcut(event, l10n, title, roomAvatarFile);
}
// #Pangea - Include activity session data in payload
final Map<String, String> additionalDataMap = {};
if (additionalData != null) {
additionalData.forEach((key, value) {
if (value is String) {
additionalDataMap[key] = value;
}
});
}
final payload = FluffyChatPushPayload(
client.clientName,
event.room.id,
event.eventId,
additionalData: additionalDataMap,
).toString();
// Pangea#
await flutterLocalNotificationsPlugin.show(
id,
title,
body,
platformChannelSpecifics,
// #Pangea
// payload: FluffyChatPushPayload(
// client.clientName,
// event.room.id,
// event.eventId,
// ).toString(),
payload: payload,
// Pangea#
);
Logs().v('Push helper has been completed!');
}
class FluffyChatPushPayload {
final String? clientName, roomId, eventId;
// #Pangea
final Map<String, String> additionalData;
// Pangea#
// #Pangea
// FluffyChatPushPayload(this.clientName, this.roomId, this.eventId);
FluffyChatPushPayload(
this.clientName,
this.roomId,
this.eventId, {
this.additionalData = const {},
});
// Pangea#
factory FluffyChatPushPayload.fromString(String payload) {
final parts = payload.split('|');
// #Pangea
// if (parts.length != 3) {
// return FluffyChatPushPayload(null, null, null);
// }
// return FluffyChatPushPayload(parts[0], parts[1], parts[2]);
if (parts.length < 3) {
return FluffyChatPushPayload(null, null, null);
}
Map<String, String> additionalData = {};
if (parts.length > 3) {
try {
additionalData = Map<String, String>.from(jsonDecode(parts[3]));
} catch (e, s) {
Logs().e('Unable to parse additional data from payload', e, s);
}
}
return FluffyChatPushPayload(
parts[0],
parts[1],
parts[2],
additionalData: additionalData,
);
// Pangea#
}
// #Pangea
// @override
// String toString() => '$clientName|$roomId|$eventId';
@override
String toString() =>
'$clientName|$roomId|$eventId|${jsonEncode(additionalData)}';
// Pangea#
}
/// Creates a shortcut for Android platform but does not block displaying the
/// notification. This is optional but provides a nicer view of the
/// notification popup.
Future<void> _setShortcut(
Event event,
L10n l10n,
String title,
Uint8List? avatarFile,
) async {
final flutterShortcuts = FlutterShortcuts();
await flutterShortcuts.initialize(debug: !kReleaseMode);
await flutterShortcuts.pushShortcutItem(
shortcut: ShortcutItem(
id: event.room.id,
action: AppConfig.inviteLinkPrefix + event.room.id,
shortLabel: title,
conversationShortcut: true,
icon: avatarFile == null ? null : base64Encode(avatarFile),
shortcutIconAsset: avatarFile == null
? ShortcutIconAsset.androidAsset
: ShortcutIconAsset.memoryAsset,
isImportant: event.room.isFavourite,
),
);
}