Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

547
lib/utils/push_helper.dart Normal file
View File

@@ -0,0 +1,547 @@
// 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<String, DateTime> lastReceivedPushNotification = {};
Future<void> pushHelper(
PushNotification notification, {
List<Client>? 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<void> _tryPushHelper(
PushNotification notification, {
List<Client>? 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>[
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<void> 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<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,
),
);
}
extension on PushNotification {
String? get clientName =>
devices?.firstOrNull?.data?.tryGet<String>('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<void> _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<String>('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;
}
}
}