moxxy/lib/service/xmpp.dart

345 lines
12 KiB
Dart

import "dart:async";
import "dart:convert";
import "package:moxxyv2/shared/models/message.dart";
import "package:moxxyv2/ui/helpers.dart";
// TODO: Maybe move this file somewhere else
import "package:moxxyv2/ui/redux/account/state.dart";
import "package:moxxyv2/shared/events.dart";
import "package:moxxyv2/shared/helpers.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/managers/namespaces.dart";
import "package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart";
import "package:moxxyv2/service/state.dart";
import "package:moxxyv2/service/roster.dart";
import "package:moxxyv2/service/database.dart";
import "package:moxxyv2/service/download.dart";
import "package:moxxyv2/service/notifications.dart";
import "package:get_it/get_it.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:connectivity_plus/connectivity_plus.dart";
import "package:logging/logging.dart";
const xmppStateKey = "xmppState";
const xmppAccountDataKey = "xmppAccount";
class XmppService {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true)
);
final Logger _log;
final void Function(BaseIsolateEvent) sendData;
bool loginTriggeredFromUI = false;
String _currentlyOpenedChatJid;
StreamSubscription<ConnectivityResult>? _networkStateSubscription;
XmppState? _state;
XmppService({ required this.sendData }) : _currentlyOpenedChatJid = "", _networkStateSubscription = null, _log = Logger("XmppService"), _state = null;
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
return await _storage.read(key: key);
} else {
return null;
}
}
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
final data = await _readKeyOrNull(xmppStateKey);
// GetIt.I.get<Logger>().finest("data != null: " + (data != null).toString());
if (data == null) {
_state = XmppState(
0,
0,
"",
"",
0,
false
);
await _commitXmppState();
return _state!;
}
_state = XmppState.fromJson(json.decode(data));
return _state!;
}
Future<void> _commitXmppState() async {
// final logger = GetIt.I.get<Logger>();
// logger.finest("Commiting _xmppState to EncryptedSharedPrefs");
// logger.finest("=> ${json.encode(_state!.toJson())}");
await _storage.write(key: xmppStateKey, value: json.encode(_state!.toJson()));
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
await _commitXmppState();
}
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 db = GetIt.I.get<DatabaseService>();
_currentlyOpenedChatJid = jid;
final conversation = await db.getConversationByJid(jid);
if (conversation != null && conversation.unreadCounter > 0) {
final newConversation = await db.updateConversation(
id: conversation.id,
unreadCounter: 0
);
sendData(ConversationUpdatedEvent(conversation: newConversation));
}
}
/// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Load the [AccountState] from storage. Returns null if not found.
Future<AccountState?> getAccountData() async {
final data = await _readKeyOrNull(xmppAccountDataKey);
if (data == null) {
return null;
}
return AccountState.fromJson(jsonDecode(data));
}
/// Save [state] to storage such that it can be loaded again by [getAccountData].
Future<void> setAccountData(AccountState state) async {
return await _storage.write(key: xmppAccountDataKey, value: jsonEncode(state.toJson()));
}
/// Removes the account data from storage.
Future<void> removeAccountData() async {
// TODO: This sometimes fails
await _storage.delete(key: xmppAccountDataKey);
}
/// Sends a message to [jid] with the body of [body].
Future<void> sendMessage({ required String body, required String jid }) async {
final db = GetIt.I.get<DatabaseService>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final message = await db.addMessageFromData(
body,
timestamp,
GetIt.I.get<XmppConnection>().getConnectionSettings().jid.toString(),
jid,
true,
false,
"" // TODO: Stanza ID
);
sendData(MessageSendResultEvent(message: message));
GetIt.I.get<XmppConnection>().getManagerById(messageManager)!.sendMessage(body, jid);
final conversation = await db.getConversationByJid(jid);
final newConversation = await db.updateConversation(
id: conversation!.id,
lastMessageBody: body,
lastChangeTimestamp: timestamp
);
sendData(ConversationUpdatedEvent(conversation: newConversation));
}
Future<void> _handleEvent(XmppEvent event) async {
if (event is ConnectionStateChangedEvent) {
sendData(ConnectionStateEvent(state: event.state.toString().split(".")[1]));
// 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:
case ConnectivityResult.mobile:
case ConnectivityResult.ethernet: {
// 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();
}
if (loginTriggeredFromUI) {
// TODO: Trigger another event so the UI can see this aswell
await setAccountData(AccountState(
jid: connection.getConnectionSettings().jid.toString(),
displayName: connection.getConnectionSettings().jid.local,
avatarUrl: ""
));
sendData(LoginSuccessfulEvent(
jid: connection.getConnectionSettings().jid.toString(),
displayName: connection.getConnectionSettings().jid.local
));
}
}
} else if (event is StreamManagementEnabledEvent) {
// TODO: Remove
modifyXmppState((state) => state.copyWith(
srid: event.id,
resource: event.resource
));
} else if (event is ResourceBindingSuccessEvent) {
modifyXmppState((state) => state.copyWith(
resource: event.resource
));
} else if (event is MessageEvent) {
_log.finest("Received message with origin-id: " + (event.stanzaId.originId ?? "null"));
final timestamp = DateTime.now().millisecondsSinceEpoch;
final db = GetIt.I.get<DatabaseService>();
final fromBare = event.fromJid.toBare().toString();
final isChatOpen = _currentlyOpenedChatJid == fromBare;
final isInRoster = await GetIt.I.get<RosterService>().isInRoster(fromBare);
final oobUrl = event.sfs == null ? event.oob?.url : null;
final isMedia = event.body == oobUrl && Uri.parse(oobUrl!).scheme == "https" || event.sfs != null || event.sims != null;
final shouldNotify = !(isMedia && isInRoster);
String? thumbnailData;
final thumbnails = firstNotNull([ event.sfs?.metadata.thumbnails, event.sims?.thumbnails ]) ?? [];
for (final i in thumbnails) {
if (i is BlurhashThumbnail) {
thumbnailData = i.hash;
break;
}
}
Message msg = await db.addMessageFromData(
event.body,
timestamp,
event.fromJid.toString(),
fromBare,
false,
isMedia,
event.sid,
oobUrl: oobUrl,
thumbnailData: thumbnailData,
thumbnailDimensions: event.sfs?.metadata.dimensions
);
if (isMedia && isInRoster) {
String url;
if (event.sfs != null) {
url = event.sfs!.url;
} else if (event.sims != null) {
url = event.sims!.url;
} else {
// NOTE: Either sfs, sims or oobUrl must be != null
url = oobUrl!;
}
// TODO: Check the file size first
msg = msg.copyWith(isDownloading: true);
GetIt.I.get<DownloadService>().downloadFile(url, msg.id);
}
// TODO: Somehow figure out if it was an image or a file
// TODO: Maybe guess based on the file extension
final body = isMedia ? "📷 Image" : event.body;
final conversation = await db.getConversationByJid(fromBare);
if (conversation != null) {
final newConversation = await db.updateConversation(
id: conversation.id,
lastMessageBody: body,
lastChangeTimestamp: timestamp,
unreadCounter: isChatOpen ? conversation.unreadCounter : conversation.unreadCounter + 1
);
sendData(ConversationUpdatedEvent(conversation: newConversation));
if (!isChatOpen && shouldNotify) {
await GetIt.I.get<NotificationsService>().showNotification(msg, isInRoster ? conversation.title : fromBare);
}
} else {
final conv = await db.addConversationFromData(
fromBare.split("@")[0], // TODO: Check with the roster first
body,
"", // TODO: avatarUrl
fromBare, // TODO: jid
1,
timestamp,
[],
true
);
sendData(ConversationCreatedEvent(conversation: conv));
if (!isChatOpen && shouldNotify) {
await GetIt.I.get<NotificationsService>().showNotification(msg, isInRoster ? conv.title : fromBare);
}
}
sendData(MessageReceivedEvent(message: msg));
} else if (event is RosterPushEvent) {
GetIt.I.get<RosterService>().handleRosterPushEvent(event);
_log.fine("Roster push version: " + (event.ver ?? "(null)"));
} else if (event is RosterItemNotFoundEvent) {
GetIt.I.get<RosterService>().handleRosterItemNotFoundEvent(event);
} else if (event is AuthenticationFailedEvent) {
sendData(LoginFailedEvent(reason: saslErrorToHumanReadable(event.saslError)));
}
}
void installEventHandlers() {
GetIt.I.get<XmppConnection>().asBroadcastStream().listen(_handleEvent);
}
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();
}
}