/* * Copyright (c) 2019 Zender & Kurtz GbR. * * Authors: * Christian Pauly * Marcel Radzio * * This file is part of famedlysdk. * * famedlysdk is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * famedlysdk is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with famedlysdk. If not, see . */ import 'dart:async'; import 'dart:convert'; import 'package:pedantic/pedantic.dart'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/src/client.dart'; import 'package:famedlysdk/src/event.dart'; import 'package:famedlysdk/src/room_account_data.dart'; import 'package:famedlysdk/src/sync/event_update.dart'; import 'package:famedlysdk/src/sync/room_update.dart'; import 'package:famedlysdk/src/utils/matrix_exception.dart'; import 'package:famedlysdk/src/utils/matrix_file.dart'; import 'package:famedlysdk/src/utils/session_key.dart'; import 'package:image/image.dart'; import 'package:matrix_file_e2ee/matrix_file_e2ee.dart'; import 'package:mime_type/mime_type.dart'; import 'package:olm/olm.dart' as olm; import 'package:html_unescape/html_unescape.dart'; import './user.dart'; import 'timeline.dart'; import 'utils/matrix_localizations.dart'; import 'utils/states_map.dart'; import './utils/markdown.dart'; import './database/database.dart' show DbRoom; enum PushRuleState { notify, mentions_only, dont_notify } enum JoinRules { public, knock, invite, private } enum GuestAccess { can_join, forbidden } enum HistoryVisibility { invited, joined, shared, world_readable } /// Represents a Matrix room. class Room { /// The full qualified Matrix ID for the room in the format '!localid:server.abc'. final String id; /// Membership status of the user for this room. Membership membership; /// The count of unread notifications. int notificationCount; /// The count of highlighted notifications. int highlightCount; /// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint. String prev_batch; /// The users which can be used to generate a room name if the room does not have one. /// Required if the room's m.room.name or m.room.canonical_alias state events are unset or empty. List mHeroes = []; /// The number of users with membership of join, including the client's own user ID. int mJoinedMemberCount; /// The number of users with membership of invite. int mInvitedMemberCount; StatesMap states = StatesMap(); /// Key-Value store for ephemerals. Map ephemerals = {}; /// Key-Value store for private account data only visible for this user. Map roomAccountData = {}; olm.OutboundGroupSession get outboundGroupSession => _outboundGroupSession; olm.OutboundGroupSession _outboundGroupSession; List _outboundGroupSessionDevices; DateTime _outboundGroupSessionCreationTime; int _outboundGroupSessionSentMessages; double _newestSortOrder; double _oldestSortOrder; double get newSortOrder { _newestSortOrder++; return _newestSortOrder; } double get oldSortOrder { _oldestSortOrder--; return _oldestSortOrder; } void resetSortOrder() { _oldestSortOrder = _newestSortOrder = 0.0; } Future updateSortOrder() async { await client.database?.updateRoomSortOrder( _oldestSortOrder, _newestSortOrder, client.id, id); } /// Clears the existing outboundGroupSession, tries to create a new one and /// stores it as an ingoingGroupSession in the [sessionKeys]. Then sends the /// new session encrypted with olm to all non-blocked devices using /// to-device-messaging. Future createOutboundGroupSession() async { await clearOutboundGroupSession(wipe: true); var deviceKeys = await getUserDeviceKeys(); olm.OutboundGroupSession outboundGroupSession; var outboundGroupSessionDevices = []; for (var keys in deviceKeys) { if (!keys.blocked) outboundGroupSessionDevices.add(keys.deviceId); } outboundGroupSessionDevices.sort(); try { outboundGroupSession = olm.OutboundGroupSession(); outboundGroupSession.create(); } catch (e) { outboundGroupSession = null; print('[LibOlm] Unable to create new outboundGroupSession: ' + e.toString()); } if (outboundGroupSession == null) return; // Add as an inboundSession to the [sessionKeys]. var rawSession = { 'algorithm': 'm.megolm.v1.aes-sha2', 'room_id': id, 'session_id': outboundGroupSession.session_id(), 'session_key': outboundGroupSession.session_key(), }; setInboundGroupSession(rawSession['session_id'], rawSession); try { await client.sendToDevice(deviceKeys, 'm.room_key', rawSession); _outboundGroupSession = outboundGroupSession; _outboundGroupSessionDevices = outboundGroupSessionDevices; _outboundGroupSessionCreationTime = DateTime.now(); _outboundGroupSessionSentMessages = 0; await _storeOutboundGroupSession(); } catch (e) { print( '[LibOlm] Unable to send the session key to the participating devices: ' + e.toString()); await clearOutboundGroupSession(); } return; } Future _storeOutboundGroupSession() async { if (_outboundGroupSession == null) return; await client.database?.storeOutboundGroupSession( client.id, id, _outboundGroupSession.pickle(client.userID), json.encode(_outboundGroupSessionDevices), _outboundGroupSessionCreationTime, _outboundGroupSessionSentMessages); return; } /// Clears the existing outboundGroupSession but first checks if the participating /// devices have been changed. Returns false if the session has not been cleared because /// it wasn't necessary. Future clearOutboundGroupSession({bool wipe = false}) async { if (!wipe && _outboundGroupSessionDevices != null) { // first check if the devices in the room changed var deviceKeys = await getUserDeviceKeys(); var outboundGroupSessionDevices = []; for (var keys in deviceKeys) { if (!keys.blocked) outboundGroupSessionDevices.add(keys.deviceId); } outboundGroupSessionDevices.sort(); if (outboundGroupSessionDevices.toString() != _outboundGroupSessionDevices.toString()) { wipe = true; } // next check if it needs to be rotated final encryptionContent = getState('m.room.encryption')?.content; final maxMessages = encryptionContent != null && encryptionContent['rotation_period_msgs'] is int ? encryptionContent['rotation_period_msgs'] : 100; final maxAge = encryptionContent != null && encryptionContent['rotation_period_ms'] is int ? encryptionContent['rotation_period_ms'] : 604800000; // default of one week if (_outboundGroupSessionSentMessages >= maxMessages || _outboundGroupSessionCreationTime .add(Duration(milliseconds: maxAge)) .isBefore(DateTime.now())) { wipe = true; } if (!wipe) { return false; } } if (!wipe && _outboundGroupSessionDevices == null && _outboundGroupSession == null) { return true; // let's just short-circuit out of here, no need to do DB stuff } _outboundGroupSessionDevices = null; await client.database?.removeOutboundGroupSession(client.id, id); _outboundGroupSession?.free(); _outboundGroupSession = null; return true; } /// Key-Value store of session ids to the session keys. Only m.megolm.v1.aes-sha2 /// session keys are supported. They are stored as a Map with the following keys: /// { /// "algorithm": "m.megolm.v1.aes-sha2", /// "room_id": "!Cuyf34gef24t:localhost", /// "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ", /// "session_key": "AgAAAADxKHa9uFxcXzwYoNueL5Xqi69IkD4sni8LlfJL7qNBEY..." /// } Map get inboundGroupSessions => _inboundGroupSessions; final _inboundGroupSessions = {}; /// Add a new session key to the [sessionKeys]. void setInboundGroupSession(String sessionId, Map content, {bool forwarded = false}) { if (inboundGroupSessions.containsKey(sessionId)) return; olm.InboundGroupSession inboundGroupSession; if (content['algorithm'] == 'm.megolm.v1.aes-sha2') { try { inboundGroupSession = olm.InboundGroupSession(); if (forwarded) { inboundGroupSession.import_session(content['session_key']); } else { inboundGroupSession.create(content['session_key']); } } catch (e) { inboundGroupSession = null; print('[LibOlm] Could not create new InboundGroupSession: ' + e.toString()); } } _inboundGroupSessions[sessionId] = SessionKey( content: content, inboundGroupSession: inboundGroupSession, indexes: {}, key: client.userID, ); client.database?.storeInboundGroupSession( client.id, id, sessionId, inboundGroupSession.pickle(client.userID), json.encode(content), json.encode({}), ); _tryAgainDecryptLastMessage(); onSessionKeyReceived.add(sessionId); } void _tryAgainDecryptLastMessage() { if (getState('m.room.encrypted') != null) { final decrypted = getState('m.room.encrypted').decrypted; if (decrypted.type != EventTypes.Encrypted) { setState(decrypted); } } } /// Returns the [Event] for the given [typeKey] and optional [stateKey]. /// If no [stateKey] is provided, it defaults to an empty string. Event getState(String typeKey, [String stateKey = '']) => states.states[typeKey] != null ? states.states[typeKey][stateKey] : null; /// Adds the [state] to this room and overwrites a state with the same /// typeKey/stateKey key pair if there is one. void setState(Event state) { // Decrypt if necessary if (state.type == EventTypes.Encrypted) { try { state = decryptGroupMessage(state); } catch (e) { print('[LibOlm] Could not decrypt room state: ' + e.toString()); } } // Check if this is a member change and we need to clear the outboundGroupSession. if (encrypted && outboundGroupSession != null && state.type == EventTypes.RoomMember) { var newUser = state.asUser; var oldUser = getState('m.room.member', newUser.id)?.asUser; if (oldUser == null || oldUser.membership != newUser.membership) { clearOutboundGroupSession(); } } if ((getState(state.typeKey)?.time?.millisecondsSinceEpoch ?? 0) > (state.time?.millisecondsSinceEpoch ?? 1)) { return; } if (!states.states.containsKey(state.typeKey)) { states.states[state.typeKey] = {}; } states.states[state.typeKey][state.stateKey ?? ''] = state; } /// ID of the fully read marker event. String get fullyRead => roomAccountData['m.fully_read'] != null ? roomAccountData['m.fully_read'].content['event_id'] : ''; /// If something changes, this callback will be triggered. Will return the /// room id. final StreamController onUpdate = StreamController.broadcast(); /// If there is a new session key received, this will be triggered with /// the session ID. final StreamController onSessionKeyReceived = StreamController.broadcast(); /// The name of the room if set by a participant. String get name => states['m.room.name'] != null ? states['m.room.name'].content['name'] : ''; /// Returns a localized displayname for this server. If the room is a groupchat /// without a name, then it will return the localized version of 'Group with Alice' instead /// of just 'Alice' to make it different to a direct chat. /// Empty chats will become the localized version of 'Empty Chat'. /// This method requires a localization class which implements [MatrixLocalizations] String getLocalizedDisplayname(MatrixLocalizations i18n) { if ((name?.isEmpty ?? true) && (canonicalAlias?.isEmpty ?? true) && !isDirectChat && (mHeroes != null && mHeroes.isNotEmpty)) { return i18n.groupWith(displayname); } if (displayname?.isNotEmpty ?? false) { return displayname; } return i18n.emptyChat; } /// The topic of the room if set by a participant. String get topic => states['m.room.topic'] != null ? states['m.room.topic'].content['topic'] : ''; /// The avatar of the room if set by a participant. Uri get avatar { if (states['m.room.avatar'] != null && states['m.room.avatar'].content['url'] != null) { return Uri.parse(states['m.room.avatar'].content['url']); } if (mHeroes != null && mHeroes.length == 1 && states[mHeroes[0]] != null) { return states[mHeroes[0]].asUser.avatarUrl; } if (membership == Membership.invite && getState('m.room.member', client.userID) != null) { return getState('m.room.member', client.userID).sender.avatarUrl; } return null; } /// The address in the format: #roomname:homeserver.org. String get canonicalAlias => states['m.room.canonical_alias'] != null ? states['m.room.canonical_alias'].content['alias'] : ''; /// If this room is a direct chat, this is the matrix ID of the user. /// Returns null otherwise. String get directChatMatrixID { String returnUserId; if (client.directChats is Map) { client.directChats.forEach((String userId, dynamic roomIds) { if (roomIds is List) { for (var i = 0; i < roomIds.length; i++) { if (roomIds[i] == id) { returnUserId = userId; break; } } } }); } return returnUserId; } /// Wheither this is a direct chat or not bool get isDirectChat => directChatMatrixID != null; /// Must be one of [all, mention] String notificationSettings; Event get lastEvent { var lastSortOrder = -1e32; // this bound to be small enough var lastEvent = getState('m.room.message'); if (lastEvent == null) { states.forEach((final String key, final entry) { if (!entry.containsKey('')) return; final Event state = entry['']; if (state.sortOrder != null && state.sortOrder > lastSortOrder) { lastSortOrder = state.sortOrder; lastEvent = state; } }); } return lastEvent; } /// Returns a list of all current typing users. List get typingUsers { if (!ephemerals.containsKey('m.typing')) return []; List typingMxid = ephemerals['m.typing'].content['user_ids']; var typingUsers = []; for (var i = 0; i < typingMxid.length; i++) { typingUsers.add(getUserByMXIDSync(typingMxid[i])); } return typingUsers; } /// Your current client instance. final Client client; Room({ this.id, this.membership = Membership.join, this.notificationCount = 0, this.highlightCount = 0, this.prev_batch = '', this.client, this.notificationSettings, this.mHeroes = const [], this.mInvitedMemberCount = 0, this.mJoinedMemberCount = 0, this.roomAccountData = const {}, double newestSortOrder = 0.0, double oldestSortOrder = 0.0, }) : _newestSortOrder = newestSortOrder, _oldestSortOrder = oldestSortOrder; /// The default count of how much events should be requested when requesting the /// history of this room. static const int DefaultHistoryCount = 100; /// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and /// then generates a name from the heroes. String get displayname { if (name != null && name.isNotEmpty) return name; if (canonicalAlias != null && canonicalAlias.isNotEmpty && canonicalAlias.length > 3) { return canonicalAlias.localpart; } var heroes = []; if (mHeroes != null && mHeroes.isNotEmpty && mHeroes.any((h) => h.isNotEmpty)) { heroes = mHeroes; } else { if (states['m.room.member'] is Map) { for (var entry in states['m.room.member'].entries) { Event state = entry.value; if (state.type == EventTypes.RoomMember && state.stateKey != client?.userID) heroes.add(state.stateKey); } } } if (heroes.isNotEmpty) { var displayname = ''; for (var i = 0; i < heroes.length; i++) { if (heroes[i].isEmpty) continue; displayname += getUserByMXIDSync(heroes[i]).calcDisplayname() + ', '; } return displayname.substring(0, displayname.length - 2); } if (membership == Membership.invite && getState('m.room.member', client.userID) != null) { return getState('m.room.member', client.userID).sender.calcDisplayname(); } return 'Empty chat'; } /// The last message sent to this room. String get lastMessage { if (lastEvent != null) { return lastEvent.body; } else { return ''; } } /// When the last message received. DateTime get timeCreated { if (lastEvent != null) { return lastEvent.time; } return DateTime.now(); } /// Call the Matrix API to change the name of this room. Returns the event ID of the /// new m.room.name event. Future setName(String newName) async { final resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/${id}/state/m.room.name', data: {'name': newName}); return resp['event_id']; } /// Call the Matrix API to change the topic of this room. Future setDescription(String newName) async { final resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/${id}/state/m.room.topic', data: {'topic': newName}); return resp['event_id']; } /// return all current emote packs for this room Map> get emotePacks { final packs = >{}; final normalizeEmotePackName = (String name) { name = name.replaceAll(' ', '-'); name = name.replaceAll(RegExp(r'[^\w-]'), ''); return name.toLowerCase(); }; final addEmotePack = (String packName, Map content, [String packNameOverride]) { if (!(content['short'] is Map)) { return; } if (content['pack'] is Map && content['pack']['name'] is String) { packName = content['pack']['name']; } if (packNameOverride != null && packNameOverride.isNotEmpty) { packName = packNameOverride; } packName = normalizeEmotePackName(packName); if (!packs.containsKey(packName)) { packs[packName] = {}; } content['short'].forEach((key, value) { if (key is String && value is String && value.startsWith('mxc://')) { packs[packName][key] = value; } }); }; // first add all the room emotes final allRoomEmotes = states.states['im.ponies.room_emotes']; if (allRoomEmotes != null) { for (final entry in allRoomEmotes.entries) { final stateKey = entry.key; final event = entry.value; addEmotePack(stateKey.isEmpty ? 'room' : stateKey, event.content); } } // next add all the user emotes final userEmotes = client.accountData['im.ponies.user_emotes']; if (userEmotes != null) { addEmotePack('user', userEmotes.content); } // finally add all the external emote rooms final emoteRooms = client.accountData['im.ponies.emote_rooms']; if (emoteRooms != null && emoteRooms.content['rooms'] is Map) { for (final roomEntry in emoteRooms.content['rooms'].entries) { final roomId = roomEntry.key; if (roomId == id) { continue; } final room = client.getRoomById(roomId); if (room != null && roomEntry.value is Map) { for (final stateKeyEntry in roomEntry.value.entries) { final stateKey = stateKeyEntry.key; final event = room.getState('im.ponies.room_emotes', stateKey); if (event != null && stateKeyEntry.value is Map) { addEmotePack( room.canonicalAlias.isEmpty ? room.id : canonicalAlias, event.content, stateKeyEntry.value['name']); } } } } } return packs; } /// Sends a normal text message to this room. Returns the event ID generated /// by the server for this message. Future sendTextEvent(String message, {String txid, Event inReplyTo, bool parseMarkdown = true, Map> emotePacks}) { final event = { 'msgtype': 'm.text', 'body': message, }; if (message.startsWith('/me ')) { event['msgtype'] = 'm.emote'; event['body'] = message.substring(4); } if (parseMarkdown) { final html = markdown(event['body'], emotePacks ?? this.emotePacks); // if the decoded html is the same as the body, there is no need in sending a formatted message if (HtmlUnescape().convert(html) != event['body']) { event['format'] = 'org.matrix.custom.html'; event['formatted_body'] = html; } } return sendEvent(event, txid: txid, inReplyTo: inReplyTo); } /// Sends a [file] to this room after uploading it. The [msgType] is optional /// and will be detected by the mimetype of the file. Returns the mxc uri of /// the uploaded file. If [waitUntilSent] is true, the future will wait until /// the message event has received the server. Otherwise the future will only /// wait until the file has been uploaded. Future sendFileEvent( MatrixFile file, { String msgType, String txid, Event inReplyTo, Map info, bool waitUntilSent = false, MatrixFile thumbnail, }) async { Image fileImage; Image thumbnailImage; EncryptedFile encryptedThumbnail; String thumbnailUploadResp; var fileName = file.path.split('/').last; final mimeType = mime(file.path) ?? ''; if (msgType == null) { final metaType = (mimeType).split('/')[0]; switch (metaType) { case 'image': case 'audio': case 'video': msgType = 'm.$metaType'; break; default: msgType = 'm.file'; break; } } if (msgType == 'm.image') { fileImage = decodeImage(file.bytes.toList()); if (thumbnail != null) { thumbnailImage = decodeImage(thumbnail.bytes.toList()); } } final sendEncrypted = encrypted && client.fileEncryptionEnabled; EncryptedFile encryptedFile; if (sendEncrypted) { encryptedFile = await file.encrypt(); if (thumbnail != null) { encryptedThumbnail = await thumbnail.encrypt(); } } final uploadResp = await client.upload( file, contentType: sendEncrypted ? 'application/octet-stream' : null, ); if (thumbnail != null) { thumbnailUploadResp = await client.upload( thumbnail, contentType: sendEncrypted ? 'application/octet-stream' : null, ); } // Send event var content = { 'msgtype': msgType, 'body': fileName, 'filename': fileName, if (!sendEncrypted) 'url': uploadResp, if (sendEncrypted) 'file': { 'url': uploadResp, 'mimetype': mimeType, 'v': 'v2', 'key': { 'alg': 'A256CTR', 'ext': true, 'k': encryptedFile.k, 'key_ops': ['encrypt', 'decrypt'], 'kty': 'oct' }, 'iv': encryptedFile.iv, 'hashes': {'sha256': encryptedFile.sha256} }, 'info': info ?? { 'mimetype': mimeType, 'size': file.size, if (fileImage != null) 'h': fileImage.height, if (fileImage != null) 'w': fileImage.width, if (thumbnailUploadResp != null && !sendEncrypted) 'thumbnail_url': thumbnailUploadResp, if (thumbnailUploadResp != null && sendEncrypted) 'thumbnail_file': { 'url': thumbnailUploadResp, 'mimetype': mimeType, 'v': 'v2', 'key': { 'alg': 'A256CTR', 'ext': true, 'k': encryptedThumbnail.k, 'key_ops': ['encrypt', 'decrypt'], 'kty': 'oct' }, 'iv': encryptedThumbnail.iv, 'hashes': {'sha256': encryptedThumbnail.sha256} }, if (thumbnailImage != null) 'thumbnail_info': { 'h': thumbnailImage.height, 'mimetype': mimeType, 'size': thumbnail.size, 'w': thumbnailImage.width, } } }; final sendResponse = sendEvent( content, txid: txid, inReplyTo: inReplyTo, ); if (waitUntilSent) { await sendResponse; } return uploadResp; } /// Sends an audio file to this room and returns the mxc uri. Future sendAudioEvent(MatrixFile file, {String txid, Event inReplyTo}) async { return await sendFileEvent(file, msgType: 'm.audio', txid: txid, inReplyTo: inReplyTo); } /// Sends an image to this room and returns the mxc uri. Future sendImageEvent(MatrixFile file, {String txid, int width, int height, Event inReplyTo}) async { return await sendFileEvent(file, msgType: 'm.image', txid: txid, inReplyTo: inReplyTo, info: { 'size': file.size, 'mimetype': mime(file.path.split('/').last), 'w': width, 'h': height, }); } /// Sends an video to this room and returns the mxc uri. Future sendVideoEvent(MatrixFile file, {String txid, int videoWidth, int videoHeight, int duration, MatrixFile thumbnail, int thumbnailWidth, int thumbnailHeight, Event inReplyTo}) async { var fileName = file.path.split('/').last; var info = { 'size': file.size, 'mimetype': mime(fileName), }; if (videoWidth != null) { info['w'] = videoWidth; } if (thumbnailHeight != null) { info['h'] = thumbnailHeight; } if (duration != null) { info['duration'] = duration; } if (thumbnail != null && !(encrypted && client.encryptionEnabled)) { var thumbnailName = file.path.split('/').last; final thumbnailUploadResp = await client.upload(thumbnail); info['thumbnail_url'] = thumbnailUploadResp; info['thumbnail_info'] = { 'size': thumbnail.size, 'mimetype': mime(thumbnailName), }; if (thumbnailWidth != null) { info['thumbnail_info']['w'] = thumbnailWidth; } if (thumbnailHeight != null) { info['thumbnail_info']['h'] = thumbnailHeight; } } return await sendFileEvent( file, msgType: 'm.video', txid: txid, inReplyTo: inReplyTo, info: info, ); } /// Sends an event to this room with this json as a content. Returns the /// event ID generated from the server. Future sendEvent(Map content, {String type, String txid, Event inReplyTo}) async { type = type ?? 'm.room.message'; final sendType = (encrypted && client.encryptionEnabled) ? 'm.room.encrypted' : type; // Create new transaction id String messageID; final now = DateTime.now().millisecondsSinceEpoch; if (txid == null) { messageID = 'msg$now'; } else { messageID = txid; } if (inReplyTo != null) { var replyText = '<${inReplyTo.senderId}> ' + inReplyTo.body; var replyTextLines = replyText.split('\n'); for (var i = 0; i < replyTextLines.length; i++) { replyTextLines[i] = '> ' + replyTextLines[i]; } replyText = replyTextLines.join('\n'); content['format'] = 'org.matrix.custom.html'; content['formatted_body'] = '
In reply to ${inReplyTo.senderId}
${inReplyTo.body}
${content["formatted_body"] ?? content["body"]}'; content['body'] = replyText + "\n\n${content["body"] ?? ""}"; content['m.relates_to'] = { 'm.in_reply_to': { 'event_id': inReplyTo.eventId, }, }; } final sortOrder = newSortOrder; // Display a *sending* event and store it. var eventUpdate = EventUpdate( type: 'timeline', roomID: id, eventType: type, sortOrder: sortOrder, content: { 'type': type, 'event_id': messageID, 'sender': client.userID, 'status': 0, 'origin_server_ts': now, 'content': content }, ); client.onEvent.add(eventUpdate); await client.database?.transaction(() async { await client.database.storeEventUpdate(client.id, eventUpdate); await updateSortOrder(); }); // Send the text and on success, store and display a *sent* event. try { final response = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/${id}/send/$sendType/$messageID', data: encrypted && client.encryptionEnabled ? await encryptGroupMessagePayload(content, type: type) : content); final String res = response['event_id']; eventUpdate.content['status'] = 1; eventUpdate.content['unsigned'] = {'transaction_id': messageID}; eventUpdate.content['event_id'] = res; client.onEvent.add(eventUpdate); await client.database?.transaction(() async { await client.database.storeEventUpdate(client.id, eventUpdate); }); return res; } catch (exception) { print('[Client] Error while sending: ' + exception.toString()); // On error, set status to -1 eventUpdate.content['status'] = -1; eventUpdate.content['unsigned'] = {'transaction_id': messageID}; client.onEvent.add(eventUpdate); await client.database?.transaction(() async { await client.database.storeEventUpdate(client.id, eventUpdate); }); } return null; } /// Call the Matrix API to join this room if the user is not already a member. /// If this room is intended to be a direct chat, the direct chat flag will /// automatically be set. Future join() async { try { await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/join'); final invitation = getState('m.room.member', client.userID); if (invitation != null && invitation.content['is_direct'] is bool && invitation.content['is_direct']) { await addToDirectChat(invitation.sender.id); } } on MatrixException catch (exception) { if (exception.errorMessage == 'No known servers') { await client.database?.forgetRoom(client.id, id); client.onRoomUpdate.add( RoomUpdate( id: id, membership: Membership.leave, notification_count: 0, highlight_count: 0), ); } rethrow; } } /// Call the Matrix API to leave this room. If this room is set as a direct /// chat, this will be removed too. Future leave() async { if (directChatMatrixID != '') await removeFromDirectChat(); await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/leave'); return; } /// Call the Matrix API to forget this room if you already left it. Future forget() async { await client.database?.forgetRoom(client.id, id); await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/forget'); return; } /// Call the Matrix API to kick a user from this room. Future kick(String userID) async { await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/kick', data: {'user_id': userID}); return; } /// Call the Matrix API to ban a user from this room. Future ban(String userID) async { await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/ban', data: {'user_id': userID}); return; } /// Call the Matrix API to unban a banned user from this room. Future unban(String userID) async { await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/unban', data: {'user_id': userID}); return; } /// Set the power level of the user with the [userID] to the value [power]. /// Returns the event ID of the new state event. If there is no known /// power level event, there might something broken and this returns null. Future setPower(String userID, int power) async { if (states['m.room.power_levels'] == null) return null; var powerMap = {}..addAll(states['m.room.power_levels'].content); if (powerMap['users'] == null) powerMap['users'] = {}; powerMap['users'][userID] = power; final resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.power_levels', data: powerMap); return resp['event_id']; } /// Call the Matrix API to invite a user to this room. Future invite(String userID) async { await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/${id}/invite', data: {'user_id': userID}); return; } /// Request more previous events from the server. [historyCount] defines how much events should /// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before** /// the historical events will be published in the onEvent stream. Future requestHistory( {int historyCount = DefaultHistoryCount, onHistoryReceived}) async { final dynamic resp = await client.jsonRequest( type: HTTPType.GET, action: '/client/r0/rooms/$id/messages?from=${prev_batch}&dir=b&limit=$historyCount&filter=${Client.messagesFilters}'); if (onHistoryReceived != null) onHistoryReceived(); prev_batch = resp['end']; final dbActions = Function()>[]; if (client.database != null) { dbActions.add( () => client.database.setRoomPrevBatch(prev_batch, client.id, id)); } if (!(resp['chunk'] is List && resp['chunk'].length > 0 && resp['end'] is String)) return; if (resp['state'] is List) { for (final state in resp['state']) { var eventUpdate = EventUpdate( type: 'state', roomID: id, eventType: state['type'], content: state, sortOrder: oldSortOrder, ).decrypt(this); client.onEvent.add(eventUpdate); if (client.database != null) { dbActions.add( () => client.database.storeEventUpdate(client.id, eventUpdate)); } } } List history = resp['chunk']; for (final hist in history) { var eventUpdate = EventUpdate( type: 'history', roomID: id, eventType: hist['type'], content: hist, sortOrder: oldSortOrder, ).decrypt(this); client.onEvent.add(eventUpdate); if (client.database != null) { dbActions.add( () => client.database.storeEventUpdate(client.id, eventUpdate)); } } if (client.database != null) { dbActions.add( () => client.database.setRoomPrevBatch(resp['end'], client.id, id)); } await client.database?.transaction(() async { for (final f in dbActions) { await f(); } await updateSortOrder(); }); client.onRoomUpdate.add( RoomUpdate( id: id, membership: membership, prev_batch: resp['end'], notification_count: notificationCount, highlight_count: highlightCount, ), ); } /// Sets this room as a direct chat for this user if not already. Future addToDirectChat(String userID) async { var directChats = client.directChats; if (directChats.containsKey(userID)) { if (!directChats[userID].contains(id)) { directChats[userID].add(id); } else { return; } // Is already in direct chats } else { directChats[userID] = [id]; } await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/user/${client.userID}/account_data/m.direct', data: directChats); return; } /// Removes this room from all direct chat tags. Future removeFromDirectChat() async { var directChats = client.directChats; if (directChats.containsKey(directChatMatrixID) && directChats[directChatMatrixID].contains(id)) { directChats[directChatMatrixID].remove(id); } else { return; } // Nothing to do here await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/user/${client.userID}/account_data/m.direct', data: directChats); return; } /// Sends *m.fully_read* and *m.read* for the given event ID. Future sendReadReceipt(String eventID) async { notificationCount = 0; await client.database?.resetNotificationCount(client.id, id); await client.jsonRequest( type: HTTPType.POST, action: '/client/r0/rooms/$id/read_markers', data: { 'm.fully_read': eventID, 'm.read': eventID, }); return; } /// Returns a Room from a json String which comes normally from the store. If the /// state are also given, the method will await them. static Future getRoomFromTableRow( DbRoom row, // either Map or DbRoom Client matrix, { dynamic states, // DbRoomState, as iterator and optionally as future dynamic roomAccountData, // DbRoomAccountData, as iterator and optionally as future }) async { final newRoom = Room( id: row.roomId, membership: Membership.values .firstWhere((e) => e.toString() == 'Membership.' + row.membership), notificationCount: row.notificationCount, highlightCount: row.highlightCount, notificationSettings: 'mention', // TODO: do proper things prev_batch: row.prevBatch, mInvitedMemberCount: row.invitedMemberCount, mJoinedMemberCount: row.joinedMemberCount, mHeroes: row.heroes?.split(',') ?? [], client: matrix, roomAccountData: {}, newestSortOrder: row.newestSortOrder, oldestSortOrder: row.oldestSortOrder, ); if (states != null) { var rawStates; if (states is Future) { rawStates = await states; } else { rawStates = states; } for (final rawState in rawStates) { final newState = Event.fromDb(rawState, newRoom); ; newRoom.setState(newState); } } var newRoomAccountData = {}; if (roomAccountData != null) { var rawRoomAccountData; if (roomAccountData is Future) { rawRoomAccountData = await roomAccountData; } else { rawRoomAccountData = roomAccountData; } for (final singleAccountData in rawRoomAccountData) { final newData = RoomAccountData.fromDb(singleAccountData, newRoom); newRoomAccountData[newData.typeKey] = newData; } } newRoom.roomAccountData = newRoomAccountData; return newRoom; } /// Creates a timeline from the store. Returns a [Timeline] object. Future getTimeline( {onTimelineUpdateCallback onUpdate, onTimelineInsertCallback onInsert}) async { var events; if (client.database != null) { events = await client.database.getEventList(client.id, this); } else { events = []; } // Try again to decrypt encrypted events and update the database. if (encrypted && client.database != null) { await client.database.transaction(() async { for (var i = 0; i < events.length; i++) { if (events[i].type == EventTypes.Encrypted && events[i].content['body'] == DecryptError.UNKNOWN_SESSION) { await events[i].loadSession(); events[i] = events[i].decrypted; if (events[i].type != EventTypes.Encrypted) { await client.database.storeEventUpdate( client.id, EventUpdate( eventType: events[i].typeKey, content: events[i].toJson(), roomID: events[i].roomId, type: 'timeline', sortOrder: events[i].sortOrder, ), ); } } } }); } var timeline = Timeline( room: this, events: events, onUpdate: onUpdate, onInsert: onInsert, ); if (client.database == null) { prev_batch = ''; await requestHistory(historyCount: 10); } return timeline; } /// Returns all participants for this room. With lazy loading this /// list may not be complete. User [requestParticipants] in this /// case. List getParticipants() { var userList = []; if (states['m.room.member'] is Map) { for (var entry in states['m.room.member'].entries) { Event state = entry.value; if (state.type == EventTypes.RoomMember) userList.add(state.asUser); } } return userList; } /// Request the full list of participants from the server. The local list /// from the store is not complete if the client uses lazy loading. Future> requestParticipants() async { if (participantListComplete) return getParticipants(); var participants = []; dynamic res = await client.jsonRequest( type: HTTPType.GET, action: '/client/r0/rooms/${id}/members'); for (num i = 0; i < res['chunk'].length; i++) { var newUser = Event.fromJson(res['chunk'][i], this).asUser; if (![Membership.leave, Membership.ban].contains(newUser.membership)) { participants.add(newUser); setState(newUser); } } return participants; } /// Checks if the local participant list of joined and invited users is complete. bool get participantListComplete { var knownParticipants = getParticipants(); knownParticipants.removeWhere( (u) => ![Membership.join, Membership.invite].contains(u.membership)); return knownParticipants.length == (mJoinedMemberCount ?? 0) + (mInvitedMemberCount ?? 0); } /// Returns the [User] object for the given [mxID] or requests it from /// the homeserver and waits for a response. Future getUserByMXID(String mxID) async { if (states[mxID] != null) return states[mxID].asUser; return requestUser(mxID); } /// Returns the [User] object for the given [mxID] or requests it from /// the homeserver and returns a default [User] object while waiting. User getUserByMXIDSync(String mxID) { if (states[mxID] != null) { return states[mxID].asUser; } else { requestUser(mxID, ignoreErrors: true); return User(mxID, room: this); } } final Set _requestingMatrixIds = {}; /// Requests a missing [User] for this room. Important for clients using /// lazy loading. Future requestUser(String mxID, {bool ignoreErrors = false}) async { if (getState('m.room.member', mxID) != null) { return getState('m.room.member', mxID).asUser; } if (mxID == null || !_requestingMatrixIds.add(mxID)) return null; Map resp; try { resp = await client.jsonRequest( type: HTTPType.GET, action: '/client/r0/rooms/$id/state/m.room.member/$mxID'); } catch (exception) { _requestingMatrixIds.remove(mxID); if (!ignoreErrors) rethrow; } if (resp == null) { return null; } final user = User(mxID, displayName: resp['displayname'], avatarUrl: resp['avatar_url'], room: this); states[mxID] = user; await client.database?.transaction(() async { final content = { 'sender': mxID, 'type': 'm.room.member', 'content': resp, 'state_key': mxID, }; await client.database.storeEventUpdate( client.id, EventUpdate( content: content, roomID: id, type: 'state', eventType: 'm.room.member', sortOrder: 0.0), ); }); if (onUpdate != null) onUpdate.add(id); _requestingMatrixIds.remove(mxID); return user; } /// Searches for the event on the server. Returns null if not found. Future getEventById(String eventID) async { final dynamic resp = await client.jsonRequest( type: HTTPType.GET, action: '/client/r0/rooms/$id/event/$eventID'); return Event.fromJson(resp, this); } /// Returns the power level of the given user ID. int getPowerLevelByUserId(String userId) { var powerLevel = 0; Event powerLevelState = states['m.room.power_levels']; if (powerLevelState == null) return powerLevel; if (powerLevelState.content['users_default'] is int) { powerLevel = powerLevelState.content['users_default']; } if (powerLevelState.content['users'] is Map && powerLevelState.content['users'][userId] != null) { powerLevel = powerLevelState.content['users'][userId]; } return powerLevel; } /// Returns the user's own power level. int get ownPowerLevel => getPowerLevelByUserId(client.userID); /// Returns the power levels from all users for this room or null if not given. Map get powerLevels { Event powerLevelState = states['m.room.power_levels']; if (powerLevelState.content['users'] is Map) { return powerLevelState.content['users']; } return null; } /// Uploads a new user avatar for this room. Returns the event ID of the new /// m.room.avatar event. Future setAvatar(MatrixFile file) async { final uploadResp = await client.upload(file); final setAvatarResp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.avatar/', data: {'url': uploadResp}); return setAvatarResp['event_id']; } bool _hasPermissionFor(String action) { if (getState('m.room.power_levels') == null || getState('m.room.power_levels').content[action] == null) return true; return ownPowerLevel >= getState('m.room.power_levels').content[action]; } /// The level required to ban a user. bool get canBan => _hasPermissionFor('ban'); /// The default level required to send message events. Can be overridden by the events key. bool get canSendDefaultMessages => _hasPermissionFor('events_default'); /// The level required to invite a user. bool get canInvite => _hasPermissionFor('invite'); /// The level required to kick a user. bool get canKick => _hasPermissionFor('kick'); /// The level required to redact an event. bool get canRedact => _hasPermissionFor('redact'); /// The default level required to send state events. Can be overridden by the events key. bool get canSendDefaultStates => _hasPermissionFor('state_default'); bool get canChangePowerLevel => canSendEvent('m.room.power_levels'); bool canSendEvent(String eventType) { if (getState('m.room.power_levels') == null) return true; if (getState('m.room.power_levels').content['events'] == null || getState('m.room.power_levels').content['events'][eventType] == null) { return eventType == 'm.room.message' ? canSendDefaultMessages : canSendDefaultStates; } return ownPowerLevel >= getState('m.room.power_levels').content['events'][eventType]; } /// Returns the [PushRuleState] for this room, based on the m.push_rules stored in /// the account_data. PushRuleState get pushRuleState { if (!client.accountData.containsKey('m.push_rules') || !(client.accountData['m.push_rules'].content['global'] is Map)) { return PushRuleState.notify; } final Map globalPushRules = client.accountData['m.push_rules'].content['global']; if (globalPushRules == null) return PushRuleState.notify; if (globalPushRules['override'] is List) { for (var i = 0; i < globalPushRules['override'].length; i++) { if (globalPushRules['override'][i]['rule_id'] == id) { if (globalPushRules['override'][i]['actions'] .indexOf('dont_notify') != -1) { return PushRuleState.dont_notify; } break; } } } if (globalPushRules['room'] is List) { for (var i = 0; i < globalPushRules['room'].length; i++) { if (globalPushRules['room'][i]['rule_id'] == id) { if (globalPushRules['room'][i]['actions'].indexOf('dont_notify') != -1) { return PushRuleState.mentions_only; } break; } } } return PushRuleState.notify; } /// Sends a request to the homeserver to set the [PushRuleState] for this room. /// Returns ErrorResponse if something goes wrong. Future setPushRuleState(PushRuleState newState) async { if (newState == pushRuleState) return null; dynamic resp; switch (newState) { // All push notifications should be sent to the user case PushRuleState.notify: if (pushRuleState == PushRuleState.dont_notify) { resp = await client.jsonRequest( type: HTTPType.DELETE, action: '/client/r0/pushrules/global/override/$id', data: {}); } else if (pushRuleState == PushRuleState.mentions_only) { resp = await client.jsonRequest( type: HTTPType.DELETE, action: '/client/r0/pushrules/global/room/$id', data: {}); } break; // Only when someone mentions the user, a push notification should be sent case PushRuleState.mentions_only: if (pushRuleState == PushRuleState.dont_notify) { resp = await client.jsonRequest( type: HTTPType.DELETE, action: '/client/r0/pushrules/global/override/$id', data: {}); resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/pushrules/global/room/$id', data: { 'actions': ['dont_notify'] }); } else if (pushRuleState == PushRuleState.notify) { resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/pushrules/global/room/$id', data: { 'actions': ['dont_notify'] }); } break; // No push notification should be ever sent for this room. case PushRuleState.dont_notify: if (pushRuleState == PushRuleState.mentions_only) { resp = await client.jsonRequest( type: HTTPType.DELETE, action: '/client/r0/pushrules/global/room/$id', data: {}); } resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/pushrules/global/override/$id', data: { 'actions': ['dont_notify'], 'conditions': [ {'key': 'room_id', 'kind': 'event_match', 'pattern': id} ] }); } return resp; } /// Redacts this event. Returns [ErrorResponse] on error. Future redactEvent(String eventId, {String reason, String txid}) async { // Create new transaction id String messageID; final now = DateTime.now().millisecondsSinceEpoch; if (txid == null) { messageID = 'msg$now'; } else { messageID = txid; } var data = {}; if (reason != null) data['reason'] = reason; final dynamic resp = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/redact/$eventId/$messageID', data: data); return resp; } Future sendTypingInfo(bool isTyping, {int timeout}) { var data = { 'typing': isTyping, }; if (timeout != null) data['timeout'] = timeout; return client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/${id}/typing/${client.userID}', data: data, ); } /// This is sent by the caller when they wish to establish a call. /// [callId] is a unique identifier for the call. /// [version] is the version of the VoIP specification this message adheres to. This specification is version 0. /// [lifetime] is the time in milliseconds that the invite is valid for. Once the invite age exceeds this value, /// clients should discard it. They should also no longer show the call as awaiting an answer in the UI. /// [type] The type of session description. Must be 'offer'. /// [sdp] The SDP text of the session description. Future inviteToCall(String callId, int lifetime, String sdp, {String type = 'offer', int version = 0, String txid}) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; final response = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/send/m.call.invite/$txid', data: { 'call_id': callId, 'lifetime': lifetime, 'offer': {'sdp': sdp, 'type': type}, 'version': version, }, ); return response['event_id']; } /// This is sent by callers after sending an invite and by the callee after answering. /// Its purpose is to give the other party additional ICE candidates to try using to communicate. /// /// [callId] The ID of the call this event relates to. /// /// [version] The version of the VoIP specification this messages adheres to. This specification is version 0. /// /// [candidates] Array of objects describing the candidates. Example: /// /// ``` /// [ /// { /// "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0", /// "sdpMLineIndex": 0, /// "sdpMid": "audio" /// } /// ], /// ``` Future sendCallCandidates( String callId, List> candidates, { int version = 0, String txid, }) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; final response = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/send/m.call.candidates/$txid', data: { 'call_id': callId, 'candidates': candidates, 'version': version, }, ); return response['event_id']; } /// This event is sent by the callee when they wish to answer the call. /// [callId] is a unique identifier for the call. /// [version] is the version of the VoIP specification this message adheres to. This specification is version 0. /// [type] The type of session description. Must be 'answer'. /// [sdp] The SDP text of the session description. Future answerCall(String callId, String sdp, {String type = 'answer', int version = 0, String txid}) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; final response = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/send/m.call.answer/$txid', data: { 'call_id': callId, 'answer': {'sdp': sdp, 'type': type}, 'version': version, }, ); return response['event_id']; } /// This event is sent by the callee when they wish to answer the call. /// [callId] The ID of the call this event relates to. /// [version] is the version of the VoIP specification this message adheres to. This specification is version 0. Future hangupCall(String callId, {int version = 0, String txid}) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; final response = await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/send/m.call.hangup/$txid', data: { 'call_id': callId, 'version': version, }, ); return response['event_id']; } /// Returns all aliases for this room. List get aliases { var aliases = []; for (var aliasEvent in states.states['m.room.aliases'].values) { if (aliasEvent.content['aliases'] is List) { aliases.addAll(aliasEvent.content['aliases']); } } return aliases; } /// A room may be public meaning anyone can join the room without any prior action. Alternatively, /// it can be invite meaning that a user who wishes to join the room must first receive an invite /// to the room from someone already inside of the room. Currently, knock and private are reserved /// keywords which are not implemented. JoinRules get joinRules => getState('m.room.join_rules') != null ? JoinRules.values.firstWhere( (r) => r.toString().replaceAll('JoinRules.', '') == getState('m.room.join_rules').content['join_rule'], orElse: () => null) : null; /// Changes the join rules. You should check first if the user is able to change it. Future setJoinRules(JoinRules joinRules) async { await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.join_rules/', data: { 'join_rule': joinRules.toString().replaceAll('JoinRules.', ''), }, ); return; } /// Whether the user has the permission to change the join rules. bool get canChangeJoinRules => canSendEvent('m.room.join_rules'); /// This event controls whether guest users are allowed to join rooms. If this event /// is absent, servers should act as if it is present and has the guest_access value "forbidden". GuestAccess get guestAccess => getState('m.room.guest_access') != null ? GuestAccess.values.firstWhere( (r) => r.toString().replaceAll('GuestAccess.', '') == getState('m.room.guest_access').content['guest_access'], orElse: () => GuestAccess.forbidden) : GuestAccess.forbidden; /// Changes the guest access. You should check first if the user is able to change it. Future setGuestAccess(GuestAccess guestAccess) async { await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.guest_access/', data: { 'guest_access': guestAccess.toString().replaceAll('GuestAccess.', ''), }, ); return; } /// Whether the user has the permission to change the guest access. bool get canChangeGuestAccess => canSendEvent('m.room.guest_access'); /// This event controls whether a user can see the events that happened in a room from before they joined. HistoryVisibility get historyVisibility => getState('m.room.history_visibility') != null ? HistoryVisibility.values.firstWhere( (r) => r.toString().replaceAll('HistoryVisibility.', '') == getState('m.room.history_visibility') .content['history_visibility'], orElse: () => null) : null; /// Changes the history visibility. You should check first if the user is able to change it. Future setHistoryVisibility(HistoryVisibility historyVisibility) async { await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.history_visibility/', data: { 'history_visibility': historyVisibility.toString().replaceAll('HistoryVisibility.', ''), }, ); return; } /// Whether the user has the permission to change the history visibility. bool get canChangeHistoryVisibility => canSendEvent('m.room.history_visibility'); /// Returns the encryption algorithm. Currently only `m.megolm.v1.aes-sha2` is supported. /// Returns null if there is no encryption algorithm. String get encryptionAlgorithm => getState('m.room.encryption') != null ? getState('m.room.encryption').content['algorithm'].toString() : null; /// Checks if this room is encrypted. bool get encrypted => encryptionAlgorithm != null; Future enableEncryption({int algorithmIndex = 0}) async { if (encrypted) throw ('Encryption is already enabled!'); final algorithm = Client.supportedGroupEncryptionAlgorithms[algorithmIndex]; await client.jsonRequest( type: HTTPType.PUT, action: '/client/r0/rooms/$id/state/m.room.encryption/', data: { 'algorithm': algorithm, }, ); return; } /// Returns all known device keys for all participants in this room. Future> getUserDeviceKeys() async { var deviceKeys = []; var users = await requestParticipants(); for (final user in users) { if (client.userDeviceKeys.containsKey(user.id)) { for (var deviceKeyEntry in client.userDeviceKeys[user.id].deviceKeys.values) { deviceKeys.add(deviceKeyEntry); } } } return deviceKeys; } bool _restoredOutboundGroupSession = false; Future restoreOutboundGroupSession() async { if (_restoredOutboundGroupSession || client.database == null) { return; } final outboundSession = await client.database.getDbOutboundGroupSession(client.id, id); if (outboundSession != null) { try { _outboundGroupSession = olm.OutboundGroupSession(); _outboundGroupSession.unpickle(client.userID, outboundSession.pickle); _outboundGroupSessionDevices = List.from(json.decode(outboundSession.deviceIds)); _outboundGroupSessionCreationTime = outboundSession.creationTime; _outboundGroupSessionSentMessages = outboundSession.sentMessages; } catch (e) { _outboundGroupSession = null; _outboundGroupSessionDevices = null; print('[LibOlm] Unable to unpickle outboundGroupSession: ' + e.toString()); } } _restoredOutboundGroupSession = true; } /// Encrypts the given json payload and creates a send-ready m.room.encrypted /// payload. This will create a new outgoingGroupSession if necessary. Future> encryptGroupMessagePayload( Map payload, {String type = 'm.room.message'}) async { if (!encrypted || !client.encryptionEnabled) return payload; if (encryptionAlgorithm != 'm.megolm.v1.aes-sha2') { throw ('Unknown encryption algorithm'); } if (!_restoredOutboundGroupSession && client.database != null) { // try to restore an outbound group session from the database await restoreOutboundGroupSession(); } // and clear the outbound session, if it needs clearing await clearOutboundGroupSession(); // create a new one if none exists... if (_outboundGroupSession == null) { await createOutboundGroupSession(); } final Map mRelatesTo = payload.remove('m.relates_to'); final payloadContent = { 'content': payload, 'type': type, 'room_id': id, }; var encryptedPayload = { 'algorithm': 'm.megolm.v1.aes-sha2', 'ciphertext': _outboundGroupSession.encrypt(json.encode(payloadContent)), 'device_id': client.deviceID, 'sender_key': client.identityKey, 'session_id': _outboundGroupSession.session_id(), if (mRelatesTo != null) 'm.relates_to': mRelatesTo, }; _outboundGroupSessionSentMessages++; await _storeOutboundGroupSession(); return encryptedPayload; } final Set _requestedSessionIds = {}; Future requestSessionKey(String sessionId, String senderKey) async { final users = await requestParticipants(); await client.sendToDevice( [], 'm.room_key_request', { 'action': 'request_cancellation', 'request_id': base64.encode(utf8.encode(sessionId)), 'requesting_device_id': client.deviceID, }, encrypted: false, toUsers: users); await client.sendToDevice( [], 'm.room_key_request', { 'action': 'request', 'body': { 'algorithm': 'm.megolm.v1.aes-sha2', 'room_id': id, 'sender_key': senderKey, 'session_id': sessionId, }, 'request_id': base64.encode(utf8.encode(sessionId)), 'requesting_device_id': client.deviceID, }, encrypted: false, toUsers: users); } Future loadInboundGroupSessionKey(String sessionId, [String senderKey]) async { if (sessionId == null || inboundGroupSessions.containsKey(sessionId)) { return; } // nothing to do final session = await client.database .getDbInboundGroupSession(client.id, id, sessionId); if (session == null) { // no session found, let's request it! if (client.enableE2eeRecovery && !_requestedSessionIds.contains(sessionId) && senderKey != null) { unawaited(requestSessionKey(sessionId, senderKey)); _requestedSessionIds.add(sessionId); } return; } try { _inboundGroupSessions[sessionId] = SessionKey.fromDb(session, client.userID); } catch (e) { print('[LibOlm] Could not unpickle inboundGroupSession: ' + e.toString()); } } Future loadInboundGroupSessionKeyForEvent(Event event) async { if (client.database == null) return; // nothing to do, no database if (event.type != EventTypes.Encrypted) return; if (!client.encryptionEnabled) { throw (DecryptError.NOT_ENABLED); } if (event.content['algorithm'] != 'm.megolm.v1.aes-sha2') { throw (DecryptError.UNKNOWN_ALGORITHM); } final String sessionId = event.content['session_id']; return loadInboundGroupSessionKey(sessionId, event.content['sender_key']); } /// Decrypts the given [event] with one of the available ingoingGroupSessions. /// Returns a m.bad.encrypted event if it fails and does nothing if the event /// was not encrypted. Event decryptGroupMessage(Event event) { if (event.type != EventTypes.Encrypted || event.content['ciphertext'] == null) return event; Map decryptedPayload; try { if (!client.encryptionEnabled) { throw (DecryptError.NOT_ENABLED); } if (event.content['algorithm'] != 'm.megolm.v1.aes-sha2') { throw (DecryptError.UNKNOWN_ALGORITHM); } final String sessionId = event.content['session_id']; if (!inboundGroupSessions.containsKey(sessionId)) { throw (DecryptError.UNKNOWN_SESSION); } final decryptResult = inboundGroupSessions[sessionId] .inboundGroupSession .decrypt(event.content['ciphertext']); final messageIndexKey = event.eventId + event.time.millisecondsSinceEpoch.toString(); if (inboundGroupSessions[sessionId] .indexes .containsKey(messageIndexKey) && inboundGroupSessions[sessionId].indexes[messageIndexKey] != decryptResult.message_index) { if ((_outboundGroupSession?.session_id() ?? '') == sessionId) { clearOutboundGroupSession(); } throw (DecryptError.CHANNEL_CORRUPTED); } inboundGroupSessions[sessionId].indexes[messageIndexKey] = decryptResult.message_index; // now we persist the udpated indexes into the database. // the entry should always exist. In the case it doesn't, the following // line *could* throw an error. As that is a future, though, and we call // it un-awaited here, nothing happens, which is exactly the result we want client.database?.updateInboundGroupSessionIndexes( json.encode(inboundGroupSessions[sessionId].indexes), client.id, id, sessionId); decryptedPayload = json.decode(decryptResult.plaintext); } catch (exception) { // alright, if this was actually by our own outbound group session, we might as well clear it if (client.enableE2eeRecovery && (_outboundGroupSession?.session_id() ?? '') == event.content['session_id']) { clearOutboundGroupSession(wipe: true); } if (exception.toString() == DecryptError.UNKNOWN_SESSION) { decryptedPayload = { 'content': event.content, 'type': 'm.room.encrypted', }; decryptedPayload['content']['body'] = exception.toString(); decryptedPayload['content']['msgtype'] = 'm.bad.encrypted'; } else { decryptedPayload = { 'content': { 'msgtype': 'm.bad.encrypted', 'body': exception.toString(), }, 'type': 'm.room.encrypted', }; } } if (event.content['m.relates_to'] != null) { decryptedPayload['content']['m.relates_to'] = event.content['m.relates_to']; } return Event( content: decryptedPayload['content'], typeKey: decryptedPayload['type'], senderId: event.senderId, eventId: event.eventId, roomId: event.roomId, room: event.room, time: event.time, unsigned: event.unsigned, stateKey: event.stateKey, prevContent: event.prevContent, status: event.status, sortOrder: event.sortOrder, ); } } abstract class DecryptError { static const String NOT_ENABLED = 'Encryption is not enabled in your client.'; static const String UNKNOWN_ALGORITHM = 'Unknown encryption algorithm.'; static const String UNKNOWN_SESSION = 'The sender has not sent us the session key.'; static const String CHANNEL_CORRUPTED = 'The secure channel with the sender was corrupted.'; }