// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/utils/call_kit_params.dart'; import 'package:tatchat/utils/client_manager.dart'; import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/notification_avatar_extension.dart'; import 'package:tatchat/utils/notification_background_handler.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/start_push_foreground_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_new_badger/flutter_new_badger.dart'; import 'package:flutter_shortcuts_new/flutter_shortcuts_new.dart'; import 'package:http/http.dart' as http; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart' hide Result; import 'package:shared_preferences/shared_preferences.dart'; final Map lastReceivedPushNotification = {}; Future pushHelper( PushNotification notification, { List? clients, L10n? l10n, String? activeRoomId, required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin, }) async { l10n ??= await lookupL10n(PlatformDispatcher.instance.locale); try { await _tryPushHelper( notification, clients: clients, l10n: l10n, activeRoomId: activeRoomId, flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, ).timeout(const Duration(seconds: 30)); } catch (e, s) { if (PlatformInfos.isAndroid && e is! TimeoutException && e is! IOException && e is! http.ClientException) { Logs().e('Push Helper has crashed! Writing into temporary file...', e, s); final store = await SharedPreferences.getInstance(); await store.setStringList(AppConfig.pushHelperCrashReportKey, [ e.toString(), s.toString(), ]); } if (notification.roomId != null) { await flutterLocalNotificationsPlugin.show( id: notification.notificationId, title: l10n.newMessageInFluffyChat, body: l10n.openAppToReadMessages, notificationDetails: NotificationDetails( iOS: DarwinNotificationDetails( threadIdentifier: '${notification.clientName}_${notification.roomId}', ), 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, category: AndroidNotificationCategory.message, groupKey: notification.clientName, ), ), ); } rethrow; } finally { ForegroundServices.stopService('background_push'); } } Future _tryPushHelper( PushNotification notification, { List? clients, L10n? l10n, String? activeRoomId, required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin, }) async { final isBackgroundMessage = clients == null; Logs().v( 'Push helper has been started (background=$isBackgroundMessage).', notification.toJson(), ); final clientName = notification.clientName; final store = await AppSettings.init(); final client = clientName == null ? (clients?.first ?? (await ClientManager.getClients( initialize: false, store: store, )).first) : (clients?.firstWhereOrNull( (client) => client.clientName == clientName, ) ?? await ClientManager.createClient(clientName, store)); lastReceivedPushNotification[client.clientName] = DateTime.now(); l10n ??= await L10n.delegate.load(PlatformDispatcher.instance.locale); Logs().v('Load event...'); final event = await client.getEventByPushNotification( notification, storeInDatabase: false, ); updateAppBadge(notification.counts?.unread ?? 0); if (event == null) { Logs().v('Notification is a clearing indicator.'); if (clients?.length == 1 && (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() .timeout(const Duration(seconds: 8)) .catchError((_) => null); final activeNotifications = await flutterLocalNotificationsPlugin .getActiveNotifications(); activeNotifications.removeWhere( (notification) => notification.groupKey != client.clientName, ); var needsUpdateForSummaryNotification = false; for (final activeNotification in activeNotifications) { final room = client.rooms.singleWhereOrNull( (room) => '${client.clientName}_${room.id}'.hashCode == activeNotification.id, ); if (room != null && !room.isUnreadOrInvited) { flutterLocalNotificationsPlugin.cancel(id: activeNotification.id!); if (PlatformInfos.isAndroid) needsUpdateForSummaryNotification = true; } } if (needsUpdateForSummaryNotification) { await updateSummaryNotification( clientName: client.clientName, l10n: l10n, flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, ); } } return; } Logs().v('Push helper got notification event of type ${event.type}.'); if (!client.pushruleEvaluator.match(event).notify) { Logs().i('Push helper: filtered by client-side push rules.'); return; } if (event.type == RtcNotificationContent.eventType && event.tryParseRtcNotificationContent()?.notificationType == .ring && PlatformInfos.isMobile) { _showIncomingCall(event, l10n); return; } if (notification.roomId != null && activeRoomId == notification.roomId && WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { Logs().v('Room is in foreground. Stop push helper here.'); return; } final matrixLocals = MatrixLocals(l10n); // Calculate the body final body = event.type == EventTypes.Encrypted ? l10n.newMessageInFluffyChat : await event.calcLocalizedBody( matrixLocals, plaintextBody: true, withSenderNamePrefix: false, hideReply: true, hideEdit: true, removeMarkdown: true, ); // The person object for the android message style notification final avatar = event.room.avatar; final senderAvatar = event.room.isDirectChat ? avatar : event.senderFromMemoryOrFallback.avatarUrl; final ownUser = event.room.unsafeGetUserFromMemoryOrFallback(client.userID!); final userAvatarFile = await client.tryDownloadNotificationAvatar( ownUser.avatarUrl, ); final roomAvatarFile = await client.tryDownloadNotificationAvatar(avatar); final senderAvatarFile = await client.tryDownloadNotificationAvatar( senderAvatar, ); final id = '${client.clientName}_${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: id) : null; messagingStyleInformation?.messages?.add(newMessage); final roomName = event.room.getLocalizedDisplayname(MatrixLocals(l10n)); final notificationGroupId = event.room.isDirectChat ? 'directChats' : 'groupChats'; final groupName = event.room.isDirectChat ? l10n.directChats : l10n.groups; 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: ownUser.calcDisplayname(), icon: userAvatarFile == null ? null : ByteArrayAndroidIcon(userAvatarFile), key: event.room.client.userID, ), 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: client.clientName, actions: switch (event.type) { EventTypes.Message || EventTypes.Encrypted || EventTypes.Sticker => [ AndroidNotificationAction( FluffyChatNotificationActions.reply.name, l10n.reply, inputs: [AndroidNotificationActionInput(label: l10n.writeAMessage)], allowGeneratedReplies: true, semanticAction: SemanticAction.reply, ), AndroidNotificationAction( FluffyChatNotificationActions.markAsRead.name, l10n.markAsRead, semanticAction: SemanticAction.markAsRead, ), AndroidNotificationAction( FluffyChatNotificationActions.mute.name, l10n.mute, semanticAction: SemanticAction.mute, ), ], RtcNotificationContent.eventType => [ AndroidNotificationAction( FluffyChatNotificationActions.enterCall.name, l10n.enterCall, semanticAction: SemanticAction.call, ), ], _ => null, }, ); final iOSAttachmentPath = PlatformInfos.isIOS ? await client.getIosNotificationAvatar(event.room.avatar) : null; final iOSPlatformChannelSpecifics = DarwinNotificationDetails( threadIdentifier: '${notification.clientName}_${notification.roomId}', attachments: iOSAttachmentPath == null ? null : [ DarwinNotificationAttachment( iOSAttachmentPath, identifier: 'image', ), ], ); 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); } final needsTitleAndBody = !PlatformInfos.isAndroid; await flutterLocalNotificationsPlugin.show( id: notification.notificationId, title: needsTitleAndBody ? title : null, body: needsTitleAndBody ? body : null, notificationDetails: platformChannelSpecifics, payload: FluffyChatPushPayload( client.clientName, event.room.id, event.eventId, ).toString(), ); // Send summary notification on Android if (PlatformInfos.isAndroid) { await updateSummaryNotification( clientName: client.clientName, l10n: l10n, flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, ); } Logs().v('Push helper has been completed!'); } void updateAppBadge(int unreadCount) { if (PlatformInfos.isAndroid || PlatformInfos.isMacOS || PlatformInfos.isIOS) { if (unreadCount == 0) { FlutterNewBadger.removeBadge(); } else { FlutterNewBadger.setBadge(unreadCount); } return; } } Future updateSummaryNotification({ required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin, required String clientName, required L10n l10n, }) async { final activeNotifications = (await flutterLocalNotificationsPlugin.getActiveNotifications()) .where((n) => n.groupKey == clientName) .toList(); if (activeNotifications.length <= 1) { await flutterLocalNotificationsPlugin.cancel(id: clientName.hashCode); return; } if (activeNotifications.any( (notification) => notification.id == clientName.hashCode, )) { // Already have a visible summary notification! return; } await flutterLocalNotificationsPlugin.show( id: clientName.hashCode, notificationDetails: NotificationDetails( android: AndroidNotificationDetails( AppConfig.pushNotificationsChannelId, l10n.incomingMessages, groupKey: clientName, setAsGroupSummary: true, styleInformation: InboxStyleInformation( activeNotifications.map((n) => n.body ?? '').toList(), ), autoCancel: false, ), ), ); } class FluffyChatPushPayload { final String? clientName, roomId, eventId; FluffyChatPushPayload(this.clientName, this.roomId, this.eventId); factory FluffyChatPushPayload.fromString(String payload) { final parts = payload.split('|'); if (parts.length != 3) { return FluffyChatPushPayload(null, null, null); } return FluffyChatPushPayload(parts.first, 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. Future _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, ), ); } extension on PushNotification { String? get clientName => devices?.firstOrNull?.data?.tryGet('client_name'); int get notificationId { final roomId = this.roomId; if (roomId == null) return 0; final clientName = this.clientName; if (clientName == null) return roomId.hashCode; return '${clientName}_$roomId'.hashCode; } } Future _showIncomingCall(Event event, L10n l10n) async { final callId = '${event.room.id}|${event.room.client.clientName}'; final activeCalls = await FlutterCallkitIncoming.activeCalls(); if (activeCalls.any((call) => call.id == callId)) { Logs().d( 'Call with this ID is already active. Ignoring this Push Notification...', callId, ); return; } final intentStr = event.content.tryGet('m.call.intent'); final intent = MatrixRtcCallIntent.values.singleWhereOrNull( (i) => i.name == intentStr, ) ?? .video; final timeout = event.tryParseRtcNotificationContent()?.lifetime ?? RtcNotificationContent.defaultLifetime; final params = await buildFluffyChatCallKitParams( event.room, l10n: l10n, intent: intent, timeout: timeout, ); final timeoutDateTime = DateTime.now().add(timeout); await FlutterCallkitIncoming.showCallkitIncoming(params); while (DateTime.now().isBefore(timeoutDateTime)) { await event.room.client.oneShotSync(); if (!event.room.hasActiveMatrixRtcCall) { Logs().i('The other party has ended the call'); await FlutterCallkitIncoming.endCall(params.id); break; } if (event.room.ownMatrixRtcMembership != null) { Logs().d('User has joined the call.'); break; } if (event.room.getActiveMatrixRtcMembers().any( (member) => member.senderId == event.room.client.userID! && member.deviceId != event.room.client.deviceID!, )) { Logs().i('User has accepted the call on a different device'); await FlutterCallkitIncoming.endCall(params.id); break; } } }