531 lines
17 KiB
Dart
531 lines
17 KiB
Dart
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get_it/get_it.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:moxlib/moxlib.dart';
|
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
|
import 'package:moxxyv2/service/conversation.dart';
|
|
import 'package:moxxyv2/service/database/constants.dart';
|
|
import 'package:moxxyv2/service/database/database.dart';
|
|
import 'package:moxxyv2/service/lifecycle.dart';
|
|
import 'package:moxxyv2/service/message.dart';
|
|
import 'package:moxxyv2/service/pigeon/api.g.dart' as api;
|
|
import 'package:moxxyv2/service/service.dart';
|
|
import 'package:moxxyv2/service/xmpp.dart';
|
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
|
import 'package:moxxyv2/shared/constants.dart';
|
|
import 'package:moxxyv2/shared/error_types.dart';
|
|
import 'package:moxxyv2/shared/events.dart';
|
|
import 'package:moxxyv2/shared/helpers.dart';
|
|
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
|
import 'package:moxxyv2/shared/models/message.dart' as modelm;
|
|
import 'package:moxxyv2/shared/models/notification.dart' as modeln;
|
|
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
|
|
|
const _maxNotificationId = 2147483647;
|
|
|
|
/// Message payload keys.
|
|
const _conversationJidKey = 'conversationJid';
|
|
const _messageIdKey = 'message_id';
|
|
const _conversationTitleKey = 'title';
|
|
const _conversationAvatarKey = 'avatarPath';
|
|
|
|
class NotificationsService {
|
|
NotificationsService() {
|
|
_eventStream = _channel
|
|
.receiveBroadcastStream()
|
|
.cast<Object>()
|
|
.map(api.NotificationEvent.decode);
|
|
}
|
|
|
|
/// Logging.
|
|
final Logger _log = Logger('NotificationsService');
|
|
|
|
/// The Pigeon channel to the native side
|
|
final api.MoxxyApi _api = api.MoxxyApi();
|
|
final EventChannel _channel =
|
|
const EventChannel('org.moxxy.moxxyv2/notification_stream');
|
|
late final Stream<api.NotificationEvent> _eventStream;
|
|
|
|
/// Called when something happens to the notification, i.e. the actions are triggered or
|
|
/// the notification has been tapped.
|
|
Future<void> onNotificationEvent(api.NotificationEvent event) async {
|
|
final conversationJid = event.extra![_conversationJidKey]!;
|
|
if (event.type == api.NotificationEventType.open) {
|
|
// The notification has been tapped
|
|
sendEvent(
|
|
MessageNotificationTappedEvent(
|
|
conversationJid: conversationJid,
|
|
title: event.extra![_conversationTitleKey]!,
|
|
avatarPath: event.extra![_conversationAvatarKey]!,
|
|
),
|
|
);
|
|
} else if (event.type == api.NotificationEventType.markAsRead) {
|
|
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
|
// Mark the message as read
|
|
await GetIt.I.get<MessageService>().markMessageAsRead(
|
|
event.extra![_messageIdKey]!,
|
|
accountJid!,
|
|
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
|
|
// the marker, i.e. if the privacy settings allow it.
|
|
true,
|
|
);
|
|
|
|
// Update the conversation
|
|
final cs = GetIt.I.get<ConversationService>();
|
|
await cs.createOrUpdateConversation(
|
|
conversationJid,
|
|
accountJid,
|
|
update: (conversation) async {
|
|
final newConversation = await cs.updateConversation(
|
|
conversationJid,
|
|
accountJid,
|
|
unreadCounter: 0,
|
|
);
|
|
|
|
// Notify the UI
|
|
sendEvent(
|
|
ConversationUpdatedEvent(
|
|
conversation: newConversation,
|
|
),
|
|
);
|
|
|
|
return newConversation;
|
|
},
|
|
);
|
|
|
|
// Clear notifications
|
|
await dismissNotificationsByJid(conversationJid, accountJid);
|
|
} else if (event.type == api.NotificationEventType.reply) {
|
|
// Save this as a notification so that we can display it later
|
|
assert(
|
|
event.payload != null,
|
|
'Reply payload must be not null',
|
|
);
|
|
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
|
final notification = modeln.Notification(
|
|
event.id,
|
|
conversationJid,
|
|
accountJid!,
|
|
null,
|
|
null,
|
|
null,
|
|
event.payload!,
|
|
null,
|
|
null,
|
|
DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
await GetIt.I.get<DatabaseService>().database.insert(
|
|
notificationsTable,
|
|
notification.toJson(),
|
|
);
|
|
|
|
// Send the actual reply
|
|
await GetIt.I.get<XmppService>().sendMessage(
|
|
accountJid: accountJid,
|
|
body: event.payload!,
|
|
recipients: [conversationJid],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Configures the translatable strings on the native side
|
|
/// using locale is currently configured.
|
|
Future<void> configureNotificationI18n() async {
|
|
await _api.setNotificationI18n(
|
|
api.NotificationI18nData(
|
|
reply: t.notifications.message.reply,
|
|
markAsRead: t.notifications.message.markAsRead,
|
|
you: t.messages.you,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> initialize() async {
|
|
// Set up notification groups
|
|
await _api.createNotificationGroups(
|
|
[
|
|
api.NotificationGroup(
|
|
id: messageNotificationGroupId,
|
|
description: 'Chat messages',
|
|
),
|
|
api.NotificationGroup(
|
|
id: warningNotificationChannelId,
|
|
description: 'Warnings',
|
|
),
|
|
api.NotificationGroup(
|
|
id: foregroundServiceNotificationGroupId,
|
|
description: 'Foreground service',
|
|
),
|
|
],
|
|
);
|
|
|
|
// Set up the notitifcation channels.
|
|
await _api.createNotificationChannels([
|
|
api.NotificationChannel(
|
|
title: t.notifications.channels.messagesChannelName,
|
|
description: t.notifications.channels.messagesChannelDescription,
|
|
id: messageNotificationChannelId,
|
|
importance: api.NotificationChannelImportance.HIGH,
|
|
showBadge: true,
|
|
vibration: true,
|
|
enableLights: true,
|
|
),
|
|
api.NotificationChannel(
|
|
title: t.notifications.channels.warningChannelName,
|
|
description: t.notifications.channels.warningChannelDescription,
|
|
id: warningNotificationChannelId,
|
|
importance: api.NotificationChannelImportance.DEFAULT,
|
|
showBadge: false,
|
|
vibration: true,
|
|
enableLights: false,
|
|
),
|
|
// The foreground notification channel is only required on Android
|
|
if (Platform.isAndroid)
|
|
api.NotificationChannel(
|
|
title: t.notifications.channels.serviceChannelName,
|
|
description: t.notifications.channels.serviceChannelDescription,
|
|
id: foregroundServiceNotificationChannelId,
|
|
importance: api.NotificationChannelImportance.MIN,
|
|
showBadge: false,
|
|
vibration: false,
|
|
enableLights: false,
|
|
),
|
|
]);
|
|
|
|
// Configure i18n
|
|
await configureNotificationI18n();
|
|
|
|
// Listen to notification events
|
|
_eventStream.listen(onNotificationEvent);
|
|
}
|
|
|
|
/// Returns true if a notification should be shown. false otherwise.
|
|
bool shouldShowNotification(String jid) {
|
|
return GetIt.I.get<ConversationService>().activeConversationJid != jid ||
|
|
!GetIt.I.get<LifecycleService>().isActive;
|
|
}
|
|
|
|
/// Queries the notifications for the conversation [jid] from the database.
|
|
Future<List<modeln.Notification>> _getNotificationsForJid(
|
|
String jid,
|
|
String accountJid,
|
|
) async {
|
|
final rawNotifications =
|
|
await GetIt.I.get<DatabaseService>().database.query(
|
|
notificationsTable,
|
|
where: 'conversationJid = ? AND accountJid = ?',
|
|
whereArgs: [jid, accountJid],
|
|
);
|
|
return rawNotifications.map(modeln.Notification.fromJson).toList();
|
|
}
|
|
|
|
Future<int?> _clearNotificationsForJid(String jid, String accountJid) async {
|
|
final db = GetIt.I.get<DatabaseService>().database;
|
|
|
|
final result = await db.query(
|
|
notificationsTable,
|
|
where: 'conversationJid = ? AND accountJid = ?',
|
|
whereArgs: [jid, accountJid],
|
|
limit: 1,
|
|
);
|
|
|
|
// Assumption that all rows with the same conversationJid have the same id.
|
|
final id = result.isNotEmpty ? result.first['id']! as int : null;
|
|
await db.delete(
|
|
notificationsTable,
|
|
where: 'conversationJid = ? AND accountJid = ?',
|
|
whereArgs: [jid, accountJid],
|
|
);
|
|
|
|
return id;
|
|
}
|
|
|
|
Future<modeln.Notification> _createNotification(
|
|
modelc.Conversation c,
|
|
modelm.Message m,
|
|
String accountJid,
|
|
String? avatarPath,
|
|
int id, {
|
|
bool shouldOverride = false,
|
|
}) async {
|
|
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
|
String body;
|
|
if (m.stickerPackId != null) {
|
|
body = t.messages.sticker;
|
|
} else if (m.isMedia) {
|
|
body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
|
|
} else {
|
|
body = m.body;
|
|
}
|
|
|
|
assert(
|
|
implies(m.fileMetadata?.path != null, m.fileMetadata?.mimeType != null),
|
|
'File metadata has path but no mime type',
|
|
);
|
|
|
|
// Use the resource (nick) when the chat is a groupchat
|
|
final senderJid = m.senderJid;
|
|
final senderTitle = c.isGroupchat
|
|
? senderJid.resource
|
|
: await c.titleWithOptionalContactService;
|
|
|
|
// If the file is a video, use its thumbnail, if available
|
|
var filePath = m.fileMetadata?.path;
|
|
var fileMime = m.fileMetadata?.mimeType;
|
|
|
|
// Thumbnail workaround for Android
|
|
if (Platform.isAndroid &&
|
|
(m.fileMetadata?.mimeType?.startsWith('video/') ?? false) &&
|
|
m.fileMetadata?.path != null) {
|
|
final thumbnailPath = await getVideoThumbnailPath(m.fileMetadata!.path!);
|
|
if (File(thumbnailPath).existsSync()) {
|
|
// Workaround for Android to show the thumbnail in the notification
|
|
filePath = thumbnailPath;
|
|
fileMime = 'image/jpeg';
|
|
}
|
|
}
|
|
|
|
// Add to the database
|
|
final newNotification = modeln.Notification(
|
|
id,
|
|
c.jid,
|
|
accountJid,
|
|
senderTitle,
|
|
senderJid.toString(),
|
|
(avatarPath?.isEmpty ?? false) ? null : avatarPath,
|
|
body,
|
|
fileMime,
|
|
filePath,
|
|
m.timestamp,
|
|
);
|
|
await GetIt.I.get<DatabaseService>().database.insert(
|
|
notificationsTable,
|
|
newNotification.toJson(),
|
|
conflictAlgorithm: shouldOverride ? ConflictAlgorithm.replace : null,
|
|
);
|
|
return newNotification;
|
|
}
|
|
|
|
/// Indicates whether we're allowed to show notifications on devices >= Android 13.
|
|
Future<bool> _canDoNotifications() async {
|
|
return Permission.notification.isGranted;
|
|
}
|
|
|
|
/// When a notification is already visible, then build a new notification based on [c] and [m],
|
|
/// update the database state and tell the OS to show the notification again.
|
|
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
|
|
Future<void> updateOrShowNotification(
|
|
modelc.Conversation c,
|
|
modelm.Message m,
|
|
String accountJid,
|
|
) async {
|
|
if (!(await _canDoNotifications())) {
|
|
_log.warning(
|
|
'updateNotification: Notifications permission not granted. Doing nothing.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final notifications = await _getNotificationsForJid(c.jid, accountJid);
|
|
final id = notifications.isNotEmpty
|
|
? notifications.first.id
|
|
: Random().nextInt(_maxNotificationId);
|
|
// TODO(Unknown): Handle groupchat member avatars
|
|
final notification = await _createNotification(
|
|
c,
|
|
m,
|
|
accountJid,
|
|
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
|
|
id,
|
|
shouldOverride: true,
|
|
);
|
|
|
|
await _api.showMessagingNotification(
|
|
api.MessagingNotification(
|
|
title: await c.titleWithOptionalContactService,
|
|
id: id,
|
|
channelId: messageNotificationChannelId,
|
|
jid: c.jid,
|
|
messages: notifications.map((n) {
|
|
// Based on the table's composite primary key
|
|
if (n.id == notification.id &&
|
|
n.conversationJid == notification.conversationJid &&
|
|
n.senderJid == notification.senderJid &&
|
|
n.timestamp == notification.timestamp) {
|
|
return notification.toNotificationMessage();
|
|
}
|
|
|
|
return n.toNotificationMessage();
|
|
}).toList(),
|
|
isGroupchat: c.isGroupchat,
|
|
groupId: messageNotificationGroupId,
|
|
extra: {
|
|
_conversationJidKey: c.jid,
|
|
_messageIdKey: m.id,
|
|
_conversationTitleKey: await c.titleWithOptionalContactService,
|
|
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show a notification for a message [m] grouped by its conversationJid
|
|
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
|
/// then Android's BigPicture will be used.
|
|
Future<void> showNotification(
|
|
modelc.Conversation c,
|
|
modelm.Message m,
|
|
String accountJid,
|
|
String title, {
|
|
String? body,
|
|
}) async {
|
|
if (!(await _canDoNotifications())) {
|
|
_log.warning(
|
|
'showNotification: Notifications permission not granted. Doing nothing.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final notifications = await _getNotificationsForJid(c.jid, accountJid);
|
|
final id = notifications.isNotEmpty
|
|
? notifications.first.id
|
|
: Random().nextInt(_maxNotificationId);
|
|
await _api.showMessagingNotification(
|
|
api.MessagingNotification(
|
|
title: title,
|
|
id: id,
|
|
channelId: messageNotificationChannelId,
|
|
jid: c.jid,
|
|
messages: [
|
|
...notifications.map((n) => n.toNotificationMessage()),
|
|
// TODO(Unknown): Handle groupchat member avatars
|
|
(await _createNotification(
|
|
c,
|
|
m,
|
|
accountJid,
|
|
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
|
|
id,
|
|
))
|
|
.toNotificationMessage(),
|
|
],
|
|
isGroupchat: c.isGroupchat,
|
|
groupId: messageNotificationGroupId,
|
|
extra: {
|
|
_conversationJidKey: c.jid,
|
|
_messageIdKey: m.id,
|
|
_conversationTitleKey: await c.titleWithOptionalContactService,
|
|
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show a notification with the highest priority that uses [title] as the title
|
|
/// and [body] as the body.
|
|
Future<void> showWarningNotification(String title, String body) async {
|
|
if (!(await _canDoNotifications())) {
|
|
_log.warning(
|
|
'showWarningNotification: Notifications permission not granted. Doing nothing.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await _api.showNotification(
|
|
api.RegularNotification(
|
|
title: title,
|
|
body: body,
|
|
channelId: warningNotificationChannelId,
|
|
id: Random().nextInt(_maxNotificationId),
|
|
icon: api.NotificationIcon.warning,
|
|
groupId: warningNotificationGroupId,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show a notification for a bounced message with erorr [type] for a
|
|
/// message in the chat with [jid].
|
|
Future<void> showMessageErrorNotification(
|
|
String jid,
|
|
String accountJid,
|
|
MessageErrorType type,
|
|
) async {
|
|
if (!(await _canDoNotifications())) {
|
|
_log.warning(
|
|
'showMessageErrorNotification: Notifications permission not granted. Doing nothing.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Only show the notification for certain errors
|
|
if (![
|
|
MessageErrorType.remoteServerTimeout,
|
|
MessageErrorType.remoteServerNotFound,
|
|
MessageErrorType.serviceUnavailable
|
|
].contains(type)) {
|
|
return;
|
|
}
|
|
|
|
final conversation = await GetIt.I
|
|
.get<ConversationService>()
|
|
.getConversationByJid(jid, accountJid);
|
|
await _api.showNotification(
|
|
api.RegularNotification(
|
|
title: t.notifications.errors.messageError.title,
|
|
body: t.notifications.errors.messageError
|
|
.body(conversationTitle: conversation!.title),
|
|
channelId: warningNotificationChannelId,
|
|
id: Random().nextInt(_maxNotificationId),
|
|
icon: api.NotificationIcon.error,
|
|
groupId: warningNotificationGroupId,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Since all notifications are grouped by the conversation's JID, this function
|
|
/// clears all notifications for [jid].
|
|
Future<void> dismissNotificationsByJid(String jid, String accountJid) async {
|
|
final id = await _clearNotificationsForJid(jid, accountJid);
|
|
if (id != null) {
|
|
await _api.dismissNotification(id);
|
|
}
|
|
}
|
|
|
|
/// Dismisses all notifications for the context of [accountJid].
|
|
Future<void> dismissAllNotifications(String accountJid) async {
|
|
final db = GetIt.I.get<DatabaseService>().database;
|
|
final ids = await db.query(
|
|
notificationsTable,
|
|
where: 'accountJid = ?',
|
|
whereArgs: [accountJid],
|
|
columns: ['id'],
|
|
distinct: true,
|
|
);
|
|
|
|
// Dismiss the notification
|
|
for (final idRaw in ids) {
|
|
await _api.dismissNotification(idRaw['id']! as int);
|
|
}
|
|
|
|
// Remove database entries
|
|
await db.delete(
|
|
notificationsTable,
|
|
where: 'accountJid = ?',
|
|
whereArgs: [accountJid],
|
|
);
|
|
}
|
|
|
|
/// Requests the avatar path from [XmppStateService] and configures the notification plugin
|
|
/// accordingly, if the avatar path is not null. If it is null, this method does nothing.
|
|
Future<void> maybeSetAvatarFromState() async {
|
|
final xss = GetIt.I.get<XmppStateService>();
|
|
final avatarPath = (await xss.state).avatarUrl;
|
|
if (avatarPath.isNotEmpty) {
|
|
await _api.setNotificationSelfAvatar(avatarPath);
|
|
}
|
|
}
|
|
}
|