moxxy/lib/service/notifications.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);
}
}
}