548 lines
18 KiB
Dart
548 lines
18 KiB
Dart
// 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;
|
|
}
|
|
}
|
|
}
|