/* * 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 'dart:core'; import 'package:canonical_json/canonical_json.dart'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/src/account_data.dart'; import 'package:famedlysdk/src/presence.dart'; import 'package:famedlysdk/src/room.dart'; import 'package:famedlysdk/src/sync/user_update.dart'; import 'package:famedlysdk/src/utils/device_keys_list.dart'; import 'package:famedlysdk/src/utils/matrix_file.dart'; import 'package:famedlysdk/src/utils/open_id_credentials.dart'; import 'package:famedlysdk/src/utils/public_rooms_response.dart'; import 'package:famedlysdk/src/utils/room_key_request.dart'; import 'package:famedlysdk/src/utils/session_key.dart'; import 'package:famedlysdk/src/utils/to_device_event.dart'; import 'package:famedlysdk/src/utils/turn_server_credentials.dart'; import 'package:famedlysdk/src/utils/user_device.dart'; import 'package:http/http.dart' as http; import 'package:mime_type/mime_type.dart'; import 'package:olm/olm.dart' as olm; import 'package:pedantic/pedantic.dart'; import 'event.dart'; import 'room.dart'; import 'sync/event_update.dart'; import 'sync/room_update.dart'; import 'sync/user_update.dart'; import 'user.dart'; import 'utils/matrix_exception.dart'; import 'utils/profile.dart'; import 'database/database.dart' show Database; import 'utils/pusher.dart'; import 'utils/well_known_informations.dart'; import 'utils/key_verification.dart'; typedef RoomSorter = int Function(Room a, Room b); enum HTTPType { GET, POST, PUT, DELETE } enum LoginState { logged, loggedOut } /// Represents a Matrix client to communicate with a /// [Matrix](https://matrix.org) homeserver and is the entry point for this /// SDK. class Client { /// Handles the connection for this client. @deprecated Client get connection => this; int _id; int get id => _id; Database database; bool enableE2eeRecovery; /// Create a client /// clientName = unique identifier of this client /// debug: Print debug output? /// database: The database instance to use /// enableE2eeRecovery: Enable additional logic to try to recover from bad e2ee sessions Client(this.clientName, {this.debug = false, this.database, this.enableE2eeRecovery = false}) { onLoginStateChanged.stream.listen((loginState) { print('LoginState: ${loginState.toString()}'); }); } /// Whether debug prints should be displayed. final bool debug; /// The required name for this client. final String clientName; /// The homeserver this client is communicating with. String get homeserver => _homeserver; String _homeserver; /// The Matrix ID of the current logged user. String get userID => _userID; String _userID; /// This is the access token for the matrix client. When it is undefined, then /// the user needs to sign in first. String get accessToken => _accessToken; String _accessToken; /// This points to the position in the synchronization history. String prevBatch; /// The device ID is an unique identifier for this device. String get deviceID => _deviceID; String _deviceID; /// The device name is a human readable identifier for this device. String get deviceName => _deviceName; String _deviceName; /// Returns the current login state. bool isLogged() => accessToken != null; /// A list of all rooms the user is participating or invited. List get rooms => _rooms; List _rooms = []; olm.Account _olmAccount; /// Returns the base64 encoded keys to store them in a store. /// This String should **never** leave the device! String get pickledOlmAccount => encryptionEnabled ? _olmAccount.pickle(userID) : null; /// Whether this client supports end-to-end encryption using olm. bool get encryptionEnabled => _olmAccount != null; /// Whether this client is able to encrypt and decrypt files. bool get fileEncryptionEnabled => true; /// Warning! This endpoint is for testing only! set rooms(List newList) { print('Warning! This endpoint is for testing only!'); _rooms = newList; } /// Key/Value store of account data. Map accountData = {}; /// Presences of users by a given matrix ID Map presences = {}; int _timeoutFactor = 1; Room getRoomByAlias(String alias) { for (var i = 0; i < rooms.length; i++) { if (rooms[i].canonicalAlias == alias) return rooms[i]; } return null; } Room getRoomById(String id) { for (var j = 0; j < rooms.length; j++) { if (rooms[j].id == id) return rooms[j]; } return null; } void handleUserUpdate(UserUpdate userUpdate) { if (userUpdate.type == 'account_data') { var newAccountData = AccountData.fromJson(userUpdate.content); accountData[newAccountData.typeKey] = newAccountData; if (onAccountData != null) onAccountData.add(newAccountData); } if (userUpdate.type == 'presence') { var newPresence = Presence.fromJson(userUpdate.content); presences[newPresence.sender] = newPresence; if (onPresence != null) onPresence.add(newPresence); } } Map get directChats => accountData['m.direct'] != null ? accountData['m.direct'].content : {}; /// Returns the (first) room ID from the store which is a private chat with the user [userId]. /// Returns null if there is none. String getDirectChatFromUserId(String userId) { if (accountData['m.direct'] != null && accountData['m.direct'].content[userId] is List && accountData['m.direct'].content[userId].length > 0) { if (getRoomById(accountData['m.direct'].content[userId][0]) != null) { return accountData['m.direct'].content[userId][0]; } (accountData['m.direct'].content[userId] as List) .remove(accountData['m.direct'].content[userId][0]); jsonRequest( type: HTTPType.PUT, action: '/client/r0/user/${userID}/account_data/m.direct', data: directChats); return getDirectChatFromUserId(userId); } for (var i = 0; i < rooms.length; i++) { if (rooms[i].membership == Membership.invite && rooms[i].states[userID]?.senderId == userId && rooms[i].states[userID].content['is_direct'] == true) { return rooms[i].id; } } return null; } /// Gets discovery information about the domain. The file may include additional keys. Future getWellKnownInformationsByUserId( String MatrixIdOrDomain, ) async { final response = await http .get('https://${MatrixIdOrDomain.domain}/.well-known/matrix/client'); final rawJson = json.decode(response.body); return WellKnownInformations.fromJson(rawJson); } /// Checks the supported versions of the Matrix protocol and the supported /// login types. Returns false if the server is not compatible with the /// client. /// Throws FormatException, TimeoutException and MatrixException on error. Future checkServer(serverUrl) async { try { _homeserver = serverUrl; final versionResp = await jsonRequest(type: HTTPType.GET, action: '/client/versions'); final versions = List.from(versionResp['versions']); for (var i = 0; i < versions.length; i++) { if (versions[i] == 'r0.5.0' || versions[i] == 'r0.6.0') { break; } else if (i == versions.length - 1) { return false; } } final loginResp = await jsonRequest(type: HTTPType.GET, action: '/client/r0/login'); final List flows = loginResp['flows']; for (var i = 0; i < flows.length; i++) { if (flows[i].containsKey('type') && flows[i]['type'] == 'm.login.password') { break; } else if (i == flows.length - 1) { return false; } } return true; } catch (_) { _homeserver = null; rethrow; } } /// Checks to see if a username is available, and valid, for the server. /// You have to call [checkServer] first to set a homeserver. Future usernameAvailable(String username) async { final response = await jsonRequest( type: HTTPType.GET, action: '/client/r0/register/available?username=$username', ); return response['available']; } /// Checks to see if a username is available, and valid, for the server. /// Returns the fully-qualified Matrix user ID (MXID) that has been registered. /// You have to call [checkServer] first to set a homeserver. Future> register({ String kind, String username, String password, Map auth, String deviceId, String initialDeviceDisplayName, bool inhibitLogin, }) async { final action = '/client/r0/register' + (kind != null ? '?kind=$kind' : ''); var data = {}; if (username != null) data['username'] = username; if (password != null) data['password'] = password; if (auth != null) data['auth'] = auth; if (deviceId != null) data['device_id'] = deviceId; if (initialDeviceDisplayName != null) { data['initial_device_display_name'] = initialDeviceDisplayName; } if (inhibitLogin != null) data['inhibit_login'] = inhibitLogin; final response = await jsonRequest(type: HTTPType.POST, action: action, data: data); // Connect if there is an access token in the response. if (response.containsKey('access_token') && response.containsKey('device_id') && response.containsKey('user_id')) { await connect( newToken: response['access_token'], newUserID: response['user_id'], newHomeserver: homeserver, newDeviceName: initialDeviceDisplayName ?? '', newDeviceID: response['device_id']); } return response; } /// Handles the login and allows the client to call all APIs which require /// authentication. Returns false if the login was not successful. Throws /// MatrixException if login was not successful. /// You have to call [checkServer] first to set a homeserver. Future login( String username, String password, { String initialDeviceDisplayName, String deviceId, }) async { var data = { 'type': 'm.login.password', 'user': username, 'identifier': { 'type': 'm.id.user', 'user': username, }, 'password': password, }; if (deviceId != null) data['device_id'] = deviceId; if (initialDeviceDisplayName != null) { data['initial_device_display_name'] = initialDeviceDisplayName; } final loginResp = await jsonRequest( type: HTTPType.POST, action: '/client/r0/login', data: data); if (loginResp.containsKey('user_id') && loginResp.containsKey('access_token') && loginResp.containsKey('device_id')) { await connect( newToken: loginResp['access_token'], newUserID: loginResp['user_id'], newHomeserver: homeserver, newDeviceName: initialDeviceDisplayName ?? '', newDeviceID: loginResp['device_id'], ); return true; } return false; } /// Sends a logout command to the homeserver and clears all local data, /// including all persistent data from the store. Future logout() async { try { await jsonRequest(type: HTTPType.POST, action: '/client/r0/logout'); } catch (exception) { rethrow; } finally { await clear(); } } /// Returns the user's own displayname and avatar url. In Matrix it is possible that /// one user can have different displaynames and avatar urls in different rooms. So /// this endpoint first checks if the profile is the same in all rooms. If not, the /// profile will be requested from the homserver. Future get ownProfile async { if (rooms.isNotEmpty) { var profileSet = {}; for (var room in rooms) { final user = room.getUserByMXIDSync(userID); profileSet.add(Profile.fromJson(user.content)); } if (profileSet.length == 1) return profileSet.first; } return getProfileFromUserId(userID); } final Map _profileCache = {}; /// Get the combined profile information for this user. /// If [getFromRooms] is true then the profile will first be searched from the /// room memberships. This is unstable if the given user makes use of different displaynames /// and avatars per room, which is common for some bots and bridges. /// If [cache] is true then /// the profile get cached for this session. Please note that then the profile may /// become outdated if the user changes the displayname or avatar in this session. Future getProfileFromUserId(String userId, {bool cache = true, bool getFromRooms = true}) async { if (getFromRooms) { final room = rooms.firstWhere( (Room room) => room .getParticipants() .indexWhere((User user) => user.id == userId) != -1, orElse: () => null); if (room != null) { final user = room.getParticipants().firstWhere((User user) => user.id == userId); return Profile(user.displayName, user.avatarUrl); } } if (cache && _profileCache.containsKey(userId)) { return _profileCache[userId]; } final resp = await jsonRequest( type: HTTPType.GET, action: '/client/r0/profile/${userId}'); final profile = Profile.fromJson(resp); _profileCache[userId] = profile; return profile; } Future> get archive async { var archiveList = []; var syncFilters = '{"room":{"include_leave":true,"timeline":{"limit":10}}}'; var action = '/client/r0/sync?filter=$syncFilters&timeout=0'; final sync = await jsonRequest(type: HTTPType.GET, action: action); if (sync['rooms']['leave'] is Map) { for (var entry in sync['rooms']['leave'].entries) { final String id = entry.key; final dynamic room = entry.value; var leftRoom = Room( id: id, membership: Membership.leave, client: this, roomAccountData: {}, mHeroes: []); if (room['account_data'] is Map && room['account_data']['events'] is List) { for (dynamic event in room['account_data']['events']) { leftRoom.roomAccountData[event['type']] = RoomAccountData.fromJson(event, leftRoom); } } if (room['timeline'] is Map && room['timeline']['events'] is List) { for (dynamic event in room['timeline']['events']) { leftRoom.setState(Event.fromJson(event, leftRoom)); } } if (room['state'] is Map && room['state']['events'] is List) { for (dynamic event in room['state']['events']) { leftRoom.setState(Event.fromJson(event, leftRoom)); } } archiveList.add(leftRoom); } } return archiveList; } /// This API starts a user participating in a particular room, if that user is allowed to participate in that room. /// After this call, the client is allowed to see all current state events in the room, and all subsequent events /// associated with the room until the user leaves the room. Future joinRoomById(String roomIdOrAlias) async { return await jsonRequest( type: HTTPType.POST, action: '/client/r0/join/$roomIdOrAlias'); } /// Loads the contact list for this user excluding the user itself. /// Currently the contacts are found by discovering the contacts of /// the famedlyContactDiscovery room, which is /// defined by the autojoin room feature in Synapse. Future> loadFamedlyContacts() async { var contacts = []; var contactDiscoveryRoom = getRoomByAlias('#famedlyContactDiscovery:${userID.domain}'); if (contactDiscoveryRoom != null) { contacts = await contactDiscoveryRoom.requestParticipants(); } else { var userMap = {}; for (var i = 0; i < rooms.length; i++) { var roomUsers = rooms[i].getParticipants(); for (var j = 0; j < roomUsers.length; j++) { if (userMap[roomUsers[j].id] != true) contacts.add(roomUsers[j]); userMap[roomUsers[j].id] = true; } } } return contacts; } @Deprecated('Please use [createRoom] instead!') Future createGroup(List users) => createRoom(invite: users); /// Creates a new group chat and invites the given Users and returns the new /// created room ID. If [params] are provided, invite will be ignored. For the /// moment please look at https://matrix.org/docs/spec/client_server/r0.5.0#post-matrix-client-r0-createroom /// to configure [params]. Future createRoom( {List invite, Map params}) async { var inviteIDs = []; if (params == null && invite != null) { for (var i = 0; i < invite.length; i++) { inviteIDs.add(invite[i].id); } } try { final dynamic resp = await jsonRequest( type: HTTPType.POST, action: '/client/r0/createRoom', data: params ?? { 'invite': inviteIDs, }); return resp['room_id']; } catch (e) { rethrow; } } /// Changes the user's displayname. Future setDisplayname(String displayname) async { await jsonRequest( type: HTTPType.PUT, action: '/client/r0/profile/$userID/displayname', data: {'displayname': displayname}); return; } /// Uploads a new user avatar for this user. Future setAvatar(MatrixFile file) async { final uploadResp = await upload(file); await jsonRequest( type: HTTPType.PUT, action: '/client/r0/profile/$userID/avatar_url', data: {'avatar_url': uploadResp}); return; } /// Get credentials for the client to use when initiating calls. Future getTurnServerCredentials() async { final response = await jsonRequest( type: HTTPType.GET, action: '/client/r0/voip/turnServer', ); return TurnServerCredentials.fromJson(response); } /// Fetches the pushrules for the logged in user. /// These are needed for notifications on Android @Deprecated('Use [pushRules] instead.') Future getPushrules() async { final dynamic resp = await jsonRequest( type: HTTPType.GET, action: '/client/r0/pushrules/', ); return PushRules.fromJson(resp); } /// Returns the push rules for the logged in user. PushRules get pushRules => accountData.containsKey('m.push_rules') ? PushRules.fromJson(accountData['m.push_rules'].content) : null; /// Gets all currently active pushers for the authenticated user. Future> getPushers() async { final response = await jsonRequest(type: HTTPType.GET, action: '/client/r0/pushers'); var list = []; for (final pusherJson in response['pushers']) { list.add(Pusher.fromJson(pusherJson)); } return list; } /// This endpoint allows the creation, modification and deletion of pushers for this user ID. Future setPushers(String pushKey, String kind, String appId, String appDisplayName, String deviceDisplayName, String lang, String url, {bool append, String profileTag, String format}) async { var data = { 'lang': lang, 'kind': kind, 'app_display_name': appDisplayName, 'device_display_name': deviceDisplayName, 'profile_tag': profileTag, 'app_id': appId, 'pushkey': pushKey, 'data': {'url': url} }; if (format != null) data['data']['format'] = format; if (profileTag != null) data['profile_tag'] = profileTag; if (append != null) data['append'] = append; await jsonRequest( type: HTTPType.POST, action: '/client/r0/pushers/set', data: data, ); return; } static String syncFilters = '{"room":{"state":{"lazy_load_members":true}}}'; static String messagesFilters = '{"lazy_load_members":true}'; static const List supportedDirectEncryptionAlgorithms = [ 'm.olm.v1.curve25519-aes-sha2' ]; static const List supportedGroupEncryptionAlgorithms = [ 'm.megolm.v1.aes-sha2' ]; static const int defaultThumbnailSize = 256; http.Client httpClient = http.Client(); /// The newEvent signal is the most important signal in this concept. Every time /// the app receives a new synchronization, this event is called for every signal /// to update the GUI. For example, for a new message, it is called: /// onRoomEvent( "m.room.message", "!chat_id:server.com", "timeline", {sender: "@bob:server.com", body: "Hello world"} ) final StreamController onEvent = StreamController.broadcast(); /// Outside of the events there are updates for the global chat states which /// are handled by this signal: final StreamController onRoomUpdate = StreamController.broadcast(); /// Outside of rooms there are account updates like account_data or presences. final StreamController onUserEvent = StreamController.broadcast(); /// The onToDeviceEvent is called when there comes a new to device event. It is /// already decrypted if necessary. final StreamController onToDeviceEvent = StreamController.broadcast(); /// Called when the login state e.g. user gets logged out. final StreamController onLoginStateChanged = StreamController.broadcast(); /// Synchronization erros are coming here. final StreamController onError = StreamController.broadcast(); /// This is called once, when the first sync has received. final StreamController onFirstSync = StreamController.broadcast(); /// When a new sync response is coming in, this gives the complete payload. final StreamController onSync = StreamController.broadcast(); /// Callback will be called on presences. final StreamController onPresence = StreamController.broadcast(); /// Callback will be called on account data updates. final StreamController onAccountData = StreamController.broadcast(); /// Will be called on call invites. final StreamController onCallInvite = StreamController.broadcast(); /// Will be called on call hangups. final StreamController onCallHangup = StreamController.broadcast(); /// Will be called on call candidates. final StreamController onCallCandidates = StreamController.broadcast(); /// Will be called on call answers. final StreamController onCallAnswer = StreamController.broadcast(); /// Will be called when another device is requesting session keys for a room. final StreamController onRoomKeyRequest = StreamController.broadcast(); /// Will be called when another device is requesting verification with this device. final StreamController onKeyVerificationRequest = StreamController.broadcast(); final Map _keyVerificationRequests = {}; /// Matrix synchronisation is done with https long polling. This needs a /// timeout which is usually 30 seconds. int syncTimeoutSec = 30; /// How long should the app wait until it retrys the synchronisation after /// an error? int syncErrorTimeoutSec = 3; /// Sets the user credentials and starts the synchronisation. /// /// Before you can connect you need at least an [accessToken], a [homeserver], /// a [userID], a [deviceID], and a [deviceName]. /// /// You get this informations /// by logging in to your Matrix account, using the [login API](https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-login). /// /// To log in you can use [jsonRequest()] after you have set the [homeserver] /// to a valid url. For example: /// /// ``` /// final resp = await matrix /// .jsonRequest(type: HTTPType.POST, action: "/client/r0/login", data: { /// "type": "m.login.password", /// "user": "test", /// "password": "1234", /// "initial_device_display_name": "Fluffy Matrix Client" /// }); /// ``` /// /// Returns: /// /// ``` /// { /// "user_id": "@cheeky_monkey:matrix.org", /// "access_token": "abc123", /// "device_id": "GHTYAJCE" /// } /// ``` /// /// Sends [LoginState.logged] to [onLoginStateChanged]. void connect({ String newToken, String newHomeserver, String newUserID, String newDeviceName, String newDeviceID, String newPrevBatch, String newOlmAccount, }) async { String olmAccount; if (database != null) { final account = await database.getClient(clientName); if (account != null) { _id = account.clientId; _homeserver = account.homeserverUrl; _accessToken = account.token; _userID = account.userId; _deviceID = account.deviceId; _deviceName = account.deviceName; prevBatch = account.prevBatch; olmAccount = account.olmAccount; } } _accessToken = newToken ?? _accessToken; _homeserver = newHomeserver ?? _homeserver; _userID = newUserID ?? _userID; _deviceID = newDeviceID ?? _deviceID; _deviceName = newDeviceName ?? _deviceName; prevBatch = newPrevBatch ?? prevBatch; olmAccount = newOlmAccount ?? olmAccount; if (_accessToken == null || _homeserver == null || _userID == null) { // we aren't logged in onLoginStateChanged.add(LoginState.loggedOut); return; } // Try to create a new olm account or restore a previous one. if (olmAccount == null) { try { await olm.init(); _olmAccount = olm.Account(); _olmAccount.create(); if (await _uploadKeys(uploadDeviceKeys: true) == false) { throw ('Upload key failed'); } } catch (_) { _olmAccount = null; } } else { try { await olm.init(); _olmAccount = olm.Account(); _olmAccount.unpickle(userID, olmAccount); } catch (_) { _olmAccount = null; } } if (database != null) { if (id != null) { await database.updateClient( _homeserver, _accessToken, _userID, _deviceID, _deviceName, prevBatch, pickledOlmAccount, id, ); } else { _id = await database.insertClient( clientName, _homeserver, _accessToken, _userID, _deviceID, _deviceName, prevBatch, pickledOlmAccount, ); } _userDeviceKeys = await database.getUserDeviceKeys(id); _olmSessions = await database.getOlmSessions(id, _userID); _rooms = await database.getRoomList(this, onlyLeft: false); _sortRooms(); accountData = await database.getAccountData(id); presences = await database.getPresences(id); } _userEventSub ??= onUserEvent.stream.listen(handleUserUpdate); onLoginStateChanged.add(LoginState.logged); return _sync(); } StreamSubscription _userEventSub; /// Resets all settings and stops the synchronisation. void clear() { olmSessions.values.forEach((List sessions) { sessions.forEach((olm.Session session) => session?.free()); }); rooms.forEach((Room room) { room.clearOutboundGroupSession(wipe: true); room.inboundGroupSessions.values.forEach((SessionKey sessionKey) { sessionKey.inboundGroupSession?.free(); }); }); _olmAccount?.free(); database?.clear(id); _id = _accessToken = _homeserver = _userID = _deviceID = _deviceName = prevBatch = null; _rooms = []; onLoginStateChanged.add(LoginState.loggedOut); } /// Used for all Matrix json requests using the [c2s API](https://matrix.org/docs/spec/client_server/r0.4.0.html). /// /// Throws: TimeoutException, FormatException, MatrixException /// /// You must first call [this.connect()] or set [this.homeserver] before you can use /// this! For example to send a message to a Matrix room with the id /// '!fjd823j:example.com' you call: /// /// ``` /// final resp = await jsonRequest( /// type: HTTPType.PUT, /// action: "/r0/rooms/!fjd823j:example.com/send/m.room.message/$txnId", /// data: { /// "msgtype": "m.text", /// "body": "hello" /// } /// ); /// ``` /// Future> jsonRequest( {HTTPType type, String action, dynamic data = '', int timeout, String contentType = 'application/json'}) async { if (isLogged() == false && homeserver == null) { throw ('No homeserver specified.'); } timeout ??= (_timeoutFactor * syncTimeoutSec) + 5; dynamic json; if (data is Map) data.removeWhere((k, v) => v == null); (!(data is String)) ? json = jsonEncode(data) : json = data; if (data is List || action.startsWith('/media/r0/upload')) json = data; final url = '${homeserver}/_matrix${action}'; var headers = {}; if (type == HTTPType.PUT || type == HTTPType.POST) { headers['Content-Type'] = contentType; } if (isLogged()) { headers['Authorization'] = 'Bearer ${accessToken}'; } if (debug) { print( "[REQUEST ${type.toString().split('.').last}] Action: $action, Data: ${jsonEncode(data)}"); } http.Response resp; var jsonResp = {}; try { switch (type.toString().split('.').last) { case 'GET': resp = await httpClient.get(url, headers: headers).timeout( Duration(seconds: timeout), onTimeout: () => null, ); break; case 'POST': resp = await httpClient.post(url, body: json, headers: headers).timeout( Duration(seconds: timeout), onTimeout: () => null, ); break; case 'PUT': resp = await httpClient.put(url, body: json, headers: headers).timeout( Duration(seconds: timeout), onTimeout: () => null, ); break; case 'DELETE': resp = await httpClient.delete(url, headers: headers).timeout( Duration(seconds: timeout), onTimeout: () => null, ); break; } if (resp == null) { throw TimeoutException; } jsonResp = jsonDecode(String.fromCharCodes(resp.body.runes)) as Map; // May throw FormatException if (resp.statusCode >= 400 && resp.statusCode < 500) { // The server has responsed with an matrix related error. var exception = MatrixException(resp); if (exception.error == MatrixError.M_UNKNOWN_TOKEN) { // The token is no longer valid. Need to sign off.... // TODO: add a way to export keys prior logout? onError.add(exception); clear(); } throw exception; } if (debug) print('[RESPONSE] ${jsonResp.toString()}'); } on ArgumentError catch (exception) { print(exception); // Ignore this error } on TimeoutException catch (_) { _timeoutFactor *= 2; rethrow; } catch (_) { print(_); rethrow; } return jsonResp; } /// Uploads a file with the name [fileName] as base64 encoded to the server /// and returns the mxc url as a string. Future upload(MatrixFile file, {String contentType}) async { // For testing if (homeserver.toLowerCase() == 'https://fakeserver.notexisting') { return 'mxc://example.com/AQwafuaFswefuhsfAFAgsw'; } var headers = {}; headers['Authorization'] = 'Bearer $accessToken'; headers['Content-Type'] = contentType ?? mime(file.path); var fileName = Uri.encodeFull(file.path.split('/').last.toLowerCase()); final url = '$homeserver/_matrix/media/r0/upload?filename=$fileName'; final streamedRequest = http.StreamedRequest('POST', Uri.parse(url)) ..headers.addAll(headers); streamedRequest.contentLength = await file.bytes.length; streamedRequest.sink.add(file.bytes); streamedRequest.sink.close(); print('[UPLOADING] $fileName'); var streamedResponse = await streamedRequest.send(); Map jsonResponse = json.decode( String.fromCharCodes(await streamedResponse.stream.first), ); if (!(jsonResponse['content_uri'] is String && jsonResponse['content_uri'].isNotEmpty)) { throw ("Missing json key: 'content_uri' ${jsonResponse.toString()}"); } return jsonResponse['content_uri']; } Future _syncRequest; Future _sync() async { if (isLogged() == false || _disposed) return; var action = '/client/r0/sync?filter=$syncFilters'; if (prevBatch != null) { action += '&timeout=30000'; action += '&since=${prevBatch}'; } try { _syncRequest = jsonRequest(type: HTTPType.GET, action: action); if (_disposed) return; final hash = _syncRequest.hashCode; final syncResp = await _syncRequest; if (hash != _syncRequest.hashCode) return; _timeoutFactor = 1; if (database != null) { await database.transaction(() async { await handleSync(syncResp); if (prevBatch != syncResp['next_batch']) { await database.storePrevBatch(syncResp['next_batch'], id); } }); } else { await handleSync(syncResp); } if (_disposed) return; if (prevBatch == null) { onFirstSync.add(true); prevBatch = syncResp['next_batch']; _sortRooms(); } prevBatch = syncResp['next_batch']; await _updateUserDeviceKeys(); _cleanupKeyVerificationRequests(); if (hash == _syncRequest.hashCode) unawaited(_sync()); } on MatrixException catch (exception) { onError.add(exception); await Future.delayed(Duration(seconds: syncErrorTimeoutSec), _sync); } catch (exception) { print('Error during processing events: ' + exception.toString()); await Future.delayed(Duration(seconds: syncErrorTimeoutSec), _sync); } } /// Use this method only for testing utilities! Future handleSync(dynamic sync) async { if (sync['to_device'] is Map && sync['to_device']['events'] is List) { _handleToDeviceEvents(sync['to_device']['events']); } if (sync['rooms'] is Map) { if (sync['rooms']['join'] is Map) { await _handleRooms(sync['rooms']['join'], Membership.join); } if (sync['rooms']['invite'] is Map) { await _handleRooms(sync['rooms']['invite'], Membership.invite); } if (sync['rooms']['leave'] is Map) { await _handleRooms(sync['rooms']['leave'], Membership.leave); } } if (sync['presence'] is Map && sync['presence']['events'] is List) { await _handleGlobalEvents(sync['presence']['events'], 'presence'); } if (sync['account_data'] is Map && sync['account_data']['events'] is List) { await _handleGlobalEvents(sync['account_data']['events'], 'account_data'); } if (sync['device_lists'] is Map) { await _handleDeviceListsEvents(sync['device_lists']); } if (sync['device_one_time_keys_count'] is Map) { _handleDeviceOneTimeKeysCount(sync['device_one_time_keys_count']); } while (_pendingToDeviceEvents.isNotEmpty) { _updateRoomsByToDeviceEvent( _pendingToDeviceEvents.removeLast(), addToPendingIfNotFound: false, ); } onSync.add(sync); } void _handleDeviceOneTimeKeysCount( Map deviceOneTimeKeysCount) { if (!encryptionEnabled) return; // Check if there are at least half of max_number_of_one_time_keys left on the server // and generate and upload more if not. if (deviceOneTimeKeysCount['signed_curve25519'] is int) { final int oneTimeKeysCount = deviceOneTimeKeysCount['signed_curve25519']; if (oneTimeKeysCount < (_olmAccount.max_number_of_one_time_keys() / 2)) { // Generate and upload more one time keys: _uploadKeys(); } } } Future _handleDeviceListsEvents( Map deviceLists) async { if (deviceLists['changed'] is List) { for (final userId in deviceLists['changed']) { if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys[userId].outdated = true; if (database != null) { await database.storeUserDeviceKeysInfo(id, userId, true); } } } for (final userId in deviceLists['left']) { if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys.remove(userId); } } } } void _cleanupKeyVerificationRequests() { for (final entry in _keyVerificationRequests.entries) { (() async { var dispose = entry.value.canceled || entry.value.state == KeyVerificationState.done || entry.value.state == KeyVerificationState.error; if (!dispose) { dispose = !(await entry.value.verifyActivity()); } if (dispose) { entry.value.dispose(); _keyVerificationRequests.remove(entry.key); } })(); } } void addKeyVerificationRequest(KeyVerification request) { if (request.transactionId == null) { return; } _keyVerificationRequests[request.transactionId] = request; } void _handleToDeviceEvents(List events) { for (var i = 0; i < events.length; i++) { var isValid = events[i] is Map && events[i]['type'] is String && events[i]['sender'] is String && events[i]['content'] is Map; if (!isValid) { print('[Sync] Invalid To Device Event! ${events[i]}'); continue; } var toDeviceEvent = ToDeviceEvent.fromJson(events[i]); if (toDeviceEvent.type == 'm.room.encrypted') { try { toDeviceEvent = decryptToDeviceEvent(toDeviceEvent); } catch (e) { print( '[LibOlm] Could not decrypt to device event from ${toDeviceEvent.sender}: ' + e.toString()); print(toDeviceEvent.sender); toDeviceEvent = ToDeviceEvent.fromJson(events[i]); } } _updateRoomsByToDeviceEvent(toDeviceEvent); if (toDeviceEvent.type.startsWith('m.key.verification.')) { _handleToDeviceKeyVerificationRequest(toDeviceEvent); } onToDeviceEvent.add(toDeviceEvent); } } void _handleToDeviceKeyVerificationRequest(ToDeviceEvent toDeviceEvent) { if (!toDeviceEvent.type.startsWith('m.key.verification.')) { return; } // we have key verification going on! final transactionId = KeyVerification.getTransactionId(toDeviceEvent.content); if (transactionId != null) { if (_keyVerificationRequests.containsKey(transactionId)) { _keyVerificationRequests[transactionId] .handlePayload(toDeviceEvent.type, toDeviceEvent.content); } else { final newKeyRequest = KeyVerification(client: this, userId: toDeviceEvent.sender); newKeyRequest .handlePayload(toDeviceEvent.type, toDeviceEvent.content) .then((res) { if (newKeyRequest.state != KeyVerificationState.askAccept) { // okay, something went wrong (unknown transaction id?), just dispose it newKeyRequest.dispose(); } else { // we have a new request! Let's broadcast it! _keyVerificationRequests[transactionId] = newKeyRequest; onKeyVerificationRequest.add(newKeyRequest); } }); } } } Future _handleRooms( Map rooms, Membership membership) async { for (final entry in rooms.entries) { final id = entry.key; final room = entry.value; // calculate the notification counts, the limitedTimeline and prevbatch num highlight_count = 0; num notification_count = 0; var prev_batch = ''; var limitedTimeline = false; if (room['unread_notifications'] is Map) { if (room['unread_notifications']['highlight_count'] is num) { highlight_count = room['unread_notifications']['highlight_count']; } if (room['unread_notifications']['notification_count'] is num) { notification_count = room['unread_notifications']['notification_count']; } } if (room['timeline'] is Map) { if (room['timeline']['limited'] is bool) { limitedTimeline = room['timeline']['limited']; } if (room['timeline']['prev_batch'] is String) { prev_batch = room['timeline']['prev_batch']; } } RoomSummary summary; if (room['summary'] is Map) { summary = RoomSummary.fromJson(room['summary']); } var update = RoomUpdate( id: id, membership: membership, notification_count: notification_count, highlight_count: highlight_count, limitedTimeline: limitedTimeline, prev_batch: prev_batch, summary: summary, ); if (database != null) { await database.storeRoomUpdate(this.id, update, getRoomById(id)); } _updateRoomsByRoomUpdate(update); final roomObj = getRoomById(id); if (limitedTimeline && roomObj != null) { roomObj.resetSortOrder(); } onRoomUpdate.add(update); var handledEvents = false; /// Handle now all room events and save them in the database if (room['state'] is Map && room['state']['events'] is List && room['state']['events'].isNotEmpty) { await _handleRoomEvents(id, room['state']['events'], 'state'); handledEvents = true; } if (room['invite_state'] is Map && room['invite_state']['events'] is List) { await _handleRoomEvents( id, room['invite_state']['events'], 'invite_state'); } if (room['timeline'] is Map && room['timeline']['events'] is List && room['timeline']['events'].isNotEmpty) { await _handleRoomEvents(id, room['timeline']['events'], 'timeline'); handledEvents = true; } if (room['ephemeral'] is Map && room['ephemeral']['events'] is List) { await _handleEphemerals(id, room['ephemeral']['events']); } if (room['account_data'] is Map && room['account_data']['events'] is List) { await _handleRoomEvents( id, room['account_data']['events'], 'account_data'); } if (handledEvents && database != null && roomObj != null) { await roomObj.updateSortOrder(); } } } Future _handleEphemerals(String id, List events) async { for (num i = 0; i < events.length; i++) { await _handleEvent(events[i], id, 'ephemeral'); // Receipt events are deltas between two states. We will create a // fake room account data event for this and store the difference // there. if (events[i]['type'] == 'm.receipt') { var room = getRoomById(id); room ??= Room(id: id); var receiptStateContent = room.roomAccountData['m.receipt']?.content ?? {}; for (var eventEntry in events[i]['content'].entries) { final String eventID = eventEntry.key; if (events[i]['content'][eventID]['m.read'] != null) { final Map userTimestampMap = events[i]['content'][eventID]['m.read']; for (var userTimestampMapEntry in userTimestampMap.entries) { final mxid = userTimestampMapEntry.key; // Remove previous receipt event from this user if (receiptStateContent[eventID] is Map && receiptStateContent[eventID]['m.read'] is Map && receiptStateContent[eventID]['m.read'].containsKey(mxid)) { receiptStateContent[eventID]['m.read'].remove(mxid); } if (userTimestampMap[mxid] is Map && userTimestampMap[mxid].containsKey('ts')) { receiptStateContent[mxid] = { 'event_id': eventID, 'ts': userTimestampMap[mxid]['ts'], }; } } } } events[i]['content'] = receiptStateContent; await _handleEvent(events[i], id, 'account_data'); } } } Future _handleRoomEvents( String chat_id, List events, String type) async { for (num i = 0; i < events.length; i++) { await _handleEvent(events[i], chat_id, type); } } Future _handleGlobalEvents(List events, String type) async { for (var i = 0; i < events.length; i++) { if (events[i]['type'] is String && events[i]['content'] is Map) { var update = UserUpdate( eventType: events[i]['type'], type: type, content: events[i], ); if (database != null) { await database.storeUserEventUpdate(id, update); } onUserEvent.add(update); } } } Future _handleEvent( Map event, String roomID, String type) async { if (event['type'] is String && event['content'] is Map) { // The client must ignore any new m.room.encryption event to prevent // man-in-the-middle attacks! final room = getRoomById(roomID); if (room == null || (event['type'] == 'm.room.encryption' && room.encrypted && event['content']['algorithm'] != room.getState('m.room.encryption')?.content['algorithm'])) { return; } // ephemeral events aren't persisted and don't need a sort order - they are // expected to be processed as soon as they come in final sortOrder = type != 'ephemeral' ? room.newSortOrder : 0.0; var update = EventUpdate( eventType: event['type'], roomID: roomID, type: type, content: event, sortOrder: sortOrder, ); if (event['type'] == 'm.room.encrypted') { update = update.decrypt(room); } if (update.eventType == 'm.room.encrypted' && database != null) { // the event is still encrytped....let's try fetching the keys from the database! await room.loadInboundGroupSessionKey( event['content']['session_id'], event['content']['sender_key']); update = update.decrypt(room); } if (type != 'ephemeral' && database != null) { await database.storeEventUpdate(id, update); } _updateRoomsByEventUpdate(update); onEvent.add(update); if (event['type'] == 'm.call.invite') { onCallInvite.add(Event.fromJson(event, room, sortOrder)); } else if (event['type'] == 'm.call.hangup') { onCallHangup.add(Event.fromJson(event, room, sortOrder)); } else if (event['type'] == 'm.call.answer') { onCallAnswer.add(Event.fromJson(event, room, sortOrder)); } else if (event['type'] == 'm.call.candidates') { onCallCandidates.add(Event.fromJson(event, room, sortOrder)); } } } void _updateRoomsByRoomUpdate(RoomUpdate chatUpdate) { // Update the chat list item. // Search the room in the rooms num j = 0; for (j = 0; j < rooms.length; j++) { if (rooms[j].id == chatUpdate.id) break; } final found = (j < rooms.length && rooms[j].id == chatUpdate.id); final isLeftRoom = chatUpdate.membership == Membership.leave; // Does the chat already exist in the list rooms? if (!found && !isLeftRoom) { var position = chatUpdate.membership == Membership.invite ? 0 : j; // Add the new chat to the list var newRoom = Room( id: chatUpdate.id, membership: chatUpdate.membership, prev_batch: chatUpdate.prev_batch, highlightCount: chatUpdate.highlight_count, notificationCount: chatUpdate.notification_count, mHeroes: chatUpdate.summary?.mHeroes, mJoinedMemberCount: chatUpdate.summary?.mJoinedMemberCount, mInvitedMemberCount: chatUpdate.summary?.mInvitedMemberCount, roomAccountData: {}, client: this, ); rooms.insert(position, newRoom); } // If the membership is "leave" then remove the item and stop here else if (found && isLeftRoom) { rooms.removeAt(j); } // Update notification, highlight count and/or additional informations else if (found && chatUpdate.membership != Membership.leave && (rooms[j].membership != chatUpdate.membership || rooms[j].notificationCount != chatUpdate.notification_count || rooms[j].highlightCount != chatUpdate.highlight_count || chatUpdate.summary != null)) { rooms[j].membership = chatUpdate.membership; rooms[j].notificationCount = chatUpdate.notification_count; rooms[j].highlightCount = chatUpdate.highlight_count; if (chatUpdate.prev_batch != null) { rooms[j].prev_batch = chatUpdate.prev_batch; } if (chatUpdate.summary != null) { if (chatUpdate.summary.mHeroes != null) { rooms[j].mHeroes = chatUpdate.summary.mHeroes; } if (chatUpdate.summary.mJoinedMemberCount != null) { rooms[j].mJoinedMemberCount = chatUpdate.summary.mJoinedMemberCount; } if (chatUpdate.summary.mInvitedMemberCount != null) { rooms[j].mInvitedMemberCount = chatUpdate.summary.mInvitedMemberCount; } } if (rooms[j].onUpdate != null) rooms[j].onUpdate.add(rooms[j].id); } _sortRooms(); } void _updateRoomsByEventUpdate(EventUpdate eventUpdate) { if (eventUpdate.type == 'history') return; // Search the room in the rooms num j = 0; for (j = 0; j < rooms.length; j++) { if (rooms[j].id == eventUpdate.roomID) break; } final found = (j < rooms.length && rooms[j].id == eventUpdate.roomID); if (!found) return; if (eventUpdate.type == 'timeline' || eventUpdate.type == 'state' || eventUpdate.type == 'invite_state') { var stateEvent = Event.fromJson(eventUpdate.content, rooms[j], eventUpdate.sortOrder); if (stateEvent.type == EventTypes.Redaction) { final String redacts = eventUpdate.content['redacts']; rooms[j].states.states.forEach( (String key, Map states) => states.forEach( (String key, Event state) { if (state.eventId == redacts) { state.setRedactionEvent(stateEvent); } }, ), ); } else { var prevState = rooms[j].getState(stateEvent.typeKey, stateEvent.stateKey); if (prevState != null && prevState.time.millisecondsSinceEpoch > stateEvent.time.millisecondsSinceEpoch) return; rooms[j].setState(stateEvent); } } else if (eventUpdate.type == 'account_data') { rooms[j].roomAccountData[eventUpdate.eventType] = RoomAccountData.fromJson(eventUpdate.content, rooms[j]); } else if (eventUpdate.type == 'ephemeral') { rooms[j].ephemerals[eventUpdate.eventType] = RoomAccountData.fromJson(eventUpdate.content, rooms[j]); } if (rooms[j].onUpdate != null) rooms[j].onUpdate.add(rooms[j].id); if (eventUpdate.type == 'timeline') _sortRooms(); } final List _pendingToDeviceEvents = []; void _updateRoomsByToDeviceEvent(ToDeviceEvent toDeviceEvent, {addToPendingIfNotFound = true}) async { try { switch (toDeviceEvent.type) { case 'm.room_key': case 'm.forwarded_room_key': final roomId = toDeviceEvent.content['room_id']; var room = getRoomById(roomId); if (room == null && addToPendingIfNotFound) { _pendingToDeviceEvents.add(toDeviceEvent); break; } room ??= Room(client: this, id: roomId); final String sessionId = toDeviceEvent.content['session_id']; if (toDeviceEvent.type == 'm.room_key' && userDeviceKeys.containsKey(toDeviceEvent.sender) && userDeviceKeys[toDeviceEvent.sender] .deviceKeys .containsKey(toDeviceEvent.content['requesting_device_id'])) { toDeviceEvent.content['sender_claimed_ed25519_key'] = userDeviceKeys[toDeviceEvent.sender] .deviceKeys[toDeviceEvent.content['requesting_device_id']] .ed25519Key; } room.setInboundGroupSession( sessionId, toDeviceEvent.content, forwarded: toDeviceEvent.type == 'm.forwarded_room_key', ); if (toDeviceEvent.type == 'm.forwarded_room_key') { await sendToDevice( [], 'm.room_key_request', { 'action': 'request_cancellation', 'request_id': base64 .encode(utf8.encode(toDeviceEvent.content['room_id'])), 'requesting_device_id': room.client.deviceID, }, encrypted: false, ); } break; case 'm.room_key_request': if (!toDeviceEvent.content.containsKey('body')) break; var room = getRoomById(toDeviceEvent.content['body']['room_id']); DeviceKeys deviceKeys; final String sessionId = toDeviceEvent.content['body']['session_id']; if (userDeviceKeys.containsKey(toDeviceEvent.sender) && userDeviceKeys[toDeviceEvent.sender] .deviceKeys .containsKey(toDeviceEvent.content['requesting_device_id'])) { deviceKeys = userDeviceKeys[toDeviceEvent.sender] .deviceKeys[toDeviceEvent.content['requesting_device_id']]; await room.loadInboundGroupSessionKey(sessionId); if (room.inboundGroupSessions.containsKey(sessionId)) { final roomKeyRequest = RoomKeyRequest.fromToDeviceEvent(toDeviceEvent, this); if (deviceKeys.userId == userID && deviceKeys.verified && !deviceKeys.blocked) { await roomKeyRequest.forwardKey(); } else { onRoomKeyRequest.add(roomKeyRequest); } } } break; } } catch (e) { print('[Matrix] Error while processing to-device-event: ' + e.toString()); } } bool _sortLock = false; /// The compare function how the rooms should be sorted internally. By default /// rooms are sorted by timestamp of the last m.room.message event or the last /// event if there is no known message. RoomSorter sortRoomsBy = (a, b) => b.timeCreated.millisecondsSinceEpoch .compareTo(a.timeCreated.millisecondsSinceEpoch); void _sortRooms() { if (prevBatch == null || _sortLock || rooms.length < 2) return; _sortLock = true; rooms?.sort(sortRoomsBy); _sortLock = false; } /// Gets an OpenID token object that the requester may supply to another service to verify their identity in Matrix. /// The generated token is only valid for exchanging for user information from the federation API for OpenID. Future requestOpenIdCredentials() async { final response = await jsonRequest( type: HTTPType.POST, action: '/client/r0/user/$userID/openid/request_token', data: {}, ); return OpenIdCredentials.fromJson(response); } /// A map of known device keys per user. Map get userDeviceKeys => _userDeviceKeys; Map _userDeviceKeys = {}; Future> _getUserIdsInEncryptedRooms() async { var userIds = {}; for (var i = 0; i < rooms.length; i++) { if (rooms[i].encrypted) { var userList = await rooms[i].requestParticipants(); for (var user in userList) { if ([Membership.join, Membership.invite].contains(user.membership)) { userIds.add(user.id); } } } } return userIds; } Future _updateUserDeviceKeys() async { try { if (!isLogged()) return; final dbActions = Function()>[]; var trackedUserIds = await _getUserIdsInEncryptedRooms(); trackedUserIds.add(userID); // Remove all userIds we no longer need to track the devices of. _userDeviceKeys .removeWhere((String userId, v) => !trackedUserIds.contains(userId)); // Check if there are outdated device key lists. Add it to the set. var outdatedLists = {}; for (var userId in trackedUserIds) { if (!userDeviceKeys.containsKey(userId)) { _userDeviceKeys[userId] = DeviceKeysList(userId); } var deviceKeysList = userDeviceKeys[userId]; if (deviceKeysList.outdated) { outdatedLists[userId] = []; } } if (outdatedLists.isNotEmpty) { // Request the missing device key lists from the server. final response = await jsonRequest( type: HTTPType.POST, action: '/client/r0/keys/query', data: {'timeout': 10000, 'device_keys': outdatedLists}); for (final rawDeviceKeyListEntry in response['device_keys'].entries) { final String userId = rawDeviceKeyListEntry.key; final oldKeys = Map.from(_userDeviceKeys[userId].deviceKeys); _userDeviceKeys[userId].deviceKeys = {}; for (final rawDeviceKeyEntry in rawDeviceKeyListEntry.value.entries) { final String deviceId = rawDeviceKeyEntry.key; // Set the new device key for this device if (!oldKeys.containsKey(deviceId)) { final entry = DeviceKeys.fromJson(rawDeviceKeyEntry.value); if (entry.isValid) { _userDeviceKeys[userId].deviceKeys[deviceId] = entry; if (deviceId == deviceID && entry.ed25519Key == fingerprintKey) { // Always trust the own device entry.verified = true; } } if (database != null) { dbActions.add(() => database.storeUserDeviceKey( id, userId, deviceId, json.encode(_userDeviceKeys[userId] .deviceKeys[deviceId] .toJson()), _userDeviceKeys[userId].deviceKeys[deviceId].verified, _userDeviceKeys[userId].deviceKeys[deviceId].blocked, )); } } else { _userDeviceKeys[userId].deviceKeys[deviceId] = oldKeys[deviceId]; } } if (database != null) { for (final oldDeviceKeyEntry in oldKeys.entries) { final deviceId = oldDeviceKeyEntry.key; if (!_userDeviceKeys[userId].deviceKeys.containsKey(deviceId)) { // we need to remove an old key dbActions.add( () => database.removeUserDeviceKey(id, userId, deviceId)); } } } _userDeviceKeys[userId].outdated = false; if (database != null) { dbActions .add(() => database.storeUserDeviceKeysInfo(id, userId, false)); } } } await database?.transaction(() async { for (final f in dbActions) { await f(); } }); } catch (e) { print('[LibOlm] Unable to update user device keys: ' + e.toString()); } } String get fingerprintKey => encryptionEnabled ? json.decode(_olmAccount.identity_keys())['ed25519'] : null; String get identityKey => encryptionEnabled ? json.decode(_olmAccount.identity_keys())['curve25519'] : null; /// Adds a signature to this json from this olm account. Map signJson(Map payload) { if (!encryptionEnabled) throw ('Encryption is disabled'); final Map unsigned = payload['unsigned']; final Map signatures = payload['signatures']; payload.remove('unsigned'); payload.remove('signatures'); final canonical = canonicalJson.encode(payload); final signature = _olmAccount.sign(String.fromCharCodes(canonical)); if (signatures != null) { payload['signatures'] = signatures; } else { payload['signatures'] = {}; } payload['signatures'][userID] = {}; payload['signatures'][userID]['ed25519:$deviceID'] = signature; if (unsigned != null) { payload['unsigned'] = unsigned; } return payload; } /// Checks the signature of a signed json object. bool checkJsonSignature(String key, Map signedJson, String userId, String deviceId) { if (!encryptionEnabled) throw ('Encryption is disabled'); final Map signatures = signedJson['signatures']; if (signatures == null || !signatures.containsKey(userId)) return false; signedJson.remove('unsigned'); signedJson.remove('signatures'); if (!signatures[userId].containsKey('ed25519:$deviceId')) return false; final String signature = signatures[userId]['ed25519:$deviceId']; final canonical = canonicalJson.encode(signedJson); final message = String.fromCharCodes(canonical); var isValid = true; try { olm.Utility() ..ed25519_verify(key, message, signature) ..free(); } catch (e) { isValid = false; print('[LibOlm] Signature check failed: ' + e.toString()); } return isValid; } DateTime lastTimeKeysUploaded; /// Generates new one time keys, signs everything and upload it to the server. Future _uploadKeys({bool uploadDeviceKeys = false}) async { if (!encryptionEnabled) return true; final oneTimeKeysCount = _olmAccount.max_number_of_one_time_keys(); _olmAccount.generate_one_time_keys(oneTimeKeysCount); final Map oneTimeKeys = json.decode(_olmAccount.one_time_keys()); var signedOneTimeKeys = {}; for (String key in oneTimeKeys['curve25519'].keys) { signedOneTimeKeys['signed_curve25519:$key'] = {}; signedOneTimeKeys['signed_curve25519:$key']['key'] = oneTimeKeys['curve25519'][key]; signedOneTimeKeys['signed_curve25519:$key'] = signJson(signedOneTimeKeys['signed_curve25519:$key']); } var keysContent = { if (uploadDeviceKeys) 'device_keys': { 'user_id': userID, 'device_id': deviceID, 'algorithms': [ 'm.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2' ], 'keys': {}, }, 'one_time_keys': signedOneTimeKeys, }; if (uploadDeviceKeys) { final Map keys = json.decode(_olmAccount.identity_keys()); for (var algorithm in keys.keys) { keysContent['device_keys']['keys']['$algorithm:$deviceID'] = keys[algorithm]; } keysContent['device_keys'] = signJson(keysContent['device_keys'] as Map); } _olmAccount.mark_keys_as_published(); final response = await jsonRequest( type: HTTPType.POST, action: '/client/r0/keys/upload', data: keysContent, ); if (response['one_time_key_counts']['signed_curve25519'] != oneTimeKeysCount) { return false; } await database?.updateClientKeys(pickledOlmAccount, id); lastTimeKeysUploaded = DateTime.now(); return true; } /// Try to decrypt a ToDeviceEvent encrypted with olm. ToDeviceEvent decryptToDeviceEvent(ToDeviceEvent toDeviceEvent) { if (toDeviceEvent.type != 'm.room.encrypted') { print( '[LibOlm] Warning! Tried to decrypt a not-encrypted to-device-event'); return toDeviceEvent; } if (toDeviceEvent.content['algorithm'] != 'm.olm.v1.curve25519-aes-sha2') { throw ('Unknown algorithm: ${toDeviceEvent.content}'); } if (!toDeviceEvent.content['ciphertext'].containsKey(identityKey)) { throw ("The message isn't sent for this device"); } String plaintext; final String senderKey = toDeviceEvent.content['sender_key']; final String body = toDeviceEvent.content['ciphertext'][identityKey]['body']; final int type = toDeviceEvent.content['ciphertext'][identityKey]['type']; if (type != 0 && type != 1) { throw ('Unknown message type'); } var existingSessions = olmSessions[senderKey]; if (existingSessions != null) { for (var session in existingSessions) { if (type == 0 && session.matches_inbound(body) == true) { plaintext = session.decrypt(type, body); storeOlmSession(senderKey, session); break; } else if (type == 1) { try { plaintext = session.decrypt(type, body); storeOlmSession(senderKey, session); break; } catch (_) { plaintext = null; } } } } if (plaintext == null && type != 0) { throw ('No existing sessions found'); } if (plaintext == null) { var newSession = olm.Session(); newSession.create_inbound_from(_olmAccount, senderKey, body); _olmAccount.remove_one_time_keys(newSession); database?.updateClientKeys(pickledOlmAccount, id); plaintext = newSession.decrypt(type, body); storeOlmSession(senderKey, newSession); } final Map plainContent = json.decode(plaintext); if (plainContent.containsKey('sender') && plainContent['sender'] != toDeviceEvent.sender) { throw ("Message was decrypted but sender doesn't match"); } if (plainContent.containsKey('recipient') && plainContent['recipient'] != userID) { throw ("Message was decrypted but recipient doesn't match"); } if (plainContent['recipient_keys'] is Map && plainContent['recipient_keys']['ed25519'] is String && plainContent['recipient_keys']['ed25519'] != fingerprintKey) { throw ("Message was decrypted but own fingerprint Key doesn't match"); } return ToDeviceEvent( content: plainContent['content'], type: plainContent['type'], sender: toDeviceEvent.sender, ); } /// A map from Curve25519 identity keys to existing olm sessions. Map> get olmSessions => _olmSessions; Map> _olmSessions = {}; void storeOlmSession(String curve25519IdentityKey, olm.Session session) { if (!_olmSessions.containsKey(curve25519IdentityKey)) { _olmSessions[curve25519IdentityKey] = []; } final ix = _olmSessions[curve25519IdentityKey] .indexWhere((s) => s.session_id() == session.session_id()); if (ix == -1) { // add a new session _olmSessions[curve25519IdentityKey].add(session); } else { // update an existing session _olmSessions[curve25519IdentityKey][ix] = session; } final pickle = session.pickle(userID); database?.storeOlmSession( id, curve25519IdentityKey, session.session_id(), pickle); } /// Sends an encrypted [message] of this [type] to these [deviceKeys]. To send /// the request to all devices of the current user, pass an empty list to [deviceKeys]. Future sendToDevice( List deviceKeys, String type, Map message, { bool encrypted = true, List toUsers, }) async { if (encrypted && !encryptionEnabled) return; // Don't send this message to blocked devices. if (deviceKeys.isNotEmpty) { deviceKeys.removeWhere((DeviceKeys deviceKeys) => deviceKeys.blocked || deviceKeys.deviceId == deviceID); if (deviceKeys.isEmpty) return; } var sendToDeviceMessage = message; // Send with send-to-device messaging var data = { 'messages': {}, }; if (deviceKeys.isEmpty) { if (toUsers == null) { data['messages'][userID] = {}; data['messages'][userID]['*'] = sendToDeviceMessage; } else { for (var user in toUsers) { data['messages'][user.id] = {}; data['messages'][user.id]['*'] = sendToDeviceMessage; } } } else { if (encrypted) { // Create new sessions with devices if there is no existing session yet. var deviceKeysWithoutSession = List.from(deviceKeys); deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) => olmSessions.containsKey(deviceKeys.curve25519Key)); if (deviceKeysWithoutSession.isNotEmpty) { await startOutgoingOlmSessions(deviceKeysWithoutSession); } } for (var i = 0; i < deviceKeys.length; i++) { var device = deviceKeys[i]; if (!data['messages'].containsKey(device.userId)) { data['messages'][device.userId] = {}; } if (encrypted) { var existingSessions = olmSessions[device.curve25519Key]; if (existingSessions == null || existingSessions.isEmpty) continue; existingSessions .sort((a, b) => a.session_id().compareTo(b.session_id())); final payload = { 'type': type, 'content': message, 'sender': userID, 'keys': {'ed25519': fingerprintKey}, 'recipient': device.userId, 'recipient_keys': {'ed25519': device.ed25519Key}, }; final encryptResult = existingSessions.first.encrypt(json.encode(payload)); storeOlmSession(device.curve25519Key, existingSessions.first); sendToDeviceMessage = { 'algorithm': 'm.olm.v1.curve25519-aes-sha2', 'sender_key': identityKey, 'ciphertext': {}, }; sendToDeviceMessage['ciphertext'][device.curve25519Key] = { 'type': encryptResult.type, 'body': encryptResult.body, }; } data['messages'][device.userId][device.deviceId] = sendToDeviceMessage; } } if (encrypted) type = 'm.room.encrypted'; final messageID = 'msg${DateTime.now().millisecondsSinceEpoch}'; await jsonRequest( type: HTTPType.PUT, action: '/client/r0/sendToDevice/$type/$messageID', data: data, ); } Future startOutgoingOlmSessions(List deviceKeys, {bool checkSignature = true}) async { var requestingKeysFrom = >{}; for (var device in deviceKeys) { if (requestingKeysFrom[device.userId] == null) { requestingKeysFrom[device.userId] = {}; } requestingKeysFrom[device.userId][device.deviceId] = 'signed_curve25519'; } final response = await jsonRequest( type: HTTPType.POST, action: '/client/r0/keys/claim', data: {'timeout': 10000, 'one_time_keys': requestingKeysFrom}, ); for (var userKeysEntry in response['one_time_keys'].entries) { final String userId = userKeysEntry.key; for (var deviceKeysEntry in userKeysEntry.value.entries) { final String deviceId = deviceKeysEntry.key; final fingerprintKey = userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key; final identityKey = userDeviceKeys[userId].deviceKeys[deviceId].curve25519Key; for (Map deviceKey in deviceKeysEntry.value.values) { if (checkSignature && checkJsonSignature(fingerprintKey, deviceKey, userId, deviceId) == false) { continue; } try { var session = olm.Session(); session.create_outbound(_olmAccount, identityKey, deviceKey['key']); await storeOlmSession(identityKey, session); } catch (e) { print('[LibOlm] Could not create new outbound olm session: ' + e.toString()); } } } } } /// Gets information about all devices for the current user. Future> requestUserDevices() async { final response = await jsonRequest(type: HTTPType.GET, action: '/client/r0/devices'); var userDevices = []; for (final rawDevice in response['devices']) { userDevices.add( UserDevice.fromJson(rawDevice, this), ); } return userDevices; } /// Gets information about all devices for the current user. Future requestUserDevice(String deviceId) async { final response = await jsonRequest( type: HTTPType.GET, action: '/client/r0/devices/$deviceId'); return UserDevice.fromJson(response, this); } /// Deletes the given devices, and invalidates any access token associated with them. Future deleteDevices(List deviceIds, {Map auth}) async { await jsonRequest( type: HTTPType.POST, action: '/client/r0/delete_devices', data: { 'devices': deviceIds, if (auth != null) 'auth': auth, }, ); return; } /// Lists the public rooms on the server, with optional filter. Future requestPublicRooms({ int limit, String since, String genericSearchTerm, String server, bool includeAllNetworks, String thirdPartyInstanceId, }) async { var action = '/client/r0/publicRooms'; if (server != null) { action += '?server=$server'; } final response = await jsonRequest( type: HTTPType.POST, action: action, data: { if (limit != null) 'limit': 10, if (since != null) 'since': since, if (genericSearchTerm != null) 'filter': {'generic_search_term': genericSearchTerm}, if (includeAllNetworks != null) 'include_all_networks': includeAllNetworks, if (thirdPartyInstanceId != null) 'third_party_instance_id': thirdPartyInstanceId, }, ); return PublicRoomsResponse.fromJson(response, this); } /// Whether all push notifications are muted using the [.m.rule.master] /// rule of the push rules: https://matrix.org/docs/spec/client_server/r0.6.0#m-rule-master bool get allPushNotificationsMuted { if (!accountData.containsKey('m.push_rules') || !(accountData['m.push_rules'].content['global'] is Map)) { return false; } final Map globalPushRules = accountData['m.push_rules'].content['global']; if (globalPushRules == null) return false; if (globalPushRules['override'] is List) { for (var i = 0; i < globalPushRules['override'].length; i++) { if (globalPushRules['override'][i]['rule_id'] == '.m.rule.master') { return globalPushRules['override'][i]['enabled']; } } } return false; } Future setMuteAllPushNotifications(bool muted) async { await jsonRequest( type: HTTPType.PUT, action: '/client/r0/pushrules/global/override/.m.rule.master/enabled', data: {'enabled': muted}, ); return; } /// Changes the password. You should either set oldPasswort or another authentication flow. Future changePassword(String newPassword, {String oldPassword, Map auth}) async { try { await jsonRequest( type: HTTPType.POST, action: '/client/r0/account/password', data: { 'new_password': newPassword, if (oldPassword != null) 'auth': { 'type': 'm.login.password', 'user': userID, 'password': oldPassword, }, if (auth != null) 'auth': auth, }, ); } on MatrixException catch (matrixException) { if (!matrixException.requireAdditionalAuthentication) { rethrow; } if (matrixException.authenticationFlows.length != 1 || !matrixException.authenticationFlows.first.stages .contains('m.login.password')) { rethrow; } if (oldPassword == null) { rethrow; } return changePassword( newPassword, auth: { 'type': 'm.login.password', 'user': userID, 'identifier': {'type': 'm.id.user', 'user': userID}, 'password': oldPassword, 'session': matrixException.session, }, ); } catch (_) { rethrow; } } bool _disposed = false; /// Stops the synchronization and closes the database. After this /// you can safely make this Client instance null. Future dispose({bool closeDatabase = false}) async { _disposed = true; if (closeDatabase) await database?.close(); database = null; return; } }