From d4bbe15ed65d5a20afbc3a345195cb542e3b1047 Mon Sep 17 00:00:00 2001 From: OfficialDakari Date: Thu, 16 Oct 2025 20:36:40 +0500 Subject: [PATCH] copy notification actions feature from fluffychat --- android/app/src/main/AndroidManifest.xml | 2 + lib/utils/background_push.dart | 47 +++- .../builder.dart | 1 + .../notification_background_handler.dart | 237 ++++++++++++++++++ lib/utils/push_helper.dart | 79 ++++-- pubspec.lock | 24 +- pubspec.yaml | 2 +- windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 359 insertions(+), 34 deletions(-) create mode 100644 lib/utils/notification_background_handler.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 08854a6..f15a756 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -149,6 +149,8 @@ + + notificationTap( + response, + client: client, + router: FluffyChatApp.router, + l10n: l10n + ), + onDidReceiveBackgroundNotificationResponse: notificationTapBackground, ); Logs().v('Flutter Local Notifications initialized'); firebase?.setListeners( @@ -278,7 +309,16 @@ class BackgroundPush { return; } _wentToRoomOnStartup = true; - goToRoom(details.notificationResponse); + final response = details.notificationResponse; + + if (response != null) { + notificationTap( + response, + client: client, + router: FluffyChatApp.router, + l10n: l10n + ); + } }); } @@ -351,7 +391,8 @@ class BackgroundPush { final replyText = response?.input; final room = client.getRoomById(roomId); if (replyText != null && room != null) { - await room.sendTextEvent(replyText, inReplyTo: await room.getEventById(eventId)); + await room.sendTextEvent(replyText, + inReplyTo: await room.getEventById(eventId)); } return; } diff --git a/lib/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/builder.dart b/lib/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/builder.dart index 7c5f2c1..f432bbe 100644 --- a/lib/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/builder.dart +++ b/lib/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/builder.dart @@ -109,6 +109,7 @@ Future _constructDatabase(String clientName) async { version: 1, // most important : apply encryption when opening the DB onConfigure: helper?.applyPragmaKey, + singleInstance: false ), ); diff --git a/lib/utils/notification_background_handler.dart b/lib/utils/notification_background_handler.dart new file mode 100644 index 0000000..415c759 --- /dev/null +++ b/lib/utils/notification_background_handler.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:extera_next/config/app_config.dart'; +import 'package:extera_next/generated/l10n/l10n.dart'; +import 'package:extera_next/utils/client_download_content_extension.dart'; +import 'package:extera_next/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:extera_next/utils/platform_infos.dart'; +import 'package:extera_next/utils/push_helper.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:extera_next/utils/client_manager.dart'; + +bool _vodInitialized = false; + +extension NotificationResponseJson on NotificationResponse { + String toJsonString() => jsonEncode({ + 'type': notificationResponseType.name, + 'id': id, + 'actionId': actionId, + 'input': input, + 'payload': payload, + 'data': data, + }); + + static NotificationResponse fromJsonString(String jsonString) { + final json = jsonDecode(jsonString) as Map; + return NotificationResponse( + notificationResponseType: NotificationResponseType.values + .singleWhere((t) => t.name == json['type']), + id: json['id'] as int?, + actionId: json['actionId'] as String?, + input: json['input'] as String?, + payload: json['payload'] as String?, + data: json['data'] as Map, + ); + } +} + +@pragma('vm:entry-point') +void notificationTapBackground( + NotificationResponse notificationResponse, +) async { + Logs().i('Notification tap in background'); + + final sendPort = IsolateNameServer.lookupPortByName('background_tab_port'); + if (sendPort != null) { + sendPort.send(notificationResponse.toJsonString()); + return; + } + + if (!_vodInitialized) { + await vod.init(); + _vodInitialized = true; + } + final store = await SharedPreferences.getInstance(); + final client = (await ClientManager.getClients( + initialize: false, + store: store, + )) + .first; + + await client.abortSync(); + await client.init( + waitForFirstSync: false, + waitUntilLoadCompletedLoaded: false, + ); + if (!client.isLogged()) { + throw Exception('Notification tab in background but not logged in!'); + } + try { + await notificationTap(notificationResponse, client: client); + } finally { + await client.dispose(closeDatabase: false); + } + return; +} + +Future notificationTap( + NotificationResponse notificationResponse, { + GoRouter? router, + L10n? l10n, + required Client client, +}) async { + Logs().d( + 'Notification action handler started', + notificationResponse.notificationResponseType.name, + ); + final payload = + NotificationPushPayload.fromString(notificationResponse.payload ?? ''); + switch (notificationResponse.notificationResponseType) { + case NotificationResponseType.selectedNotification: + final roomId = payload.roomId; + if (roomId == null) return; + + if (router == null) { + Logs().v('Ignore select notification action in background mode'); + return; + } + Logs().v('Open room from notification tap', roomId); + await client.roomsLoading; + await client.accountDataLoading; + if (client.getRoomById(roomId) == null) { + await client + .waitForRoomInSync(roomId) + .timeout(const Duration(seconds: 30)); + } + router.go( + client.getRoomById(roomId)?.membership == Membership.invite + ? '/rooms' + : '/rooms/$roomId', + ); + case NotificationResponseType.selectedNotificationAction: + final actionType = FluffyChatNotificationActions.values.singleWhereOrNull( + (action) => action.name == notificationResponse.actionId, + ); + if (actionType == null) { + throw Exception('Selected notification with action but no action ID'); + } + final roomId = notificationResponse.payload; + if (roomId == null) { + throw Exception('Selected notification with action but no payload'); + } + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + final room = client.getRoomById(roomId); + if (room == null) { + throw Exception( + 'Selected notification with action but unknown room $roomId', + ); + } + switch (actionType) { + case FluffyChatNotificationActions.markAsRead: + await room.setReadMarker( + payload!.eventId, + mRead: payload!.eventId, + public: + AppConfig.sendPublicReadReceipts, // TODO: Load preference here + ); + case FluffyChatNotificationActions.reply: + final input = notificationResponse.input; + if (input == null || input.isEmpty) { + throw Exception( + 'Selected notification with reply action but without input', + ); + } + final eventId = await room.sendTextEvent(input); + + if (PlatformInfos.isAndroid) { + final ownProfile = await room.client.fetchOwnProfile(); + + final avatar = ownProfile.avatarUrl; + final avatarFile = avatar == null + ? null + : await client + .downloadMxcCached( + avatar, + thumbnailMethod: ThumbnailMethod.scale, + width: notificationAvatarDimension, + height: notificationAvatarDimension, + animated: false, + isThumbnail: true, + rounded: true, + ) + .timeout(const Duration(seconds: 3)); + final messagingStyleInformation = + await AndroidFlutterLocalNotificationsPlugin() + .getActiveNotificationMessagingStyle(room.id.hashCode); + if (messagingStyleInformation == null) return; + l10n ??= await lookupL10n(PlatformDispatcher.instance.locale); + messagingStyleInformation.messages?.add( + Message( + input, + DateTime.now(), + Person( + key: room.client.userID, + name: l10n.you, + icon: avatarFile == null + ? null + : ByteArrayAndroidIcon(avatarFile), + ), + ), + ); + + await FlutterLocalNotificationsPlugin().show( + room.id.hashCode, + room.getLocalizedDisplayname(MatrixLocals(l10n)), + input, + NotificationDetails( + android: AndroidNotificationDetails( + AppConfig.pushNotificationsChannelId, + l10n.incomingMessages, + category: AndroidNotificationCategory.message, + shortcutId: room.id, + styleInformation: messagingStyleInformation, + groupKey: room.id, + playSound: false, + enableVibration: false, + actions: [ + 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, + ), + ], + ), + ), + payload: NotificationPushPayload( + client.clientName, + room.id, + eventId, + ).toString(), + ); + } + } + } +} + +enum FluffyChatNotificationActions { markAsRead, reply } diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index f0c7bc2..de86db4 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -1,10 +1,10 @@ +import 'dart:convert'; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; -import 'package:extera_next/generated/l10n/l10n.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_shortcuts_new/flutter_shortcuts_new.dart'; import 'package:matrix/matrix.dart'; @@ -14,7 +14,11 @@ import 'package:extera_next/config/app_config.dart'; import 'package:extera_next/utils/client_download_content_extension.dart'; import 'package:extera_next/utils/client_manager.dart'; import 'package:extera_next/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:extera_next/utils/notification_background_handler.dart'; import 'package:extera_next/utils/platform_infos.dart'; +import 'package:extera_next/generated/l10n/l10n.dart'; + +const notificationAvatarDimension = 128; Future pushHelper( PushNotification notification, { @@ -32,9 +36,9 @@ Future pushHelper( flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, ); } catch (e, s) { - Logs().v('Push Helper has crashed!', e, s); + Logs().e('Push Helper has crashed! Writing into temporary file', e, s); - l10n ??= await lookupL10n(const Locale('en')); + l10n ??= await lookupL10n(PlatformDispatcher.instance.locale); flutterLocalNotificationsPlugin.show( notification.roomId?.hashCode ?? 0, l10n.newMessageInFluffyChat, @@ -86,7 +90,7 @@ Future _tryPushHelper( .first; final event = await client.getEventByPushNotification( notification, - storeInDatabase: isBackgroundMessage, + storeInDatabase: false, ); if (event == null) { @@ -163,11 +167,12 @@ Future _tryPushHelper( : await client .downloadMxcCached( avatar, - thumbnailMethod: ThumbnailMethod.scale, - width: 256, - height: 256, + thumbnailMethod: ThumbnailMethod.crop, + width: notificationAvatarDimension, + height: notificationAvatarDimension, animated: false, isThumbnail: true, + rounded: true, ) .timeout(const Duration(seconds: 3)); } catch (e, s) { @@ -181,11 +186,12 @@ Future _tryPushHelper( : await client .downloadMxcCached( senderAvatar, - thumbnailMethod: ThumbnailMethod.scale, - width: 256, - height: 256, + thumbnailMethod: ThumbnailMethod.crop, + width: notificationAvatarDimension, + height: notificationAvatarDimension, animated: false, isThumbnail: true, + rounded: true, ) .timeout(const Duration(seconds: 3)); } catch (e, s) { @@ -247,13 +253,6 @@ Future _tryPushHelper( number: notification.counts?.unread, category: AndroidNotificationCategory.message, shortcutId: event.room.id, - actions: [ - AndroidNotificationAction("read", l10n.markAsRead), - AndroidNotificationAction("reply", - l10n.reply, inputs: [ - AndroidNotificationActionInput(label: l10n.writeAMessage) - ]) - ], styleInformation: messagingStyleInformation ?? MessagingStyleInformation( Person( @@ -279,6 +278,25 @@ Future _tryPushHelper( importance: Importance.high, priority: Priority.max, groupKey: event.room.spaceParents.firstOrNull?.roomId ?? 'rooms', + actions: [ + 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( @@ -297,11 +315,30 @@ Future _tryPushHelper( title, body, platformChannelSpecifics, - payload: '${event.roomId} ${event.eventId}', + payload: + NotificationPushPayload(client.clientName, event.room.id, event.eventId) + .toString(), ); Logs().v('Push helper has been completed!'); } +class NotificationPushPayload { + final String? clientName, roomId, eventId; + + NotificationPushPayload(this.clientName, this.roomId, this.eventId); + + factory NotificationPushPayload.fromString(String payload) { + final parts = payload.split('|'); + if (parts.length != 3) { + return NotificationPushPayload(null, null, null); + } + return NotificationPushPayload(parts[0], parts[1], parts[2]); + } + + @override + String toString() => '$clientName|$roomId|$eventId'; +} + /// 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. @@ -319,13 +356,11 @@ Future _setShortcut( action: AppConfig.inviteLinkPrefix + event.room.id, shortLabel: title, conversationShortcut: true, - icon: avatarFile == null - ? null - : ShortcutMemoryIcon(jpegImage: avatarFile).toString(), + icon: avatarFile == null ? null : base64Encode(avatarFile), shortcutIconAsset: avatarFile == null ? ShortcutIconAsset.androidAsset : ShortcutIconAsset.memoryAsset, isImportant: event.room.isFavourite, ), ); -} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 873cac5..feacc4c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -575,26 +575,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "19.4.2" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "6.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_localizations: dependency: "direct main" description: flutter @@ -2000,10 +2008,10 @@ packages: dependency: transitive description: name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.10.1" tint: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cd0cccd..619a8e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: flutter_foreground_task: ^6.1.3 flutter_highlighter: ^0.1.1 flutter_linkify: ^6.0.0 - flutter_local_notifications: ^17.2.3 + flutter_local_notifications: ^19.4.2 flutter_localizations: sdk: flutter flutter_map: ^6.1.0 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b701d30..7e3e64d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -21,6 +21,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows flutter_vodozemac )