moxxy/lib/service/xmpp.dart

712 lines
25 KiB
Dart

import "dart:async";
import "dart:convert";
import "package:moxxyv2/shared/events.dart";
import "package:moxxyv2/shared/helpers.dart";
import "package:moxxyv2/shared/eventhandler.dart";
import "package:moxxyv2/shared/migrator.dart";
import "package:moxxyv2/shared/models/message.dart";
import "package:moxxyv2/xmpp/settings.dart";
import "package:moxxyv2/xmpp/jid.dart";
import "package:moxxyv2/xmpp/events.dart";
import "package:moxxyv2/xmpp/roster.dart";
import "package:moxxyv2/xmpp/connection.dart";
import "package:moxxyv2/xmpp/stanza.dart";
import "package:moxxyv2/xmpp/namespaces.dart";
import "package:moxxyv2/xmpp/message.dart";
import "package:moxxyv2/xmpp/managers/namespaces.dart";
import "package:moxxyv2/xmpp/xeps/xep_0085.dart";
import "package:moxxyv2/xmpp/xeps/xep_0184.dart";
import "package:moxxyv2/xmpp/xeps/xep_0333.dart";
import "package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart";
import "package:moxxyv2/service/service.dart";
import "package:moxxyv2/service/state.dart";
import "package:moxxyv2/service/roster.dart";
import "package:moxxyv2/service/database.dart";
import "package:moxxyv2/service/conversation.dart";
import "package:moxxyv2/service/download.dart";
import "package:moxxyv2/service/notifications.dart";
import "package:moxxyv2/service/avatars.dart";
import "package:moxxyv2/service/preferences.dart";
import "package:moxxyv2/service/blocking.dart";
import "package:get_it/get_it.dart";
import "package:connectivity_plus/connectivity_plus.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:flutter_background_service/flutter_background_service.dart";
import "package:logging/logging.dart";
import "package:permission_handler/permission_handler.dart";
const currentXmppStateVersion = 1;
const xmppStateKey = "xmppState";
const xmppStateVersionKey = "xmppState_version";
class _XmppStateMigrator extends Migrator<XmppState> {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true)
);
_XmppStateMigrator() : super(currentXmppStateVersion, []);
// TODO: Deduplicate
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
return await _storage.read(key: key);
} else {
return null;
}
}
@override
Future<Map<String, dynamic>?> loadRawData() async {
final raw = await _readKeyOrNull(xmppStateKey);
if (raw != null) return json.decode(raw);
return null;
}
@override
Future<int?> loadVersion() async {
final raw = await _readKeyOrNull(xmppStateVersionKey);
if (raw != null) return int.parse(raw);
return null;
}
@override
XmppState fromData(Map<String, dynamic> data) => XmppState.fromJson(data);
@override
XmppState fromDefault() => XmppState();
@override
Future<void> commit(int version, XmppState data) async {
await _storage.write(key: xmppStateVersionKey, value: currentXmppStateVersion.toString());
await _storage.write(key: xmppStateKey, value: json.encode(data.toJson()));
}
}
class XmppService {
final Logger _log;
final EventHandler _eventHandler;
final _XmppStateMigrator _migrator;
bool _loginTriggeredFromUI;
bool _appOpen;
String _currentlyOpenedChatJid;
StreamSubscription<ConnectivityResult>? _networkStateSubscription;
XmppState? _state;
ConnectivityResult _currentConnectionType;
XmppService() :
_currentlyOpenedChatJid = "",
_networkStateSubscription = null,
_state = null,
_currentConnectionType = ConnectivityResult.none,
_eventHandler = EventHandler(),
_appOpen = true,
_loginTriggeredFromUI = false,
_migrator = _XmppStateMigrator(),
_log = Logger("XmppService") {
_eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
EventTypeMatcher<ResourceBindingSuccessEvent>(_onResourceBindingSuccess),
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
EventTypeMatcher<MessageAckedEvent>(_onMessageAcked),
EventTypeMatcher<MessageEvent>(_onMessage),
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
EventTypeMatcher<BlocklistUnblockAllPushEvent>(_onBlocklistUnblockAllPush),
]);
}
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
_state = await _migrator.load();
return _state!;
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
await _migrator.commit(currentXmppStateVersion, _state!);
}
/// Stores whether the app is open or not. Useful for notifications.
void setAppState(bool open) {
_appOpen = open;
}
Future<ConnectionSettings?> getConnectionSettings() async {
final state = await getXmppState();
if (state.jid == null || state.password == null) {
return null;
}
return ConnectionSettings(
jid: JID.fromString(state.jid!),
password: state.password!,
useDirectTLS: true,
allowPlainAuth: false
);
}
/// 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 cs = GetIt.I.get<ConversationService>();
_currentlyOpenedChatJid = jid;
final conversation = await cs.getConversationByJid(jid);
if (conversation != null && conversation.unreadCounter > 0) {
final newConversation = await cs.updateConversation(
conversation.id,
unreadCounter: 0
);
sendEvent(
ConversationUpdatedEvent(conversation: newConversation)
);
}
}
/// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Sends a message to [jid] with the body of [body].
Future<void> sendMessage({
required String body,
required String jid,
Message? quotedMessage,
String? commandId,
ChatState? chatState
}) async {
final db = GetIt.I.get<DatabaseService>();
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final sid = conn.generateId();
final originId = conn.generateId();
final message = await db.addMessageFromData(
body,
timestamp,
conn.getConnectionSettings().jid.toString(),
jid,
true,
false,
sid,
originId: originId,
quoteId: quotedMessage?.originId ?? quotedMessage?.sid
);
if (commandId != null) {
sendEvent(
MessageAddedEvent(message: message),
id: commandId
);
}
conn.getManagerById(messageManager)!.sendMessage(
MessageDetails(
to: jid,
body: body,
requestDeliveryReceipt: true,
id: sid,
originId: originId,
quoteBody: quotedMessage?.body,
quoteFrom: quotedMessage?.from,
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
chatState: chatState
)
);
final conversation = await cs.getConversationByJid(jid);
final newConversation = await cs.updateConversation(
conversation!.id,
lastMessageBody: body,
lastChangeTimestamp: timestamp
);
sendEvent(
ConversationUpdatedEvent(conversation: newConversation)
);
}
String? _getMessageSrcUrl(MessageEvent event) {
if (event.sfs != null) {
return event.sfs!.url;
} else if (event.sims != null) {
return event.sims!.url;
} else if (event.oob != null) {
return event.oob!.url;
}
return null;
}
Future<void> _acknowledgeMessage(MessageEvent event) async {
final info = await GetIt.I.get<XmppConnection>().getDiscoCacheManager().getInfoByJid(event.fromJid.toString());
if (info == null) return;
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
GetIt.I.get<XmppConnection>().sendStanza(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeChatMarker("received", event.stanzaId.originId ?? event.sid)
]
)
);
} else if (event.deliveryReceiptRequested && info.features.contains(deliveryXmlns)) {
GetIt.I.get<XmppConnection>().sendStanza(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeMessageDeliveryResponse(event.stanzaId.originId ?? event.sid)
]
)
);
}
}
/// Returns true if we are allowed to automatically download a file
Future<bool> _automaticFileDownloadAllowed() async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
return prefs.autoDownloadWifi && _currentConnectionType == ConnectivityResult.wifi
|| prefs.autoDownloadMobile && _currentConnectionType == ConnectivityResult.mobile;
}
void installEventHandlers() {
GetIt.I.get<XmppConnection>().asBroadcastStream().listen(_eventHandler.run);
}
Future<void> connect(ConnectionSettings settings, bool triggeredFromUI) async {
final lastResource = (await getXmppState()).resource;
_loginTriggeredFromUI = triggeredFromUI;
GetIt.I.get<XmppConnection>().setConnectionSettings(settings);
GetIt.I.get<XmppConnection>().connect(lastResource: lastResource);
installEventHandlers();
}
Future<XmppConnectionResult> connectAwaitable(ConnectionSettings settings, bool triggeredFromUI) async {
final lastResource = (await getXmppState()).resource;
_loginTriggeredFromUI = triggeredFromUI;
GetIt.I.get<XmppConnection>().setConnectionSettings(settings);
installEventHandlers();
return GetIt.I.get<XmppConnection>().connectAwaitable(lastResource: lastResource);
}
Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
switch (event.state) {
case XmppConnectionState.connected: {
FlutterBackgroundService().setNotificationInfo(title: "Moxxy", content: "Ready to receive messages");
}
break;
case XmppConnectionState.connecting: {
FlutterBackgroundService().setNotificationInfo(title: "Moxxy", content: "Connecting...");
}
break;
default: {
FlutterBackgroundService().setNotificationInfo(title: "Moxxy", content: "Disconnected");
}
break;
}
// TODO: This will fire as soon as we listen to the stream. So we either have to debounce it here or in [XmppConnection]
_networkStateSubscription ??= Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
_log.fine("Got ConnectivityResult: " + result.toString());
switch (result) {
case ConnectivityResult.none: {
GetIt.I.get<XmppConnection>().onNetworkConnectionLost();
}
break;
case ConnectivityResult.wifi: {
_currentConnectionType = ConnectivityResult.wifi;
// TODO: This will crash inside [XmppConnection] as soon as this happens
GetIt.I.get<XmppConnection>().onNetworkConnectionRegained();
}
break;
case ConnectivityResult.mobile: {
_currentConnectionType = ConnectivityResult.mobile;
// TODO: This will crash inside [XmppConnection] as soon as this happens
GetIt.I.get<XmppConnection>().onNetworkConnectionRegained();
}
break;
case ConnectivityResult.ethernet: {
// NOTE: A hack, but should be fine
_currentConnectionType = ConnectivityResult.wifi;
// TODO: This will crash inside [XmppConnection] as soon as this happens
GetIt.I.get<XmppConnection>().onNetworkConnectionRegained();
}
break;
default: break;
}
});
if (event.state == XmppConnectionState.connected) {
final connection = GetIt.I.get<XmppConnection>();
// TODO: Maybe have something better
final settings = connection.getConnectionSettings();
modifyXmppState((state) => state.copyWith(
jid: settings.jid.toString(),
password: settings.password.toString()
));
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
if (!event.resumed) {
GetIt.I.get<RosterService>().requestRoster();
// Request our own avatar and maybe those of our contacts
}
// Make sure we display our own avatar correctly
// TODO: Maybe don't do this on mobile Internet
GetIt.I.get<AvatarService>().requestOwnAvatar();
// Either we get the cached version or we retrieve it for the first time
GetIt.I.get<BlocklistService>().getBlocklist();
if (_loginTriggeredFromUI) {
// TODO: Trigger another event so the UI can see this aswell
await modifyXmppState((state) => state.copyWith(
jid: connection.getConnectionSettings().jid.toString(),
displayName: connection.getConnectionSettings().jid.local,
avatarUrl: "",
avatarHash: ""
));
}
}
}
Future<void> _onResourceBindingSuccess(ResourceBindingSuccessEvent event, { dynamic extra }) async {
modifyXmppState((state) => state.copyWith(
resource: event.resource
));
}
Future<void> _onSubscriptionRequestReceived(SubscriptionRequestReceivedEvent event, { dynamic extra }) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
if (prefs.autoAcceptSubscriptionRequests) {
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(
event.from.toBare().toString()
);
}
if (!prefs.showSubscriptionRequests) return;
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(event.from.toBare().toString());
final timestamp = DateTime.now().millisecondsSinceEpoch;
if (conversation != null) {
final newConversation = await cs.updateConversation(
conversation.id,
open: true,
lastChangeTimestamp: timestamp
);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
} else {
// TODO: Make it configurable if this should happen
final bare = event.from.toBare();
final conv = await cs.addConversationFromData(
bare.toString().split("@")[0],
"",
"", // TODO: avatarUrl
bare.toString(),
0,
timestamp,
[],
true
);
sendEvent(ConversationAddedEvent(conversation: conv));
}
}
Future<void> _onDeliveryReceiptReceived(DeliveryReceiptReceivedEvent event, { dynamic extra }) async {
_log.finest("Received delivery receipt from ${event.from.toString()}");
final db = GetIt.I.get<DatabaseService>();
final dbMsg = await db.getMessageByXmppId(event.id, event.from.toBare().toString());
if (dbMsg == null) {
_log.warning("Did not find the message with id ${event.id} in the database!");
return;
}
final msg = await db.updateMessage(
id: dbMsg.id!,
received: true
);
sendEvent(MessageUpdatedEvent(message: msg));
}
Future<void> _onChatMarker(ChatMarkerEvent event, { dynamic extra }) async {
_log.finest("Chat marker from ${event.from.toString()}");
if (event.type == "acknowledged") return;
final db = GetIt.I.get<DatabaseService>();
final dbMsg = await db.getMessageByXmppId(event.id, event.from.toBare().toString());
if (dbMsg == null) {
_log.warning("Did not find the message in the database!");
return;
}
final msg = await db.updateMessage(
id: dbMsg.id!,
received: dbMsg.received || event.type == "received" || event.type == "displayed",
displayed: dbMsg.displayed || event.type == "displayed"
);
sendEvent(MessageUpdatedEvent(message: msg));
}
Future<void> _onChatState(ChatState state, String jid) async {
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(jid);
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) {
return event.body.isNotEmpty || event.sfs != null || event.sims != null;
}
/// Extract the thumbnail data from a message, if existent.
String? _getThumbnailData(MessageEvent event) {
final thumbnails = firstNotNull([
event.sfs?.metadata.thumbnails,
event.sims?.thumbnails
]) ?? [];
for (final i in thumbnails) {
if (i is BlurhashThumbnail) {
return i.hash;
}
}
return null;
}
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
// The jid this message event is meant for
String conversationJid = event.isCarbon
? event.toJid.toBare().toString()
: event.fromJid.toBare().toString();
// Process the chat state update. Can also be attached to other messages
if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);
// Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event)) return;
final state = await getXmppState();
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Is the conversation partner in our roster
final isInRoster = await GetIt.I.get<RosterService>().isInRoster(conversationJid);
// True if the message was sent by us (via a Carbon)
final sent = event.isCarbon && event.fromJid.toBare().toString() == state.jid;
// The timestamp at which we received the message
final messageTimestamp = DateTime.now().millisecondsSinceEpoch;
// Acknowledge the message if enabled
if (event.deliveryReceiptRequested && isInRoster && prefs.sendChatMarkers) {
// NOTE: We do not await it to prevent us being blocked if the IQ response id delayed
_acknowledgeMessage(event);
}
// Pre-process the message in case it is a reply to another message
String? replyId;
String messageBody = event.body;
// TODO
if (event.reply != null /* && check if event.reply.to is okay */) {
replyId = event.reply!.id;
// Strip the compatibility fallback, if specified
if (event.reply!.start != null && event.reply!.end != null) {
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end!, "");
_log.finest("Removed message reply compatibility fallback from message");
}
}
// The Url of the file embedded in the message, if there is one.
final embeddedFileUrl = _getMessageSrcUrl(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 = embeddedFileUrl != null
&& Uri.parse(embeddedFileUrl).scheme == "https"
&& implies(event.oob != null, event.body == event.oob?.url);
// Indicates if we should auto-download the file, if a file is specified in the message
final shouldDownload = (await Permission.storage.status).isGranted
&& await _automaticFileDownloadAllowed()
&& isInRoster;
// The thumbnail for the embedded file.
final thumbnailData = _getThumbnailData(event);
// 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.
bool shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file.
String? mimeGuess;
// Create the message in the database
final db = GetIt.I.get<DatabaseService>();
Message message = await db.addMessageFromData(
messageBody,
messageTimestamp,
event.fromJid.toString(),
conversationJid,
sent,
isFileEmbedded,
event.sid,
srcUrl: embeddedFileUrl,
mediaType: mimeGuess,
thumbnailData: thumbnailData,
// TODO: What about SIMS?
thumbnailDimensions: event.sfs?.metadata.dimensions,
quoteId: replyId
);
// Attempt to auto-download the embedded file
if (isFileEmbedded && shouldDownload) {
final ds = GetIt.I.get<DownloadService>();
final metadata = await ds.peekFile(embeddedFileUrl);
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 = message.copyWith(isDownloading: true);
ds.downloadFile(embeddedFileUrl, message.id, conversationJid, mimeGuess);
} else {
// Make sure we create the notification
shouldNotify = true;
}
}
final cs = GetIt.I.get<ConversationService>();
final ns = GetIt.I.get<NotificationsService>();
// The body to be displayed in the conversations list
final conversationBody = isFileEmbedded ? mimeTypeToConversationBody(mimeGuess) : messageBody;
// Specifies if we have the conversation this message goes to opened
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
// The conversation we're about to modify, if it exists
final conversation = await cs.getConversationByJid(conversationJid);
// Whether to send the notification
final sendNotification = !sent && shouldNotify && (!isConversationOpened || !_appOpen);
if (conversation != null) {
// The conversation exists, so we can just update it
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: conversationBody,
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
? conversation.unreadCounter
: conversation.unreadCounter + 1,
open: true
);
// Notify the UI of the update
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
// Create the notification if we the user does not already know about the message
if (sendNotification) {
await ns.showNotification(
message,
isInRoster ? newConversation.title : conversationJid,
body: conversationBody
);
}
} else {
// The conversation does not exist, so we must create it
final newConversation = await cs.addConversationFromData(
conversationJid.split("@")[0], // TODO: Check with the roster and User Nickname
conversationBody,
"", // TODO: Check if we know the avatar url already, e.g. from the roster
conversationJid,
sent ? 0 : 1,
messageTimestamp,
[],
true
);
// Notify the UI
sendEvent(ConversationAddedEvent(conversation: newConversation));
// Creat the notification
if (sendNotification) {
await ns.showNotification(
message,
isInRoster ? newConversation.title : conversationJid,
body: messageBody
);
}
}
// Notify the UI of the message
sendEvent(MessageAddedEvent(message: message));
}
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {
GetIt.I.get<RosterService>().handleRosterPushEvent(event);
_log.fine("Roster push version: " + (event.ver ?? "(null)"));
}
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
await GetIt.I.get<AvatarService>().updateAvatarForJid(
event.jid,
event.hash,
event.base64
);
}
Future<void> _onMessageAcked(MessageAckedEvent event, { dynamic extra }) async {
final jid = JID.fromString(event.to).toBare().toString();
final db = GetIt.I.get<DatabaseService>();
final msg = await db.getMessageByXmppId(event.id, jid);
if (msg != null) {
await db.updateMessage(id: msg.id!, acked: true);
} 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();
}
}