1824 lines
59 KiB
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);
|
|
});
|
|
}
|
|
}
|