Files
moxxy/lib/service/xmpp.dart

1824 lines
59 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/groupchat.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/share.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart';
class XmppService {
XmppService() {
_eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
EventTypeMatcher<StreamNegotiationsDoneEvent>(_onStreamNegotiationsDone),
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
EventTypeMatcher<SubscriptionRequestReceivedEvent>(
_onSubscriptionRequestReceived,
),
EventTypeMatcher<DeliveryReceiptReceivedEvent>(
_onDeliveryReceiptReceived,
),
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
EventTypeMatcher<UserAvatarUpdatedEvent>(_onAvatarUpdated),
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
EventTypeMatcher<MessageEvent>(_onMessage),
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
EventTypeMatcher<BlocklistUnblockAllPushEvent>(
_onBlocklistUnblockAllPush,
),
EventTypeMatcher<StanzaSendingCancelledEvent>(_onStanzaSendingCancelled),
EventTypeMatcher<NonRecoverableErrorEvent>(_onUnrecoverableError),
EventTypeMatcher<NewFASTTokenReceivedEvent>(_onNewFastToken),
]);
}
/// Logger.
final Logger _log = Logger('XmppService');
/// EventHandler for XmppEvents
final EventHandler _eventHandler = EventHandler();
/// Flag indicating whether a login was triggered from the UI or not.
bool _loginTriggeredFromUI = false;
/// Subscription to events by the XmppConnection
StreamSubscription<dynamic>? _xmppConnectionSubscription;
Future<ConnectionSettings?> getConnectionSettings() async {
final xss = GetIt.I.get<XmppStateService>();
final accountJid = await xss.getAccountJid();
if (accountJid == null) return null;
final state = await GetIt.I.get<XmppStateService>().state;
if (state.jid == null || state.password == null) {
return null;
}
return ConnectionSettings(
jid: JID.fromString(state.jid!),
password: state.password!,
);
}
/// Marks the conversation with jid [jid] as open and resets its unread counter if it is
/// greater than 0.
Future<void> setCurrentlyOpenedChatJid(String jid) async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final cs = GetIt.I.get<ConversationService>()..activeConversationJid = jid;
final conversation = await cs.createOrUpdateConversation(
jid,
accountJid!,
update: (c) async {
if (c.unreadCounter > 0) {
return cs.updateConversation(
jid,
accountJid,
unreadCounter: 0,
);
}
return c;
},
);
if (conversation != null) {
sendEvent(
ConversationUpdatedEvent(conversation: conversation),
);
}
}
/// Sends a message correction to [recipient] regarding the message with stanza id
/// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
/// id. [chatState] can be optionally specified to also include a chat state
/// in the message.
///
/// This function handles updating the message and optionally the corresponding
/// conversation.
Future<void> sendMessageCorrection(
String id,
String conversationJid,
String accountJid,
String newBody,
String oldId,
String recipient,
ChatState? chatState,
) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
// Update the database
final msg = await ms.updateMessage(
id,
accountJid,
isEdited: true,
body: newBody,
);
sendEvent(MessageUpdatedEvent(message: msg));
final conversation = await cs.createOrUpdateConversation(
recipient,
accountJid,
update: (c) async {
if (c.lastMessage?.id == id) {
return cs.updateConversation(
c.jid,
accountJid,
lastChangeTimestamp: timestamp,
lastMessage: msg,
);
}
return c;
},
);
if (conversation != null) {
sendEvent(ConversationUpdatedEvent(conversation: conversation));
}
if (conversation?.type != ConversationType.note) {
// Send the correction
final manager = conn.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(newBody),
LastMessageCorrectionData(oldId),
if (chatState != null) chatState,
]),
);
}
await GetIt.I.get<ShareService>().recordSentMessage(
conversation!,
);
}
/// Sends a message to JIDs in [recipients] with the body of [body].
Future<void> sendMessage({
required String accountJid,
required String body,
required List<String> recipients,
String? currentConversationJid,
Message? quotedMessage,
String? commandId,
ChatState? chatState,
sticker.Sticker? sticker,
}) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
for (final recipient in recipients) {
final sid = conn.generateId();
final originId = conn.generateId();
Message? message;
final conversation = await cs.createOrUpdateConversation(
recipient,
accountJid,
update: (c) async {
message = await ms.addMessageFromData(
accountJid,
body,
timestamp,
conn.connectionSettings.jid.toString(),
recipient,
sid,
false,
c.type == ConversationType.note ? true : c.encrypted,
// TODO(Unknown): Maybe make this depend on some setting
false,
originId: originId,
quoteId: quotedMessage?.sid,
stickerPackId: sticker?.stickerPackId,
fileMetadata: sticker?.fileMetadata,
received: c.type == ConversationType.note ? true : false,
displayed: c.type == ConversationType.note ? true : false,
);
final newConversation = await cs.updateConversation(
recipient,
accountJid,
lastMessage: message,
lastChangeTimestamp: timestamp,
);
return newConversation;
},
);
assert(conversation != null, 'The conversation must exist');
assert(message != null, 'The message must be non-null');
// Using the same ID should be fine.
if (recipient == currentConversationJid) {
sendEvent(
MessageAddedEvent(message: message!),
id: commandId,
);
}
if (conversation?.type != ConversationType.note) {
final moxxmppSticker = sticker?.toMoxxmpp();
final manager = conn.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(body),
const MarkableData(true),
MessageIdData(sid),
StableIdData(originId, null),
if (sticker != null && moxxmppSticker != null)
StickersData(
sticker.stickerPackId,
StatelessFileSharingData(
moxxmppSticker.metadata,
moxxmppSticker.sources,
),
),
// Optional chat state
if (chatState != null) chatState,
// Prepare the appropriate quote
if (quotedMessage != null)
ReplyData.fromQuoteData(
quotedMessage.sid,
QuoteData.fromBodies(
createFallbackBodyForQuotedMessage(quotedMessage),
body,
),
),
]),
type: conversation!.type.value,
);
}
sendEvent(
ConversationUpdatedEvent(conversation: conversation!),
);
// Tell the system to show the chat in the direct share list.
await GetIt.I.get<ShareService>().recordSentMessage(
conversation,
);
}
}
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final oob = event.extensions.get<OOBData>();
if (sfs?.sources.isNotEmpty ?? false) {
// final source = firstWhereOrNull(
// event.sfs!.sources,
// (StatelessFileSharingSource source) {
// return source is StatelessFileSharingUrlSource ||
// source is StatelessFileSharingEncryptedSource;
// },
// );
final hasUrlSource = sfs!.sources.firstWhereOrNull(
(src) => src is StatelessFileSharingUrlSource,
) !=
null;
final name = sfs.metadata.name;
if (hasUrlSource) {
final sources = sfs.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
return MediaFileLocation(
sources,
name != null ? escapeFilename(name) : filenameFromUrl(sources.first),
null,
null,
null,
sfs.metadata.hashes,
null,
sfs.metadata.size,
);
} else {
final encryptedSource = sfs.sources.firstWhereOrNull(
(src) => src is StatelessFileSharingEncryptedSource,
)! as StatelessFileSharingEncryptedSource;
return MediaFileLocation(
[encryptedSource.source.url],
name != null
? escapeFilename(name)
: filenameFromUrl(encryptedSource.source.url),
encryptedSource.encryption.toNamespace(),
encryptedSource.key,
encryptedSource.iv,
sfs.metadata.hashes,
encryptedSource.hashes,
sfs.metadata.size,
);
}
} else if (oob != null) {
return MediaFileLocation(
[oob.url!],
filenameFromUrl(oob.url!),
null,
null,
null,
null,
null,
null,
);
}
return null;
}
Future<void> _acknowledgeMessage(MessageEvent event) async {
final result = await GetIt.I
.get<XmppConnection>()
.getDiscoManager()!
.discoInfoQuery(event.from);
if (result.isType<DiscoError>()) return;
final info = result.get<DiscoInfo>();
final isMarkable =
event.extensions.get<MarkableData>()?.isMarkable ?? false;
final deliveryReceiptRequested =
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
false;
final originId = event.extensions.get<StableIdData>()?.originId;
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!;
final hasId = originId != null || event.id != null;
if (isMarkable && info.features.contains(chatMarkersXmlns) && hasId) {
await manager.sendMessage(
event.from.toBare(),
TypedMap<StanzaHandlerExtension>.fromList([
ChatMarkerData(
ChatMarker.received,
originId ?? event.id!,
)
]),
);
} else if (deliveryReceiptRequested &&
info.features.contains(deliveryXmlns) &&
hasId) {
await manager.sendMessage(
event.from.toBare(),
TypedMap<StanzaHandlerExtension>.fromList([
MessageDeliveryReceivedData(originId ?? event.id!),
]),
);
}
}
/// Send a read marker to [to] in order to mark the message with stanza id [sid]
/// as read. If sending chat markers is disabled in the preferences, then this
/// function will do nothing.
Future<void> sendReadMarker(String to, String sid) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
if (!prefs.sendChatMarkers) return;
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(to),
TypedMap<StanzaHandlerExtension>.fromList([
ChatMarkerData(ChatMarker.displayed, sid),
]),
);
}
/// Returns true if we are allowed to automatically download a file
Future<bool> _automaticFileDownloadAllowed() async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final currentConnection = GetIt.I.get<ConnectivityService>().currentState;
return prefs.autoDownloadWifi &&
currentConnection == ConnectivityResult.wifi ||
prefs.autoDownloadMobile &&
currentConnection == ConnectivityResult.mobile;
}
void installEventHandlers() {
_xmppConnectionSubscription?.cancel();
_xmppConnectionSubscription = GetIt.I
.get<XmppConnection>()
.asBroadcastStream()
.listen(_eventHandler.run);
}
Future<void> connect(
ConnectionSettings settings,
bool triggeredFromUI,
) async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final conn = GetIt.I.get<XmppConnection>();
final lastResource = state.resource ?? '';
_loginTriggeredFromUI = triggeredFromUI;
conn
..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource
..getNegotiatorById<Sasl2Negotiator>(sasl2Negotiator)!.userAgent =
await xss.userAgent
..getNegotiatorById<FASTSaslNegotiator>(saslFASTNegotiator)!.fastToken =
state.fastToken;
await conn.connect(
waitForConnection: true,
shouldReconnect: true,
);
installEventHandlers();
}
Future<Result<bool, XmppError>> connectAwaitable(
ConnectionSettings settings,
bool triggeredFromUI,
) async {
final xss = GetIt.I.get<XmppStateService>();
final conn = GetIt.I.get<XmppConnection>();
final state = await xss.state;
final lastResource = state.resource ?? '';
_loginTriggeredFromUI = triggeredFromUI;
conn
..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource
..getNegotiatorById<Sasl2Negotiator>(sasl2Negotiator)!.userAgent =
await xss.userAgent
..getNegotiatorById<FASTSaslNegotiator>(saslFASTNegotiator)!.fastToken =
state.fastToken;
installEventHandlers();
return conn.connect(
waitForConnection: true,
waitUntilLogin: true,
);
}
Future<void> sendFiles(
String accountJid,
List<String> paths,
List<String> recipients,
) async {
// Create a new message
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final css = GetIt.I.get<ContactsService>();
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Path -> Recipient -> Message
final messages = <String, Map<String, Message>>{};
// Path -> Thumbnails
final thumbnails = <String, List<JingleContentThumbnail>>{};
// Path -> Dimensions
final dimensions = <String, Size>{};
// Recipient -> Should encrypt
final encrypt = <String, bool>{};
// Recipient -> Last message Id
final lastMessages = <String, Message>{};
// Path -> Metadata Id
final metadataMap = <String, String>{};
// Recipient -> Conversation
final conversationsMap = <String, Conversation>{};
// Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>();
for (final path in paths) {
final pathMime = lookupMimeType(path);
// TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) {
final imageSize = await getImageSizeFromPath(path);
if (imageSize != null) {
dimensions[path] = Size(
imageSize.width,
imageSize.height,
);
} else {
_log.warning('Failed to get image dimensions for $path');
}
}
final metadata =
await GetIt.I.get<FilesService>().addFileMetadataFromData(
FileMetadata(
DateTime.now().millisecondsSinceEpoch.toString(),
path,
null,
pathMime,
File(path).lengthSync(),
null,
null,
dimensions[path]?.width.toInt(),
dimensions[path]?.height.toInt(),
null,
null,
null,
null,
null,
pathlib.basename(path),
),
);
metadataMap[path] = metadata.id;
for (final recipient in recipients) {
final conversation =
await cs.getConversationByJid(recipient, accountJid);
encrypt[recipient] =
conversation?.encrypted ?? prefs.enableOmemoByDefault;
final msg = await ms.addMessageFromData(
accountJid,
'',
DateTime.now().millisecondsSinceEpoch,
conn.connectionSettings.jid.toString(),
recipient,
conn.generateId(),
false,
conversation?.type == ConversationType.note
? true
: encrypt[recipient]!,
// TODO(Unknown): Maybe make this depend on some setting
false,
fileMetadata: metadata,
originId: conn.generateId(),
isUploading:
conversation?.type != ConversationType.note ? true : false,
received: conversation?.type == ConversationType.note ? true : false,
displayed: conversation?.type == ConversationType.note ? true : false,
);
if (messages.containsKey(path)) {
messages[path]![recipient] = msg;
} else {
messages[path] = {recipient: msg};
}
if (path == paths.last) {
lastMessages[recipient] = msg;
}
sendEvent(MessageAddedEvent(message: msg));
}
}
final rs = GetIt.I.get<RosterService>();
final gs = GetIt.I.get<GroupchatService>();
for (final recipient in recipients) {
conversationsMap[recipient] = (await cs.createOrUpdateConversation(
recipient,
accountJid,
create: () async {
// Create
final rosterItem = await rs.getRosterItemByJid(recipient, accountJid);
final contactId = await css.getContactIdForJid(recipient);
final groupchatDetails = await gs.getGroupchatDetailsByJid(
recipient,
accountJid,
);
final newConversation = await cs.addConversationFromData(
accountJid,
// TODO(Unknown): Should we use the JID parser?
rosterItem?.title ?? recipient.split('@').first,
lastMessages[recipient],
ConversationType.chat,
rosterItem?.avatarPath ?? '',
recipient,
0,
DateTime.now().millisecondsSinceEpoch,
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
contactId,
await css.getProfilePicturePathForJid(recipient),
await css.getContactDisplayName(contactId),
groupchatDetails,
);
// Update the cache
cs.setConversation(newConversation);
// Notify the UI
sendEvent(ConversationAddedEvent(conversation: newConversation));
return newConversation;
},
update: (c) async {
// Update
final newConversation = await cs.updateConversation(
c.jid,
accountJid,
lastMessage: lastMessages[recipient],
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
open: true,
);
// Update the cache
cs.setConversation(newConversation);
// Notify the UI
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
return newConversation;
},
))!;
}
// Requesting Upload slots and uploading
final hfts = GetIt.I.get<HttpFileTransferService>();
final manager = conn.getManagerById<MessageManager>(messageManager)!;
for (final path in paths) {
final pathMime = lookupMimeType(path);
for (final recipient in recipients) {
// TODO(PapaTutuWawa): Do this for videos
// TODO(PapaTutuWawa): Maybe do this in a separate isolate
if ((pathMime ?? '').startsWith('image/')) {
// Generate a thumbnail only when we have to
if (!thumbnails.containsKey(path)) {
final thumbnail = await generateBlurhashThumbnail(path);
if (thumbnail != null) {
thumbnails[path] = [
JingleContentThumbnail(
// Just like Cheogram does it
// https://git.singpolyma.net/cheogram/commit/7adfc96853fec0648145c9d52a4a91b7bac1189f
Uri.parse('data:image/blurhash,$thumbnail'),
null,
null,
null,
),
];
} else {
_log.warning('Failed to generate thumbnail for $path');
}
}
}
if (recipient != '') {
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageIdData(messages[path]![recipient]!.sid),
FileUploadNotificationData(
FileMetadataData(
// TODO(Unknown): Maybe add media type specific metadata
mediaType: lookupMimeType(path),
name: pathlib.basename(path),
size: File(path).statSync().size,
thumbnails: thumbnails[path] ?? [],
),
),
]),
);
}
// Notify the system to update the direct shares.
await GetIt.I.get<ShareService>().recordSentMessage(
conversationsMap[recipient]!,
);
}
recipients.remove('');
if (recipients.isNotEmpty) {
await hfts.uploadFile(
FileUploadJob(
accountJid,
recipients,
path,
pathMime,
encrypt,
messages[path]!,
metadataMap[path]!,
thumbnails[path] ?? [],
),
);
_log.finest('File upload submitted');
}
}
}
Future<void> _initializeOmemoService(String jid) async {
await GetIt.I.get<OmemoService>().initializeIfNeeded(jid);
final result = await GetIt.I.get<OmemoService>().publishDeviceIfNeeded();
if (result != null) {
_log.warning('Failed to publish OMEMO device because of $result');
// Notify the user that we could not publish the Omemo ~identity~ titty
await GetIt.I.get<NotificationsService>().showWarningNotification(
t.notifications.titles.error,
t.errors.omemo.couldNotPublish,
);
}
}
/// Sets the permanent notification's title to the corresponding one for the
/// XmppConnection's state [state].
void setNotificationText(XmppConnectionState state) {
switch (state) {
case XmppConnectionState.connected:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
t.notifications.permanent.ready,
);
break;
case XmppConnectionState.connecting:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
t.notifications.permanent.connecting,
);
break;
case XmppConnectionState.notConnected:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
t.notifications.permanent.disconnect,
);
break;
case XmppConnectionState.error:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
t.notifications.permanent.error,
);
break;
}
}
Future<void> _onStreamNegotiationsDone(
StreamNegotiationsDoneEvent event, {
dynamic extra,
}) async {
final connection = GetIt.I.get<XmppConnection>();
// TODO(Unknown): Maybe have something better
final settings = connection.connectionSettings;
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: settings.jid.toString(),
password: settings.password,
),
);
_log.finest('Connection connected. Is resumed? ${event.resumed}');
unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) {
// Reset the avatar service's cache
GetIt.I.get<AvatarService>().resetCache();
// Reset the blocking service's cache
GetIt.I.get<BlocklistService>().onNewConnection();
// Reset the OMEMO cache
unawaited(
GetIt.I.get<OmemoService>().onNewConnection(),
);
// Enable carbons, if they're not already enabled (e.g. by using SASL2)
final cm = connection.getManagerById<CarbonsManager>(carbonsManager)!;
if (!cm.isEnabled) {
final carbonsResult = await cm.enableCarbons();
if (!carbonsResult) {
_log.warning('Failed to enable carbons');
}
} else {
_log.info('Not enabling carbons as they are already enabled');
}
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
await connection
.getManagerById<RosterManager>(rosterManager)!
.requestRoster();
await GetIt.I.get<BlocklistService>().getBlocklist(
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
if (_loginTriggeredFromUI) {
// TODO(Unknown): Trigger another event so the UI can see this aswell
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: connection.connectionSettings.jid.toString(),
displayName: connection.connectionSettings.jid.local,
avatarUrl: '',
avatarHash: '',
),
);
}
sendEvent(
StreamNegotiationsCompletedEvent(resumed: event.resumed),
);
}
Future<void> _onConnectionStateChanged(
ConnectionStateChangedEvent event, {
dynamic extra,
}) async {
setNotificationText(event.state);
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
event.before,
event.state,
);
}
Future<void> _onResourceBound(
ResourceBoundEvent event, {
dynamic extra,
}) async {
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
resource: event.resource,
),
);
}
Future<void> _onSubscriptionRequestReceived(
SubscriptionRequestReceivedEvent event, {
dynamic extra,
}) async {
final jid = event.from.toBare();
// Auto-accept if the JID is in the roster
final rs = GetIt.I.get<RosterService>();
final rosterItem = await rs.getRosterItemByJid(
jid.toString(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
if (rosterItem != null) {
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
switch (rosterItem.subscription) {
case 'from':
await pm.acceptSubscriptionRequest(jid);
break;
}
return;
}
}
Future<void> _onDeliveryReceiptReceived(
DeliveryReceiptReceivedEvent event, {
dynamic extra,
}) async {
_log.finest('Received delivery receipt from ${event.from}');
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final sender = event.from.toBare().toString();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final dbMsg = await ms.getMessageByStanzaId(
event.id,
accountJid!,
queryReactionPreview: false,
);
if (dbMsg == null) {
_log.warning(
'Did not find the message with id ${event.id} in the database!',
);
return;
}
final msg = await ms.updateMessage(
dbMsg.id,
accountJid,
received: true,
);
sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation
final conv = await cs.getConversationByJid(sender, accountJid);
if (conv != null && conv.lastMessage?.id == msg.id) {
final newConv = conv.copyWith(lastMessage: msg);
cs.setConversation(newConv);
_log.finest('Updating conversation');
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
}
Future<void> _onChatMarker(ChatMarkerEvent event, {dynamic extra}) async {
_log.finest('Chat marker from ${event.from}');
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final sender = event.from.toBare().toString();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
// TODO(Unknown): With groupchats, we should use the groupchat assigned stanza-id
final dbMsg = await ms.getMessageByStanzaId(event.id, accountJid!);
if (dbMsg == null) {
_log.warning('Did not find the message in the database!');
return;
}
final msg = await ms.updateMessage(
dbMsg.id,
accountJid,
received: dbMsg.received ||
event.type == ChatMarker.received ||
event.type == ChatMarker.displayed ||
event.type == ChatMarker.acknowledged,
displayed: dbMsg.displayed ||
event.type == ChatMarker.displayed ||
event.type == ChatMarker.acknowledged,
);
sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation
final conv = await cs.getConversationByJid(sender, accountJid);
if (conv != null && conv.lastMessage?.id == msg.id) {
final newConv = conv.copyWith(lastMessage: msg);
cs.setConversation(newConv);
_log.finest('Updating conversation');
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
}
Future<void> _onChatState(ChatState state, String jid) async {
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(
jid,
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
if (conversation == null) return;
final newConversation = conversation.copyWith(chatState: state);
cs.setConversation(newConversation);
sendEvent(
ConversationUpdatedEvent(
conversation: newConversation,
),
);
}
/// Return true if [event] describes a message that we want to display.
bool _isMessageEventMessage(MessageEvent event) {
final body = event.extensions.get<MessageBodyData>()?.body;
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
return (body?.isNotEmpty ?? false) || sfs != null || fun != null;
}
/// Extract the thumbnail data from a message, if existent.
String? _getThumbnailData(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
final thumbnails = firstNotNull([
sfs?.metadata.thumbnails,
fun?.metadata.thumbnails,
]) ??
[];
for (final i in thumbnails) {
if (i.uri.scheme == 'data' && i.uri.path.startsWith('image/blurhash')) {
return i.uri.path.split(',').last;
}
}
return null;
}
/// Extract the mime guess from a message, if existent.
String? _getMimeGuess(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
return firstNotNull([
sfs?.metadata.mediaType,
fun?.metadata.mediaType,
]);
}
/// Extract the embedded dimensions, if existent.
Size? _getDimensions(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
if (sfs != null &&
sfs.metadata.width != null &&
sfs.metadata.height != null) {
return Size(
sfs.metadata.width!.toDouble(),
sfs.metadata.height!.toDouble(),
);
} else if (fun != null &&
fun.metadata.width != null &&
fun.metadata.height != null) {
return Size(
fun.metadata.width!.toDouble(),
fun.metadata.height!.toDouble(),
);
}
return null;
}
/// Returns true if a file is embedded in [event]. If not, returns false.
/// [embeddedFile] is the possible source of the file. If no file is present, then
/// [embeddedFile] is null.
bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
final body = event.extensions.get<MessageBodyData>()?.body;
final oob = event.extensions.get<OOBData>();
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFile != null &&
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
implies(oob != null, body == oob?.url);
}
/// Handle a message retraction given the MessageEvent [event].
Future<void> _handleMessageRetraction(
MessageEvent event,
String conversationJid,
String accountJid,
) async {
await GetIt.I.get<MessageService>().retractMessage(
conversationJid,
accountJid,
event.extensions.get<MessageRetractionData>()!.id,
event.from.toBare().toString(),
false,
);
}
/// Returns true if a file should be automatically downloaded. If it should not, it
/// returns false.
/// [conversationJid] refers to the JID of the conversation the message was received in.
Future<bool> _shouldDownloadFile(
String conversationJid,
String accountJid,
) async {
return (await Permission.storage.status).isGranted &&
await _automaticFileDownloadAllowed() &&
await GetIt.I
.get<RosterService>()
.isInRoster(conversationJid, accountJid);
}
/// Handles receiving a message stanza of type error.
Future<void> _handleErrorMessage(
MessageEvent event,
String accountJid,
) async {
if (event.error == null) {
_log.warning(
'Received error for message ${event.id} without an error element',
);
return;
}
if (event.id == null) {
_log.warning(
'Received error message without id.',
);
return;
}
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageByStanzaId(
event.id!,
accountJid,
);
if (msg == null) {
_log.warning('Received error for message ${event.id} we cannot find');
return;
}
var error = MessageErrorType.unspecified;
if (event.error! is ServiceUnavailableError) {
error = MessageErrorType.serviceUnavailable;
} else if (event.error! is RemoteServerNotFoundError) {
error = MessageErrorType.remoteServerNotFound;
} else if (event.error! is RemoteServerTimeoutError) {
error = MessageErrorType.remoteServerTimeout;
}
final newMsg = await ms.updateMessage(
msg.id,
accountJid,
errorType: error,
);
// Show an error notification
await GetIt.I
.get<NotificationsService>()
.showMessageErrorNotification(msg.conversationJid, accountJid, error);
// Update the UI
sendEvent(MessageUpdatedEvent(message: newMsg));
}
Future<void> _handleMessageCorrection(
MessageEvent event,
String conversationJid,
String accountJid,
) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final correctionId = event.extensions.get<LastMessageCorrectionData>()!.id;
final msg = await ms.getMessageByOriginId(
correctionId,
accountJid,
);
if (msg == null) {
_log.warning(
'Received message correction for message $correctionId we cannot find.',
);
return;
}
// Check if the Jid is allowed to do correct the message
if (msg.senderJid.toBare() != event.from.toBare()) {
_log.warning(
'Received a message correction from ${event.from} for a message that is sent by ${msg.sender}',
);
return;
}
// Check if the message can be corrected
if (!msg.canEdit(true)) {
_log.warning(
'Received a message correction for a message that cannot be edited',
);
return;
}
// TODO(Unknown): Should we null-check here?
final newMsg = await ms.updateMessage(
msg.id,
accountJid,
body: event.extensions.get<MessageBodyData>()!.body,
isEdited: true,
);
sendEvent(MessageUpdatedEvent(message: newMsg));
final conv = await cs.getConversationByJid(msg.conversationJid, accountJid);
if (conv != null && conv.lastMessage?.id == msg.id) {
final newConv = conv.copyWith(
lastMessage: newMsg,
);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
}
Future<void> _handleMessageReactions(
MessageEvent event,
String conversationJid,
String accountJid,
) async {
if (event.type == 'groupchat') {
_log.warning(
'Received a message reaction of type groupchat. Ignoring...',
);
return;
}
final ms = GetIt.I.get<MessageService>();
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
final reactions = event.extensions.get<MessageReactionsData>()!;
final msg = await ms.getMessageByStanzaId(
reactions.messageId,
accountJid,
queryReactionPreview: false,
);
if (msg == null) {
_log.warning(
'Received reactions for ${reactions.messageId} from ${event.from} for $conversationJid, but could not find message.',
);
return;
}
await GetIt.I.get<ReactionsService>().processNewReactions(
msg,
accountJid,
event.from.toBare().toString(),
reactions.emojis,
);
}
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
// The jid this message event is meant for
final isCarbon = event.extensions.get<CarbonsData>()?.isCarbon ?? false;
final conversationJid = isCarbon
? event.to.toBare().toString()
: event.from.toBare().toString();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
if (event.type == 'error') {
await _handleErrorMessage(event, accountJid!);
_log.finest('Processed error message. Ending event processing here.');
return;
}
// Process the chat state update. Can also be attached to other messages
final chatState = event.extensions.get<ChatState>();
if (chatState != null) {
await _onChatState(chatState, conversationJid);
}
// Process message corrections separately
if (event.extensions.get<LastMessageCorrectionData>() != null) {
await _handleMessageCorrection(event, conversationJid, accountJid!);
return;
}
// Process File Upload Notifications replacements separately
if (event.extensions.get<FileUploadNotificationReplacementData>() != null) {
await _handleFileUploadNotificationReplacement(
event,
conversationJid,
accountJid!,
);
return;
}
if (event.extensions.get<MessageRetractionData>() != null) {
await _handleMessageRetraction(event, conversationJid, accountJid!);
return;
}
// Handle message reactions
if (event.extensions.get<MessageReactionsData>() != null) {
await _handleMessageReactions(event, conversationJid, accountJid!);
return;
}
// Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event) && event.encryptionError == null) return;
if (event.encryptionError is InvalidKeyExchangeSignatureError) return;
// Ignore File Upload Notifications where we don't have a filename.
final fun = event.extensions.get<FileUploadNotificationData>();
if (fun != null && fun.metadata.name == null) {
_log.finest(
'Ignoring File Upload Notification as it does not specify a filename',
);
return;
}
final state = await GetIt.I.get<XmppStateService>().state;
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// The (portential) roster item of the chat partner
final rosterItem = await GetIt.I
.get<RosterService>()
.getRosterItemByJid(conversationJid, accountJid!);
// Is the conversation partner in our roster
final isInRoster = rosterItem != null;
// True if the message was sent by us (via a Carbon)
final sent = isCarbon && event.from.toBare().toString() == state.jid;
// Acknowledge the message if enabled
final receiptRequested =
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
false;
if (receiptRequested && isInRoster && prefs.sendChatMarkers) {
// NOTE: We do not await it to prevent us being blocked if the IQ response id delayed
await _acknowledgeMessage(event);
}
// Pre-process the message in case it is a reply to another message
// TODO(Unknown): Fix the notion that no body means body == ''
final body = event.extensions.get<MessageBodyData>()?.body ?? '';
final reply = event.extensions.get<ReplyData>();
String? replyId;
var messageBody = body;
if (reply != null) {
replyId = reply.id;
// Strip the compatibility fallback, if specified
messageBody = reply.withoutFallback ?? body;
_log.finest('Removed message reply compatibility fallback from message');
}
// The Url of the file embedded in the message, if there is one.
final embeddedFile = _getEmbeddedFile(event);
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
// The dimensions of the file, if available.
final dimensions = _getDimensions(event);
// Indicates if we should auto-download the file, if a file is specified in the message
final shouldDownload = isFileEmbedded &&
await _shouldDownloadFile(conversationJid, accountJid);
// Indicates if a notification should be created for the message.
// The way this variable works is that if we can download the file, then the
// notification will be created later by the [DownloadService]. If we don't want the
// download to happen automatically, then the notification should happen immediately.
var shouldNotify = !(isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file.
var mimeGuess = _getMimeGuess(event);
FileMetadataWrapper? fileMetadata;
if (isFileEmbedded) {
final thumbnail = _getThumbnailData(event);
fileMetadata =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
embeddedFile!,
mimeGuess,
embeddedFile.size,
dimensions,
// TODO(Unknown): Maybe we switch to something else?
thumbnail != null ? 'blurhash' : null,
thumbnail,
createHashPointers: false,
);
}
// Log encryption errors
if (event.encryptionError != null) {
_log.warning(
'Got encryption error from moxxmpp for message: ${event.encryptionError}',
);
}
// Check if we have to create pseudo-messages related to OMEMO
final omemoData = event.get<OmemoData>();
if (omemoData != null) {
final addedRatchetsList =
omemoData.newRatchets.values.map((ids) => ids.length);
final amountAdded = addedRatchetsList.isEmpty
? 0
: addedRatchetsList.reduce((value, element) => value + element);
final replacedRatchetsList =
omemoData.replacedRatchets.values.map((ids) => ids.length);
final amountReplaced = replacedRatchetsList.isEmpty
? 0
: replacedRatchetsList.reduce((value, element) => value + element);
// Notify of new ratchets
final om = GetIt.I.get<OmemoService>();
if (omemoData.newRatchets.isNotEmpty) {
await om.addPseudoMessage(
conversationJid,
accountJid,
PseudoMessageType.newDevice,
amountAdded,
amountReplaced,
);
}
// Notify of changed ratchets
if (omemoData.replacedRatchets.isNotEmpty) {
await om.addPseudoMessage(
conversationJid,
accountJid,
PseudoMessageType.changedDevice,
amountAdded,
amountReplaced,
);
}
}
// Create the message in the database
// The timestamp at which we received the message
final messageTimestamp = DateTime.now().millisecondsSinceEpoch;
final ms = GetIt.I.get<MessageService>();
var message = await ms.addMessageFromData(
accountJid,
messageBody,
messageTimestamp,
event.from.toString(),
conversationJid,
// TODO(Unknown): Should we handle this differently?
event.id ?? '',
fun != null,
event.encrypted,
event.extensions
.get<MessageProcessingHintData>()
?.hints
.contains(MessageProcessingHint.noStore) ??
false,
fileMetadata: fileMetadata?.fileMetadata,
quoteId: replyId,
originId: event.extensions.get<StableIdData>()?.originId,
errorType: MessageErrorType.fromException(event.encryptionError),
stickerPackId: event.extensions.get<StickersData>()?.stickerPackId,
occupantId: event.extensions.get<OccupantIdData>()?.id,
);
// Attempt to auto-download the embedded file, if
// - there is a file attached and
// - we have not retrieved the file metadata OR we know of the file but have no path for it
if (shouldDownload && (!(fileMetadata?.retrieved ?? false)) ||
fileMetadata?.fileMetadata.path == null && embeddedFile != null) {
final fts = GetIt.I.get<HttpFileTransferService>();
final metadata = await peekFile(embeddedFile!.urls.first);
_log.finest('Advertised file MIME: ${metadata.mime}');
if (metadata.mime != null) mimeGuess = metadata.mime;
// Auto-download only if the file is below the set limit, if the limit is not set to
// "always download".
if (prefs.maximumAutoDownloadSize == -1 ||
(metadata.size != null &&
metadata.size! < prefs.maximumAutoDownloadSize * 1000000)) {
message = await ms.updateMessage(
message.id,
accountJid,
isDownloading: true,
);
await fts.downloadFile(
FileDownloadJob(
message.id,
message.conversationJid,
accountJid,
embeddedFile,
message.fileMetadata!.id,
// If we did not retrieve the file, then we were not able to find it using
// hashes.
!fileMetadata!.retrieved,
mimeGuess,
),
);
} else {
// Make sure we create the notification
shouldNotify = true;
}
} else {
if (fileMetadata?.retrieved ?? false) {
_log.info('Not downloading file as we already have it locally');
}
}
final cs = GetIt.I.get<ConversationService>();
final css = GetIt.I.get<ContactsService>();
final ns = GetIt.I.get<NotificationsService>();
// The body to be displayed in the conversations list
final conversationBody = isFileEmbedded || message.isFileUploadNotification
? mimeTypeToEmoji(mimeGuess)
: messageBody;
// Specifies if we have the conversation this message goes to opened
final isConversationOpened = cs.activeConversationJid == conversationJid;
// If the conversation is muted
var isMuted = false;
// Whether to send the notification
var sendNotification = true;
final gs = GetIt.I.get<GroupchatService>();
final conversation = await cs.createOrUpdateConversation(
conversationJid,
accountJid,
create: () async {
// Create
final contactId = await css.getContactIdForJid(conversationJid);
final groupchatDetails = await gs.getGroupchatDetailsByJid(
JID.fromString(conversationJid).toBare().toString(),
accountJid,
);
final newConversation = await cs.addConversationFromData(
accountJid,
rosterItem?.title ?? conversationJid.split('@')[0],
message,
ConversationType.chat,
rosterItem?.avatarPath ?? '',
conversationJid,
sent ? 0 : 1,
messageTimestamp,
true,
prefs.defaultMuteState,
message.encrypted,
contactId,
await css.getProfilePicturePathForJid(conversationJid),
await css.getContactDisplayName(contactId),
groupchatDetails,
);
// Notify the UI
sendEvent(ConversationAddedEvent(conversation: newConversation));
return newConversation;
},
update: (c) async {
// Update
final newConversation = await cs.updateConversation(
conversationJid,
accountJid,
lastMessage: message,
lastChangeTimestamp: messageTimestamp,
// Do not increment the counter for messages we sent ourselves (via Carbons)
// or if we have the chat currently opened
unreadCounter: isConversationOpened || sent
? c.unreadCounter
: c.unreadCounter + 1,
open: true,
);
// Notify the UI of the update
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
return newConversation;
},
preRun: (c) async {
isMuted = c != null ? c.muted : prefs.defaultMuteState;
sendNotification = !sent &&
shouldNotify &&
(!isConversationOpened ||
!GetIt.I.get<LifecycleService>().isActive) &&
!isMuted;
},
);
// Update the share handler
await GetIt.I.get<ShareService>().recordSentMessage(
conversation!,
);
// Create the notification if we the user does not already know about the message
if (sendNotification) {
await ns.showNotification(
conversation,
message,
accountJid,
isInRoster ? conversation.title : conversationJid,
body: conversationBody,
);
}
// Mark the file as downlading when it includes a File Upload Notification
if (fun != null) {
message = await ms.updateMessage(
message.id,
accountJid,
isDownloading: true,
);
}
// Notify the UI of the message
sendEvent(MessageAddedEvent(message: message));
}
Future<void> _handleFileUploadNotificationReplacement(
MessageEvent event,
String conversationJid,
String accountJid,
) async {
final ms = GetIt.I.get<MessageService>();
final replacementId =
event.extensions.get<FileUploadNotificationReplacementData>()!.id;
var message = await ms.getMessageByOriginId(replacementId, accountJid);
if (message == null) {
_log.warning(
'Received a FileUploadNotification replacement for unknown message',
);
return;
}
// Check if we can even replace the message
if (!message.isFileUploadNotification) {
_log.warning(
'Received a FileUploadNotification replacement for message that is not marked as a FileUploadNotification',
);
return;
}
// Check if the Jid is allowed to do so
if (message.senderJid != event.from.toBare()) {
_log.warning(
'Received a FileUploadNotification replacement by ${event.from} for a message that is sent by ${message.sender}',
);
return;
}
// The Url of the file embedded in the message, if there is one.
final embeddedFile = _getEmbeddedFile(event);
// Is there even a file we can download?
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
if (isFileEmbedded) {
final fileMetadata =
await GetIt.I.get<FilesService>().getFileMetadataFromHash(
embeddedFile!.plaintextHashes,
);
final shouldDownload =
await _shouldDownloadFile(conversationJid, accountJid) &&
fileMetadata == null;
final oldFileMetadata = message.fileMetadata;
message = await ms.updateMessage(
message.id,
accountJid,
fileMetadata: fileMetadata ?? notSpecified,
isFileUploadNotification: false,
isDownloading: shouldDownload,
sid: event.id,
originId: event.extensions.get<StableIdData>()?.originId,
);
// Remove the old entry
if (fileMetadata != null) {
await GetIt.I
.get<FilesService>()
.removeFileMetadata(oldFileMetadata!.id);
}
// Tell the UI
sendEvent(MessageUpdatedEvent(message: message));
if (shouldDownload) {
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
await GetIt.I.get<HttpFileTransferService>().downloadFile(
FileDownloadJob(
message.id,
message.conversationJid,
accountJid,
embeddedFile,
oldFileMetadata!.id,
// If [fileMetadata] is null, then we were not able to find the file metadata
// using hashes and thus have to create hash pointers.
fileMetadata == null,
_getMimeGuess(event),
shouldShowNotification: false,
),
);
} else {
if (fileMetadata != null) {
_log.info('Not downloading file as we already have it locally');
}
}
} else {
_log.warning(
'Received a File Upload Notification replacement but the replacement contains no file!',
);
}
}
Future<void> _onAvatarUpdated(
UserAvatarUpdatedEvent event, {
dynamic extra,
}) async {
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
}
Future<void> _onStanzaAcked(StanzaAckedEvent event, {dynamic extra}) async {
final jid = JID.fromString(event.stanza.to!).toBare().toString();
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final msg = await ms.getMessageByStanzaId(event.stanza.id!, accountJid!);
if (msg != null) {
// Ack the message
final newMsg = await ms.updateMessage(
msg.id,
accountJid,
acked: true,
);
sendEvent(MessageUpdatedEvent(message: newMsg));
// Ack the conversation
final conv = await cs.getConversationByJid(jid, accountJid);
if (conv != null && conv.lastMessage?.id == newMsg.id) {
final newConv = conv.copyWith(lastMessage: msg);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
} else {
_log.finest(
'Wanted to mark message as acked but did not find the message to ack',
);
}
}
Future<void> _onBlocklistBlockPush(
BlocklistBlockPushEvent event, {
dynamic extra,
}) async {
await GetIt.I
.get<BlocklistService>()
.onBlocklistPush(BlockPushType.block, event.items);
}
Future<void> _onBlocklistUnblockPush(
BlocklistUnblockPushEvent event, {
dynamic extra,
}) async {
await GetIt.I
.get<BlocklistService>()
.onBlocklistPush(BlockPushType.unblock, event.items);
}
Future<void> _onBlocklistUnblockAllPush(
BlocklistUnblockAllPushEvent event, {
dynamic extra,
}) async {
GetIt.I.get<BlocklistService>().onUnblockAllPush();
}
Future<void> _onStanzaSendingCancelled(
StanzaSendingCancelledEvent event, {
dynamic extra,
}) async {
// We only really care about messages
if (event.data.stanza.tag != 'message') return;
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final ms = GetIt.I.get<MessageService>();
final message = await ms.getMessageByStanzaId(
event.data.stanza.id!,
accountJid!,
);
if (message == null) {
_log.warning(
'Message could not be sent but we cannot find it in the database',
);
return;
}
_log.finest('Cancel reason: ${event.data.cancelReason}');
final newMessage = await ms.updateMessage(
message.id,
accountJid,
errorType: MessageErrorType.fromException(event.data.cancelReason),
);
// Tell the UI
sendEvent(MessageUpdatedEvent(message: newMessage));
}
Future<void> _onUnrecoverableError(
NonRecoverableErrorEvent event, {
dynamic extra,
}) async {
await GetIt.I.get<NotificationsService>().showWarningNotification(
t.notifications.titles.error,
getUnrecoverableErrorString(event),
);
}
Future<void> _onNewFastToken(
NewFASTTokenReceivedEvent event, {
dynamic extra,
}) async {
// Store the new FAST token for the next authentication attempt.
await GetIt.I.get<XmppStateService>().modifyXmppState((state) {
return state.copyWith(fastToken: event.token.token);
});
}
}