diff --git a/lib/matrix.dart b/lib/matrix.dart index 2627578e..87692aa7 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -36,6 +36,7 @@ export 'src/utils/commands_extension.dart'; export 'src/utils/http_timeout.dart'; export 'src/client.dart'; export 'src/event.dart'; +export 'src/event_status.dart'; export 'src/room.dart'; export 'src/voip_content.dart'; export 'src/timeline.dart'; diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 0a7a5cab..90dbc224 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -19,16 +19,17 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; - -import 'package:matrix/encryption/utils/stored_inbound_group_session.dart'; -import 'package:matrix/encryption/utils/ssss_cache.dart'; -import 'package:matrix/encryption/utils/outbound_group_session.dart'; -import 'package:matrix/encryption/utils/olm_session.dart'; import 'dart:typed_data'; -import 'package:matrix/matrix.dart'; -import 'package:matrix/src/utils/queued_to_device_event.dart'; import 'package:hive/hive.dart'; +import 'package:matrix/encryption/utils/olm_session.dart'; +import 'package:matrix/encryption/utils/outbound_group_session.dart'; +import 'package:matrix/encryption/utils/ssss_cache.dart'; +import 'package:matrix/encryption/utils/stored_inbound_group_session.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/event_status.dart'; +import 'package:matrix/src/utils/queued_to_device_event.dart'; +import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; /// This is a basic database for the Matrix SDK using the hive store. You need @@ -81,25 +82,43 @@ class FamedlySdkHiveDatabase extends DatabaseApi { late LazyBox _seenDeviceKeysBox; String get _clientBoxName => '$name.box.client'; + String get _accountDataBoxName => '$name.box.account_data'; + String get _roomsBoxName => '$name.box.rooms'; + String get _toDeviceQueueBoxName => '$name.box.to_device_queue'; + String get _roomStateBoxName => '$name.box.room_states'; + String get _roomMembersBoxName => '$name.box.room_members'; + String get _roomAccountDataBoxName => '$name.box.room_account_data'; + String get _inboundGroupSessionsBoxName => '$name.box.inbound_group_session'; + String get _outboundGroupSessionsBoxName => '$name.box.outbound_group_session'; + String get _olmSessionsBoxName => '$name.box.olm_session'; + String get _userDeviceKeysBoxName => '$name.box.user_device_keys'; + String get _userDeviceKeysOutdatedBoxName => '$name.box.user_device_keys_outdated'; + String get _userCrossSigningKeysBoxName => '$name.box.cross_signing_keys'; + String get _ssssCacheBoxName => '$name.box.ssss_cache'; + String get _presencesBoxName => '$name.box.presences'; + String get _timelineFragmentsBoxName => '$name.box.timeline_fragments'; + String get _eventsBoxName => '$name.box.events'; + String get _seenDeviceIdsBoxName => '$name.box.seen_device_ids'; + String get _seenDeviceKeysBoxName => '$name.box.seen_device_keys'; final HiveCipher? encryptionCipher; @@ -872,27 +891,34 @@ class FamedlySdkHiveDatabase extends DatabaseApi { : null; // calculate the status - final newStatus = eventUpdate.content.tryGet('status') ?? - eventUpdate.content - .tryGetMap('unsigned') - ?.tryGet(messageSendingStatusKey) ?? - 2; + final newStatus = eventStatusFromInt( + eventUpdate.content.tryGet('status') ?? + eventUpdate.content + .tryGetMap('unsigned') + ?.tryGet(messageSendingStatusKey) ?? + EventStatus.synced.intValue, + ); // Is this the response to a sending event which is already synced? Then // there is nothing to do here. - if (newStatus != 2 && prevEvent?.status == 2) { + if (!newStatus.isSynced && + prevEvent != null && + prevEvent.status.isSynced) { return; } final status = - newStatus == -1 || prevEvent == null || prevEvent.status == null + newStatus.isError || prevEvent == null || prevEvent.status != null ? newStatus - : max(prevEvent.status, newStatus); + : latestEventStatus( + prevEvent.status, + newStatus, + ); // Add the status and the sort order to the content so it get stored eventUpdate.content['unsigned'] ??= {}; eventUpdate.content['unsigned'][messageSendingStatusKey] = - eventUpdate.content['status'] = status; + eventUpdate.content['status'] = status.intValue; // In case this event has sent from this account we have a transaction ID final transactionId = eventUpdate.content @@ -903,8 +929,9 @@ class FamedlySdkHiveDatabase extends DatabaseApi { eventUpdate.content); // Update timeline fragments - final key = - MultiKey(eventUpdate.roomID, status >= 1 ? '' : 'SENDING').toString(); + final key = MultiKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING') + .toString(); + final List eventIds = (await _timelineFragmentsBox.get(key) ?? []); if (!eventIds.contains(eventId)) { @@ -914,8 +941,9 @@ class FamedlySdkHiveDatabase extends DatabaseApi { eventIds.insert(0, eventId); } await _timelineFragmentsBox.put(key, eventIds); - } else if (status == 2 && - prevEvent?.status == 1 && + } else if (status.isSynced && + prevEvent != null && + prevEvent.status.isSent && eventUpdate.type != EventUpdateType.history) { // Status changes from 1 -> 2? Make sure event is correctly sorted. eventIds.remove(eventId); @@ -923,7 +951,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi { } // If event comes from server timeline, remove sending events with this ID - if (status >= 1) { + if (status.isSent) { final key = MultiKey(eventUpdate.roomID, 'SENDING').toString(); final List eventIds = (await _timelineFragmentsBox.get(key) ?? []); final i = eventIds.indexWhere((id) => id == eventId); @@ -933,7 +961,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi { } // Is there a transaction id? Then delete the event with this id. - if (status != -1 && status != 0 && transactionId != null) { + if (!status.isError && !status.isSending && transactionId != null) { await removeEvent(transactionId, eventUpdate.roomID); } } @@ -1348,12 +1376,14 @@ Map convertToJson(Map map) { class MultiKey { final List parts; + MultiKey(String key1, [String? key2, String? key3]) : parts = [ key1, if (key2 != null) key2, if (key3 != null) key3, ]; + const MultiKey.byParts(this.parts); MultiKey.fromString(String multiKeyString) diff --git a/lib/src/database/hive_database.dart.orig b/lib/src/database/hive_database.dart.orig new file mode 100644 index 00000000..92c627f4 --- /dev/null +++ b/lib/src/database/hive_database.dart.orig @@ -0,0 +1,1410 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:hive/hive.dart'; +import 'package:matrix/encryption/utils/olm_session.dart'; +import 'package:matrix/encryption/utils/outbound_group_session.dart'; +import 'package:matrix/encryption/utils/ssss_cache.dart'; +import 'package:matrix/encryption/utils/stored_inbound_group_session.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/event_status.dart'; +import 'package:matrix/src/utils/queued_to_device_event.dart'; +import 'package:matrix/src/utils/run_benchmarked.dart'; +<<<<<<< HEAD +import 'package:matrix/src/utils/run_benchmarked.dart'; +======= +>>>>>>> 8fe85aca09b947ce7417672ce57ebfce80f3133c + +/// This is a basic database for the Matrix SDK using the hive store. You need +/// to make sure that you perform `Hive.init()` or `Hive.flutterInit()` before +/// you use this. +/// +/// This database does not support file caching! +class FamedlySdkHiveDatabase extends DatabaseApi { + static const int version = 5; + final String name; + late Box _clientBox; + late Box _accountDataBox; + late Box _roomsBox; + late Box _toDeviceQueueBox; + + /// Key is a tuple as MultiKey(roomId, type) where stateKey can be + /// an empty string. + late LazyBox _roomStateBox; + + /// Key is a tuple as MultiKey(roomId, userId) + late LazyBox _roomMembersBox; + + /// Key is a tuple as MultiKey(roomId, type) + late LazyBox _roomAccountDataBox; + late LazyBox _inboundGroupSessionsBox; + late LazyBox _outboundGroupSessionsBox; + late LazyBox _olmSessionsBox; + + /// Key is a tuple as MultiKey(userId, deviceId) + late LazyBox _userDeviceKeysBox; + + /// Key is the user ID as a String + late LazyBox _userDeviceKeysOutdatedBox; + + /// Key is a tuple as MultiKey(userId, publicKey) + late LazyBox _userCrossSigningKeysBox; + late LazyBox _ssssCacheBox; + late LazyBox _presencesBox; + + /// Key is a tuple as Multikey(roomId, fragmentId) while the default + /// fragmentId is an empty String + late LazyBox _timelineFragmentsBox; + + /// Key is a tuple as MultiKey(roomId, eventId) + late LazyBox _eventsBox; + + /// Key is a tuple as MultiKey(userId, deviceId) + late LazyBox _seenDeviceIdsBox; + + late LazyBox _seenDeviceKeysBox; + + String get _clientBoxName => '$name.box.client'; + + String get _accountDataBoxName => '$name.box.account_data'; + + String get _roomsBoxName => '$name.box.rooms'; + + String get _toDeviceQueueBoxName => '$name.box.to_device_queue'; + + String get _roomStateBoxName => '$name.box.room_states'; + + String get _roomMembersBoxName => '$name.box.room_members'; + + String get _roomAccountDataBoxName => '$name.box.room_account_data'; + + String get _inboundGroupSessionsBoxName => '$name.box.inbound_group_session'; + + String get _outboundGroupSessionsBoxName => + '$name.box.outbound_group_session'; + + String get _olmSessionsBoxName => '$name.box.olm_session'; + + String get _userDeviceKeysBoxName => '$name.box.user_device_keys'; + + String get _userDeviceKeysOutdatedBoxName => + '$name.box.user_device_keys_outdated'; + + String get _userCrossSigningKeysBoxName => '$name.box.cross_signing_keys'; + + String get _ssssCacheBoxName => '$name.box.ssss_cache'; + + String get _presencesBoxName => '$name.box.presences'; + + String get _timelineFragmentsBoxName => '$name.box.timeline_fragments'; + + String get _eventsBoxName => '$name.box.events'; + + String get _seenDeviceIdsBoxName => '$name.box.seen_device_ids'; + + String get _seenDeviceKeysBoxName => '$name.box.seen_device_keys'; + + final HiveCipher? encryptionCipher; + + FamedlySdkHiveDatabase(this.name, {this.encryptionCipher}); + + @override + int get maxFileSize => 0; + + Future _actionOnAllBoxes(Future Function(BoxBase box) action) => + Future.wait([ + action(_clientBox), + action(_accountDataBox), + action(_roomsBox), + action(_roomStateBox), + action(_roomMembersBox), + action(_toDeviceQueueBox), + action(_roomAccountDataBox), + action(_inboundGroupSessionsBox), + action(_outboundGroupSessionsBox), + action(_olmSessionsBox), + action(_userDeviceKeysBox), + action(_userDeviceKeysOutdatedBox), + action(_userCrossSigningKeysBox), + action(_ssssCacheBox), + action(_presencesBox), + action(_timelineFragmentsBox), + action(_eventsBox), + action(_seenDeviceIdsBox), + action(_seenDeviceKeysBox), + ]); + + Future open() async { + _clientBox = await Hive.openBox( + _clientBoxName, + encryptionCipher: encryptionCipher, + ); + _accountDataBox = await Hive.openBox( + _accountDataBoxName, + encryptionCipher: encryptionCipher, + ); + _roomsBox = await Hive.openBox( + _roomsBoxName, + encryptionCipher: encryptionCipher, + ); + _roomStateBox = await Hive.openLazyBox( + _roomStateBoxName, + encryptionCipher: encryptionCipher, + ); + _roomMembersBox = await Hive.openLazyBox( + _roomMembersBoxName, + encryptionCipher: encryptionCipher, + ); + _toDeviceQueueBox = await Hive.openBox( + _toDeviceQueueBoxName, + encryptionCipher: encryptionCipher, + ); + _roomAccountDataBox = await Hive.openLazyBox( + _roomAccountDataBoxName, + encryptionCipher: encryptionCipher, + ); + _inboundGroupSessionsBox = await Hive.openLazyBox( + _inboundGroupSessionsBoxName, + encryptionCipher: encryptionCipher, + ); + _outboundGroupSessionsBox = await Hive.openLazyBox( + _outboundGroupSessionsBoxName, + encryptionCipher: encryptionCipher, + ); + _olmSessionsBox = await Hive.openLazyBox( + _olmSessionsBoxName, + encryptionCipher: encryptionCipher, + ); + _userDeviceKeysBox = await Hive.openLazyBox( + _userDeviceKeysBoxName, + encryptionCipher: encryptionCipher, + ); + _userDeviceKeysOutdatedBox = await Hive.openLazyBox( + _userDeviceKeysOutdatedBoxName, + encryptionCipher: encryptionCipher, + ); + _userCrossSigningKeysBox = await Hive.openLazyBox( + _userCrossSigningKeysBoxName, + encryptionCipher: encryptionCipher, + ); + _ssssCacheBox = await Hive.openLazyBox( + _ssssCacheBoxName, + encryptionCipher: encryptionCipher, + ); + _presencesBox = await Hive.openLazyBox( + _presencesBoxName, + encryptionCipher: encryptionCipher, + ); + _timelineFragmentsBox = await Hive.openLazyBox( + _timelineFragmentsBoxName, + encryptionCipher: encryptionCipher, + ); + _eventsBox = await Hive.openLazyBox( + _eventsBoxName, + encryptionCipher: encryptionCipher, + ); + _seenDeviceIdsBox = await Hive.openLazyBox( + _seenDeviceIdsBoxName, + encryptionCipher: encryptionCipher, + ); + _seenDeviceKeysBox = await Hive.openLazyBox( + _seenDeviceKeysBoxName, + encryptionCipher: encryptionCipher, + ); + + // Check version and check if we need a migration + final currentVersion = (await _clientBox.get('version') as int?); + if (currentVersion == null) { + await _clientBox.put('version', version); + } else if (currentVersion != version) { + await _migrateFromVersion(currentVersion); + } + + return; + } + + Future _migrateFromVersion(int currentVersion) async { + Logs().i('Migrate Hive database from version $currentVersion to $version'); + if (version == 5) { + for (final key in _userDeviceKeysBox.keys) { + try { + final raw = await _userDeviceKeysBox.get(key) as Map; + if (!raw.containsKey('keys')) continue; + final deviceKeys = DeviceKeys.fromJson( + convertToJson(raw), + Client(''), + ); + await addSeenDeviceId(deviceKeys.userId, deviceKeys.deviceId!, + deviceKeys.curve25519Key! + deviceKeys.ed25519Key!); + await addSeenPublicKey(deviceKeys.ed25519Key!, deviceKeys.deviceId!); + await addSeenPublicKey( + deviceKeys.curve25519Key!, deviceKeys.deviceId!); + } catch (e) { + Logs().w('Can not migrate device $key', e); + } + } + } + await clearCache(); + await _clientBox.put('version', version); + } + + @override + Future clear() async { + Logs().i('Clear and close hive database...'); + await _actionOnAllBoxes((box) async { + try { + await box.deleteAll(box.keys); + await box.close(); + } catch (e) { + Logs().v('Unable to clear box ${box.name}', e); + await box.deleteFromDisk(); + } + }); + return; + } + + @override + Future clearCache() async { + await _roomsBox.deleteAll(_roomsBox.keys); + await _accountDataBox.deleteAll(_accountDataBox.keys); + await _roomStateBox.deleteAll(_roomStateBox.keys); + await _roomMembersBox.deleteAll(_roomMembersBox.keys); + await _eventsBox.deleteAll(_eventsBox.keys); + await _timelineFragmentsBox.deleteAll(_timelineFragmentsBox.keys); + await _outboundGroupSessionsBox.deleteAll(_outboundGroupSessionsBox.keys); + await _presencesBox.deleteAll(_presencesBox.keys); + await _clientBox.delete('prev_batch'); + } + + @override + Future clearSSSSCache() async { + await _ssssCacheBox.deleteAll(_ssssCacheBox.keys); + } + + @override + Future close() => _actionOnAllBoxes((box) => box.close()); + + @override + Future deleteFromToDeviceQueue(int id) async { + await _toDeviceQueueBox.delete(id); + return; + } + + @override + Future deleteOldFiles(int savedAt) async { + return; + } + + @override + Future forgetRoom(String roomId) async { + await _timelineFragmentsBox.delete(MultiKey(roomId, '').toString()); + for (final key in _eventsBox.keys) { + final multiKey = MultiKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _eventsBox.delete(key); + } + for (final key in _roomStateBox.keys) { + final multiKey = MultiKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomStateBox.delete(key); + } + for (final key in _roomMembersBox.keys) { + final multiKey = MultiKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomMembersBox.delete(key); + } + for (final key in _roomAccountDataBox.keys) { + final multiKey = MultiKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomAccountDataBox.delete(key); + } + await _roomsBox.delete(roomId.toHiveKey); + } + + @override + Future> getAccountData() => + runBenchmarked>('Get all account data from Hive', + () async { + final accountData = {}; + for (final key in _accountDataBox.keys) { + final raw = await _accountDataBox.get(key); + accountData[key.toString().fromHiveKey] = BasicEvent( + type: key.toString().fromHiveKey, + content: convertToJson(raw), + ); + } + return accountData; + }, _accountDataBox.keys.length); + + @override + Future?> getClient(String name) => + runBenchmarked('Get Client from Hive', () async { + final map = {}; + for (final key in _clientBox.keys) { + if (key == 'version') continue; + map[key] = await _clientBox.get(key); + } + if (map.isEmpty) return null; + return map; + }); + + @override + Future getEventById(String eventId, Room room) async { + final raw = await _eventsBox.get(MultiKey(room.id, eventId).toString()); + if (raw == null) return null; + return Event.fromJson(convertToJson(raw), room); + } + + @override + bool eventIsKnown(String eventId, String roomId) => + _eventsBox.keys.contains(MultiKey(roomId, eventId).toString()); + + /// Loads a whole list of events at once from the store for a specific room + Future> _getEventsByIds(List eventIds, Room room) => + Future.wait(eventIds + .map( + (eventId) async => Event.fromJson( + convertToJson( + await _eventsBox.get(MultiKey(room.id, eventId).toString()), + ), + room, + ), + ) + .toList()); + + @override + Future> getEventList( + Room room, { + int start = 0, + int? limit, + }) async { + // Get the synced event IDs from the store + final timelineKey = MultiKey(room.id, '').toString(); + final timelineEventIds = + (await _timelineFragmentsBox.get(timelineKey) as List? ?? []); + + // Get the local stored SENDING events from the store + late final List sendingEventIds; + if (start != 0) { + sendingEventIds = []; + } else { + final sendingTimelineKey = MultiKey(room.id, 'SENDING').toString(); + sendingEventIds = + (await _timelineFragmentsBox.get(sendingTimelineKey) as List? ?? []); + } + + // Combine those two lists while respecting the start and limit parameters. + final end = min( + timelineEventIds.length, start + (limit ?? timelineEventIds.length)); + final eventIds = sendingEventIds + + (start < timelineEventIds.length + ? timelineEventIds.getRange(start, end).toList() + : []); + + return await _getEventsByIds(eventIds.cast(), room); + } + + @override + Future getFile(Uri mxcUri) async { + return null; + } + + @override + Future getInboundGroupSession( + String roomId, + String sessionId, + ) async { + final raw = await _inboundGroupSessionsBox.get(sessionId.toHiveKey); + if (raw == null) return null; + return StoredInboundGroupSession.fromJson(convertToJson(raw)); + } + + @override + Future> + getInboundGroupSessionsToUpload() async { + final sessions = (await Future.wait(_inboundGroupSessionsBox.keys.map( + (sessionId) async => + await _inboundGroupSessionsBox.get(sessionId)))) + .where((rawSession) => rawSession['uploaded'] == false) + .take(500) + .map( + (json) => StoredInboundGroupSession.fromJson( + convertToJson(json), + ), + ) + .toList(); + return sessions; + } + + @override + Future> getLastSentMessageUserDeviceKey( + String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(MultiKey(userId, deviceId).toString()); + if (raw == null) return []; + return [raw['last_sent_message']]; + } + + @override + Future storeOlmSession(String identityKey, String sessionId, + String pickle, int lastReceived) async { + final rawSessions = + (await _olmSessionsBox.get(identityKey.toHiveKey) as Map?) ?? {}; + rawSessions[sessionId] = { + 'identity_key': identityKey, + 'pickle': pickle, + 'session_id': sessionId, + 'last_received': lastReceived, + }; + await _olmSessionsBox.put(identityKey.toHiveKey, rawSessions); + return; + } + + @override + Future> getOlmSessions( + String identityKey, String userId) async { + final rawSessions = + await _olmSessionsBox.get(identityKey.toHiveKey) as Map?; + if (rawSessions == null || rawSessions.isEmpty) return []; + return rawSessions.values + .map((json) => OlmSession.fromJson(convertToJson(json), userId)) + .toList(); + } + + @override + Future> getOlmSessionsForDevices( + List identityKey, String userId) async { + final sessions = await Future.wait( + identityKey.map((identityKey) => getOlmSessions(identityKey, userId))); + return [for (final sublist in sessions) ...sublist]; + } + + @override + Future getOutboundGroupSession( + String roomId, String userId) async { + final raw = await _outboundGroupSessionsBox.get(roomId.toHiveKey); + if (raw == null) return null; + return OutboundGroupSession.fromJson(convertToJson(raw), userId); + } + + @override + Future> getRoomList(Client client) => + runBenchmarked>('Get room list from hive', () async { + final rooms = {}; + final importantRoomStates = client.importantStateEvents; + for (final key in _roomsBox.keys) { + // Get the room + final raw = await _roomsBox.get(key); + final room = Room.fromJson(convertToJson(raw), client); + + // let's see if we need any m.room.member events + // We always need the member event for ourself + final membersToPostload = {client.userID}; + // If the room is a direct chat, those IDs should be there too + if (room.isDirectChat) membersToPostload.add(room.directChatMatrixID); + // the lastEvent message preview might have an author we need to fetch, if it is a group chat + if (room.getState(EventTypes.Message) != null && !room.isDirectChat) { + membersToPostload.add(room.getState(EventTypes.Message).senderId); + } + // if the room has no name and no canonical alias, its name is calculated + // based on the heroes of the room + if (room.getState(EventTypes.RoomName) == null && + room.getState(EventTypes.RoomCanonicalAlias) == null) { + // we don't have a name and no canonical alias, so we'll need to + // post-load the heroes + membersToPostload.addAll(room.summary?.mHeroes ?? []); + } + // Load members + for (final userId in membersToPostload) { + final state = + await _roomMembersBox.get(MultiKey(room.id, userId).toString()); + if (state == null) { + Logs().w('Unable to post load member $userId'); + continue; + } + room.setState(Event.fromJson(convertToJson(state), room)); + } + + // Get the "important" room states. All other states will be loaded once + // `getUnimportantRoomStates()` is called. + for (final type in importantRoomStates) { + final states = await _roomStateBox + .get(MultiKey(room.id, type).toString()) as Map?; + if (states == null) continue; + final stateEvents = states.values + .map((raw) => Event.fromJson(convertToJson(raw), room)) + .toList(); + for (final state in stateEvents) { + room.setState(state); + } + } + + // Add to the list and continue. + rooms[room.id] = room; + } + + // Get the room account data + for (final key in _roomAccountDataBox.keys) { + final roomId = MultiKey.fromString(key).parts.first; + if (rooms.containsKey(roomId)) { + final raw = await _roomAccountDataBox.get(key); + final basicRoomEvent = BasicRoomEvent.fromJson( + convertToJson(raw), + ); + rooms[roomId]!.roomAccountData[basicRoomEvent.type] = + basicRoomEvent; + } else { + Logs().w( + 'Found account data for unknown room $roomId. Delete now...'); + await _roomAccountDataBox.delete(key); + } + } + + return rooms.values.toList(); + }, _roomsBox.keys.length); + + @override + Future getSSSSCache(String type) async { + final raw = await _ssssCacheBox.get(type); + if (raw == null) return null; + return SSSSCache.fromJson(convertToJson(raw)); + } + + @override + Future> getToDeviceEventQueue() async => + await Future.wait(_toDeviceQueueBox.keys.map((i) async { + final raw = await _toDeviceQueueBox.get(i); + raw['id'] = i; + return QueuedToDeviceEvent.fromJson(convertToJson(raw)); + }).toList()); + + @override + Future> getUnimportantRoomEventStatesForRoom( + List events, Room room) async { + final keys = _roomStateBox.keys.where((key) { + final tuple = MultiKey.fromString(key); + return tuple.parts.first == room.id && !events.contains(tuple.parts[1]); + }); + + final unimportantEvents = []; + for (final key in keys) { + final Map states = await _roomStateBox.get(key); + unimportantEvents.addAll( + states.values.map((raw) => Event.fromJson(convertToJson(raw), room))); + } + return unimportantEvents; + } + + @override + Future getUser(String userId, Room room) async { + final state = + await _roomMembersBox.get(MultiKey(room.id, userId).toString()); + if (state == null) return null; + return Event.fromJson(convertToJson(state), room).asUser; + } + + @override + Future> getUserDeviceKeys(Client client) => + runBenchmarked>( + 'Get all user device keys from Hive', () async { + final deviceKeysOutdated = _userDeviceKeysOutdatedBox.keys; + if (deviceKeysOutdated.isEmpty) { + return {}; + } + final res = {}; + for (final userId in deviceKeysOutdated) { + final deviceKeysBoxKeys = _userDeviceKeysBox.keys.where((tuple) { + final tupleKey = MultiKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + final crossSigningKeysBoxKeys = + _userCrossSigningKeysBox.keys.where((tuple) { + final tupleKey = MultiKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + res[userId] = DeviceKeysList.fromDbJson( + { + 'client_id': client.id, + 'user_id': userId, + 'outdated': await _userDeviceKeysOutdatedBox.get(userId), + }, + await Future.wait(deviceKeysBoxKeys.map((key) async => + convertToJson(await _userDeviceKeysBox.get(key)))), + await Future.wait(crossSigningKeysBoxKeys.map((key) async => + convertToJson(await _userCrossSigningKeysBox.get(key)))), + client); + } + return res; + }, _userDeviceKeysBox.keys.length); + + @override + Future> getUsers(Room room) async { + final users = []; + for (final key in _roomMembersBox.keys) { + final statesKey = MultiKey.fromString(key); + if (statesKey.parts[0] != room.id) continue; + final state = await _roomMembersBox.get(key); + users.add(Event.fromJson(convertToJson(state), room).asUser); + } + return users; + } + + @override + Future insertClient( + String name, + String homeserverUrl, + String token, + String userId, + String deviceId, + String deviceName, + String prevBatch, + String olmAccount) async { + await _clientBox.put('homeserver_url', homeserverUrl); + await _clientBox.put('token', token); + await _clientBox.put('user_id', userId); + await _clientBox.put('device_id', deviceId); + await _clientBox.put('device_name', deviceName); + await _clientBox.put('prev_batch', prevBatch); + await _clientBox.put('olm_account', olmAccount); + await _clientBox.put('sync_filter_id', null); + return; + } + + @override + Future insertIntoToDeviceQueue( + String type, String txnId, String content) async { + return await _toDeviceQueueBox.add({ + 'type': type, + 'txn_id': txnId, + 'content': content, + }); + } + + @override + Future markInboundGroupSessionAsUploaded( + String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.get(sessionId.toHiveKey); + if (raw == null) { + Logs().w( + 'Tried to mark inbound group session as uploaded which was not found in the database!'); + return; + } + raw['uploaded'] = true; + await _inboundGroupSessionsBox.put(sessionId.toHiveKey, raw); + return; + } + + @override + Future markInboundGroupSessionsAsNeedingUpload() async { + for (final sessionId in _inboundGroupSessionsBox.keys) { + final raw = await _inboundGroupSessionsBox.get(sessionId); + raw['uploaded'] = false; + await _inboundGroupSessionsBox.put(sessionId, raw); + } + return; + } + + @override + Future removeEvent(String eventId, String roomId) async { + await _eventsBox.delete(MultiKey(roomId, eventId).toString()); + for (final key in _timelineFragmentsBox.keys) { + final multiKey = MultiKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + final List eventIds = await _timelineFragmentsBox.get(key) ?? []; + final prevLength = eventIds.length; + eventIds.removeWhere((id) => id == eventId); + if (eventIds.length < prevLength) { + await _timelineFragmentsBox.put(key, eventIds); + } + } + return; + } + + @override + Future removeOutboundGroupSession(String roomId) async { + await _outboundGroupSessionsBox.delete(roomId.toHiveKey); + return; + } + + @override + Future removeUserCrossSigningKey( + String userId, String publicKey) async { + await _userCrossSigningKeysBox + .delete(MultiKey(userId, publicKey).toString()); + return; + } + + @override + Future removeUserDeviceKey(String userId, String deviceId) async { + await _userDeviceKeysBox.delete(MultiKey(userId, deviceId).toString()); + return; + } + + @override + Future resetNotificationCount(String roomId) async { + final raw = await _roomsBox.get(roomId.toHiveKey); + if (raw == null) return; + raw['notification_count'] = raw['highlight_count'] = 0; + await _roomsBox.put(roomId.toHiveKey, raw); + return; + } + + @override + Future setBlockedUserCrossSigningKey( + bool blocked, String userId, String publicKey) async { + final raw = await _userCrossSigningKeysBox + .get(MultiKey(userId, publicKey).toString()); + raw['blocked'] = blocked; + await _userCrossSigningKeysBox.put( + MultiKey(userId, publicKey).toString(), + raw, + ); + return; + } + + @override + Future setBlockedUserDeviceKey( + bool blocked, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(MultiKey(userId, deviceId).toString()); + raw['blocked'] = blocked; + await _userDeviceKeysBox.put( + MultiKey(userId, deviceId).toString(), + raw, + ); + return; + } + + @override + Future setLastActiveUserDeviceKey( + int lastActive, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(MultiKey(userId, deviceId).toString()); + raw['last_active'] = lastActive; + await _userDeviceKeysBox.put( + MultiKey(userId, deviceId).toString(), + raw, + ); + } + + @override + Future setLastSentMessageUserDeviceKey( + String lastSentMessage, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(MultiKey(userId, deviceId).toString()); + raw['last_sent_message'] = lastSentMessage; + await _userDeviceKeysBox.put( + MultiKey(userId, deviceId).toString(), + raw, + ); + } + + @override + Future setRoomPrevBatch(String prevBatch, String roomId) async { + final raw = await _roomsBox.get(roomId.toHiveKey); + if (raw == null) return; + final room = Room.fromJson(convertToJson(raw)); + room.prev_batch = prevBatch; + await _roomsBox.put(roomId.toHiveKey, room.toJson()); + return; + } + + @override + Future setVerifiedUserCrossSigningKey( + bool verified, String userId, String publicKey) async { + final raw = (await _userCrossSigningKeysBox + .get(MultiKey(userId, publicKey).toString()) as Map?) ?? + {}; + raw['verified'] = verified; + await _userCrossSigningKeysBox.put( + MultiKey(userId, publicKey).toString(), + raw, + ); + return; + } + + @override + Future setVerifiedUserDeviceKey( + bool verified, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(MultiKey(userId, deviceId).toString()); + raw['verified'] = verified; + await _userDeviceKeysBox.put( + MultiKey(userId, deviceId).toString(), + raw, + ); + return; + } + + @override + Future storeAccountData(String type, String content) async { + await _accountDataBox.put( + type.toHiveKey, convertToJson(jsonDecode(content))); + return; + } + + @override + Future storeEventUpdate(EventUpdate eventUpdate) async { + // Ephemerals should not be stored + if (eventUpdate.type == EventUpdateType.ephemeral) return; + + // In case of this is a redaction event + if (eventUpdate.content['type'] == EventTypes.Redaction) { + final tmpRoom = Room(id: eventUpdate.roomID); + final event = await getEventById(eventUpdate.content['redacts'], tmpRoom); + if (event != null) { + event.setRedactionEvent(Event.fromJson(eventUpdate.content, tmpRoom)); + await _eventsBox.put( + MultiKey(eventUpdate.roomID, event.eventId).toString(), + event.toJson()); + } + } + + // Store a common message event + if ({EventUpdateType.timeline, EventUpdateType.history} + .contains(eventUpdate.type)) { + final eventId = eventUpdate.content['event_id']; + // Is this ID already in the store? + final prevEvent = _eventsBox + .containsKey(MultiKey(eventUpdate.roomID, eventId).toString()) + ? Event.fromJson( + convertToJson(await _eventsBox + .get(MultiKey(eventUpdate.roomID, eventId).toString())), + null) + : null; + + // calculate the status + final newStatus = eventStatusFromInt( + eventUpdate.content.tryGet('status') ?? + eventUpdate.content + .tryGetMap('unsigned') + ?.tryGet(messageSendingStatusKey) ?? + EventStatus.synced.intValue, + ); + + // Is this the response to a sending event which is already synced? Then + // there is nothing to do here. + if (!newStatus.isSynced && + prevEvent != null && + prevEvent.status.isSynced) { + return; + } + + final status = + newStatus.isError || prevEvent == null || prevEvent.status != null + ? newStatus + : latestEventStatus( + prevEvent.status, + newStatus, + ); + + // Add the status and the sort order to the content so it get stored + eventUpdate.content['unsigned'] ??= {}; + eventUpdate.content['unsigned'][messageSendingStatusKey] = + eventUpdate.content['status'] = status.intValue; + + // In case this event has sent from this account we have a transaction ID + final transactionId = eventUpdate.content + .tryGetMap('unsigned') + ?.tryGet('transaction_id'); + + await _eventsBox.put(MultiKey(eventUpdate.roomID, eventId).toString(), + eventUpdate.content); + + // Update timeline fragments + final key = MultiKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING') + .toString(); + + final List eventIds = (await _timelineFragmentsBox.get(key) ?? []); + + if (!eventIds.contains(eventId)) { + if (eventUpdate.type == EventUpdateType.history) { + eventIds.add(eventId); + } else { + eventIds.insert(0, eventId); + } + await _timelineFragmentsBox.put(key, eventIds); + } else if (status.isSynced && + prevEvent != null && + prevEvent.status.isSent && + eventUpdate.type != EventUpdateType.history) { + // Status changes from 1 -> 2? Make sure event is correctly sorted. + eventIds.remove(eventId); + eventIds.insert(0, eventId); + } + + // If event comes from server timeline, remove sending events with this ID + if (status.isSent) { + final key = MultiKey(eventUpdate.roomID, 'SENDING').toString(); + final List eventIds = (await _timelineFragmentsBox.get(key) ?? []); + final i = eventIds.indexWhere((id) => id == eventId); + if (i != -1) { + await _timelineFragmentsBox.put(key, eventIds..removeAt(i)); + } + } + + // Is there a transaction id? Then delete the event with this id. + if (!status.isError && !status.isSending && transactionId != null) { + await removeEvent(transactionId, eventUpdate.roomID); + } + } + + // Store a common state event + if ({EventUpdateType.timeline, EventUpdateType.state} + .contains(eventUpdate.type)) { + if (eventUpdate.content['type'] == EventTypes.RoomMember) { + await _roomMembersBox.put( + MultiKey( + eventUpdate.roomID, + eventUpdate.content['state_key'], + ).toString(), + eventUpdate.content); + } else { + final key = MultiKey( + eventUpdate.roomID, + eventUpdate.content['type'], + ).toString(); + final Map stateMap = await _roomStateBox.get(key) ?? {}; + // store state events and new messages, that either are not an edit or an edit of the lastest message + // An edit is an event, that has an edit relation to the latest event. In some cases for the second edit, we need to compare if both have an edit relation to the same event instead. + if (eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') == + null) { + stateMap[eventUpdate.content['state_key']] = eventUpdate.content; + await _roomStateBox.put(key, stateMap); + } else { + final editedEventRelationshipEventId = eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') + ?.tryGet('event_id'); + if (eventUpdate.content['type'] != EventTypes.Message || + eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') + ?.tryGet('rel_type') != + RelationshipTypes.edit || + editedEventRelationshipEventId == stateMap['']?.eventId || + ((stateMap['']?.relationshipType == RelationshipTypes.edit && + editedEventRelationshipEventId == + stateMap['']?.relationshipEventId))) { + stateMap[eventUpdate.content['state_key']] = eventUpdate.content; + await _roomStateBox.put(key, stateMap); + } + } + } + } + + // Store a room account data event + if (eventUpdate.type == EventUpdateType.accountData) { + await _roomAccountDataBox.put( + MultiKey( + eventUpdate.roomID, + eventUpdate.content['type'], + ).toString(), + eventUpdate.content, + ); + } + } + + @override + Future storeFile(Uri mxcUri, Uint8List bytes, int time) async { + return; + } + + @override + Future storeInboundGroupSession( + String roomId, + String sessionId, + String pickle, + String content, + String indexes, + String allowedAtIndex, + String senderKey, + String senderClaimedKey) async { + await _inboundGroupSessionsBox.put( + sessionId.toHiveKey, + StoredInboundGroupSession( + roomId: roomId, + sessionId: sessionId, + pickle: pickle, + content: content, + indexes: indexes, + allowedAtIndex: allowedAtIndex, + senderKey: senderKey, + senderClaimedKeys: senderClaimedKey, + uploaded: false, + ).toJson()); + return; + } + + @override + Future storeOutboundGroupSession( + String roomId, String pickle, String deviceIds, int creationTime) async { + await _outboundGroupSessionsBox.put(roomId.toHiveKey, { + 'room_id': roomId, + 'pickle': pickle, + 'device_ids': deviceIds, + 'creation_time': creationTime, + }); + return; + } + + @override + Future storePrevBatch( + String prevBatch, + ) async { + if (_clientBox.keys.isEmpty) return; + await _clientBox.put('prev_batch', prevBatch); + return; + } + + @override + Future storeRoomUpdate(String roomId, SyncRoomUpdate roomUpdate, + [dynamic _]) async { + // Leave room if membership is leave + if (roomUpdate is LeftRoomUpdate) { + await forgetRoom(roomId); + return; + } + final membership = roomUpdate is LeftRoomUpdate + ? Membership.leave + : roomUpdate is InvitedRoomUpdate + ? Membership.invite + : Membership.join; + // Make sure room exists + if (!_roomsBox.containsKey(roomId.toHiveKey)) { + await _roomsBox.put( + roomId.toHiveKey, + roomUpdate is JoinedRoomUpdate + ? Room( + id: roomId, + membership: membership, + highlightCount: + roomUpdate.unreadNotifications?.highlightCount?.toInt(), + notificationCount: roomUpdate + .unreadNotifications?.notificationCount + ?.toInt(), + prev_batch: roomUpdate.timeline?.prevBatch, + summary: roomUpdate.summary, + ).toJson() + : Room( + id: roomId, + membership: membership, + ).toJson()); + } else if (roomUpdate is JoinedRoomUpdate) { + final currentRawRoom = await _roomsBox.get(roomId.toHiveKey); + final currentRoom = Room.fromJson(convertToJson(currentRawRoom)); + await _roomsBox.put( + roomId.toHiveKey, + Room( + id: roomId, + membership: membership, + highlightCount: + roomUpdate.unreadNotifications?.highlightCount?.toInt() ?? + currentRoom.highlightCount, + notificationCount: + roomUpdate.unreadNotifications?.notificationCount?.toInt() ?? + currentRoom.notificationCount, + prev_batch: + roomUpdate.timeline?.prevBatch ?? currentRoom.prev_batch, + summary: RoomSummary.fromJson(currentRoom.summary.toJson() + ..addAll(roomUpdate.summary?.toJson() ?? {})), + ).toJson()); + } + + // Is the timeline limited? Then all previous messages should be + // removed from the database! + if (roomUpdate is JoinedRoomUpdate && + roomUpdate.timeline?.limited == true) { + await _timelineFragmentsBox.delete(MultiKey(roomId, '').toString()); + } + } + + @override + Future storeSSSSCache( + String type, String keyId, String ciphertext, String content) async { + await _ssssCacheBox.put( + type, + SSSSCache( + type: type, + keyId: keyId, + ciphertext: ciphertext, + content: content, + ).toJson()); + } + + @override + Future storeSyncFilterId( + String syncFilterId, + ) async { + await _clientBox.put('sync_filter_id', syncFilterId); + } + + @override + Future storeUserCrossSigningKey(String userId, String publicKey, + String content, bool verified, bool blocked) async { + await _userCrossSigningKeysBox.put( + MultiKey(userId, publicKey).toString(), + { + 'user_id': userId, + 'public_key': publicKey, + 'content': content, + 'verified': verified, + 'blocked': blocked, + }, + ); + } + + @override + Future storeUserDeviceKey(String userId, String deviceId, + String content, bool verified, bool blocked, int lastActive) async { + await _userDeviceKeysBox.put(MultiKey(userId, deviceId).toString(), { + 'user_id': userId, + 'device_id': deviceId, + 'content': content, + 'verified': verified, + 'blocked': blocked, + 'last_active': lastActive, + 'last_sent_message': '', + }); + return; + } + + @override + Future storeUserDeviceKeysInfo(String userId, bool outdated) async { + await _userDeviceKeysOutdatedBox.put(userId.toHiveKey, outdated); + return; + } + + Completer? _transactionLock; + final _transactionZones = {}; + + @override + Future transaction(Future Function() action) async { + // we want transactions to lock, however NOT if transactoins are run inside of each other. + // to be able to do this, we use dart zones (https://dart.dev/articles/archive/zones). + // _transactionZones holds a set of all zones which are currently running a transaction. + // _transactionLock holds the lock. + + // first we try to determine if we are inside of a transaction currently + var isInTransaction = false; + Zone? zone = Zone.current; + // for that we keep on iterating to the parent zone until there is either no zone anymore + // or we have found a zone inside of _transactionZones. + while (zone != null) { + if (_transactionZones.contains(zone)) { + isInTransaction = true; + break; + } + zone = zone.parent; + } + // if we are inside a transaction....just run the action + if (isInTransaction) { + return await action(); + } + // if we are *not* in a transaction, time to wait for the lock! + while (_transactionLock != null) { + await _transactionLock!.future; + } + // claim the lock + final lock = Completer(); + _transactionLock = lock; + try { + // run the action inside of a new zone + return await runZoned(() async { + try { + // don't forget to add the new zone to _transactionZones! + _transactionZones.add(Zone.current); + return await action(); + } finally { + // aaaand remove the zone from _transactionZones again + _transactionZones.remove(Zone.current); + } + }); + } finally { + // aaaand finally release the lock + _transactionLock = null; + lock.complete(); + } + } + + @override + Future updateClient( + String homeserverUrl, + String token, + String userId, + String deviceId, + String deviceName, + String prevBatch, + String olmAccount, + ) async { + await _clientBox.put('homeserver_url', homeserverUrl); + await _clientBox.put('token', token); + await _clientBox.put('user_id', userId); + await _clientBox.put('device_id', deviceId); + await _clientBox.put('device_name', deviceName); + await _clientBox.put('prev_batch', prevBatch); + await _clientBox.put('olm_account', olmAccount); + return; + } + + @override + Future updateClientKeys( + String olmAccount, + ) async { + await _clientBox.put('olm_account', olmAccount); + return; + } + + @override + Future updateInboundGroupSessionAllowedAtIndex( + String allowedAtIndex, String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.get(sessionId.toHiveKey); + if (raw == null) { + Logs().w( + 'Tried to update inbound group session as uploaded which wasnt found in the database!'); + return; + } + raw['allowed_at_index'] = allowedAtIndex; + await _inboundGroupSessionsBox.put(sessionId.toHiveKey, raw); + return; + } + + @override + Future updateInboundGroupSessionIndexes( + String indexes, String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.get(sessionId.toHiveKey); + if (raw == null) { + Logs().w( + 'Tried to update inbound group session indexes of a session which was not found in the database!'); + return; + } + raw['indexes'] = indexes; + await _inboundGroupSessionsBox.put(sessionId.toHiveKey, raw); + return; + } + + @override + Future updateRoomSortOrder( + double oldestSortOrder, double newestSortOrder, String roomId) async { + final raw = await _roomsBox.get(roomId.toHiveKey); + raw['oldest_sort_order'] = oldestSortOrder; + raw['newest_sort_order'] = newestSortOrder; + await _roomsBox.put(roomId.toHiveKey, raw); + return; + } + + @override + Future> getAllInboundGroupSessions() async { + final rawSessions = await Future.wait(_inboundGroupSessionsBox.keys + .map((key) => _inboundGroupSessionsBox.get(key))); + return rawSessions + .map((raw) => StoredInboundGroupSession.fromJson(convertToJson(raw))) + .toList(); + } + + @override + Future addSeenDeviceId( + String userId, + String deviceId, + String publicKeysHash, + ) => + _seenDeviceIdsBox.put( + MultiKey(userId, deviceId).toString(), publicKeysHash); + + @override + Future addSeenPublicKey( + String publicKey, + String deviceId, + ) => + _seenDeviceKeysBox.put(publicKey.toHiveKey, deviceId); + + @override + Future deviceIdSeen(userId, deviceId) async { + final raw = + await _seenDeviceIdsBox.get(MultiKey(userId, deviceId).toString()); + if (raw == null) return null; + return raw as String; + } + + @override + Future publicKeySeen(String publicKey) async { + final raw = await _seenDeviceKeysBox.get(publicKey.toHiveKey); + if (raw == null) return null; + return raw as String; + } +} + +dynamic _castValue(dynamic value) { + if (value is Map) { + return convertToJson(value); + } + if (value is List) { + return value.map(_castValue).toList(); + } + return value; +} + +/// Hive always gives back an `_InternalLinkedHasMap`. This +/// creates a deep copy of the json and makes sure that the format is always +/// `Map`. +Map convertToJson(Map map) { + final copy = Map.from(map); + for (final entry in copy.entries) { + copy[entry.key] = _castValue(entry.value); + } + return copy; +} + +class MultiKey { + final List parts; + + MultiKey(String key1, [String? key2, String? key3]) + : parts = [ + key1, + if (key2 != null) key2, + if (key3 != null) key3, + ]; + + const MultiKey.byParts(this.parts); + + MultiKey.fromString(String multiKeyString) + : parts = multiKeyString.split('|').map((s) => s.fromHiveKey).toList(); + + @override + String toString() => parts.map((s) => s.toHiveKey).join('|'); + + @override + bool operator ==(other) => parts.toString() == other.toString(); +} + +extension HiveKeyExtension on String { + String get toHiveKey => isValidMatrixId + ? '$sigil${Uri.encodeComponent(localpart!)}:${Uri.encodeComponent(domain!)}' + : Uri.encodeComponent(this); +} + +extension FromHiveKeyExtension on String { + String get fromHiveKey => Uri.decodeComponent(this); +} diff --git a/lib/src/event.dart b/lib/src/event.dart index 91ab9eff..853bd683 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -23,12 +23,13 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import '../matrix.dart'; +import 'event_status.dart'; import 'room.dart'; +import 'utils/crypto/encrypted_file.dart'; +import 'utils/event_localizations.dart'; +import 'utils/html_to_text.dart'; import 'utils/matrix_localizations.dart'; import 'utils/receipt.dart'; -import 'utils/event_localizations.dart'; -import 'utils/crypto/encrypted_file.dart'; -import 'utils/html_to_text.dart'; abstract class RelationshipTypes { static const String reply = 'm.in_reply_to'; @@ -53,21 +54,9 @@ class Event extends MatrixEvent { final Room room; /// The status of this event. - /// -1=ERROR - /// 0=SENDING - /// 1=SENT - /// 2=TIMELINE - /// 3=ROOM_STATE - int status; + EventStatus status; - static const int defaultStatus = 2; - static const Map statusType = { - 'error': -1, - 'sending': 0, - 'sent': 1, - 'timeline': 2, - 'roomState': 3, - }; + static const EventStatus defaultStatus = EventStatus.synced; /// Optional. The event that redacted this event, if any. Otherwise null. Event get redactedBecause => @@ -98,7 +87,7 @@ class Event extends MatrixEvent { this.roomId = roomId ?? room?.id; this.senderId = senderId; this.unsigned = unsigned; - // synapse unfortunatley isn't following the spec and tosses the prev_content + // synapse unfortunately isn't following the spec and tosses the prev_content // into the unsigned block. // Currently we are facing a very strange bug in web which is impossible to debug. // It may be because of this line so we put this in try-catch until we can fix it. @@ -119,7 +108,7 @@ class Event extends MatrixEvent { // Mark event as failed to send if status is `sending` and event is older // than the timeout. This should not happen with the deprecated Moor // database! - if (status == 0 && room?.client?.database != null) { + if (status.isSending && room?.client?.database != null) { // Age of this event in milliseconds final age = DateTime.now().millisecondsSinceEpoch - originServerTs.millisecondsSinceEpoch; @@ -128,7 +117,7 @@ class Event extends MatrixEvent { // Update this event in database and open timelines final json = toJson(); json['unsigned'] ??= {}; - json['unsigned'][messageSendingStatusKey] = -1; + json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue; room.client.handleSync(SyncUpdate(nextBatch: '') ..rooms = (RoomsUpdate() ..join = ({}..[room.id] = @@ -154,7 +143,7 @@ class Event extends MatrixEvent { factory Event.fromMatrixEvent( MatrixEvent matrixEvent, Room room, { - int status, + EventStatus status, }) => Event( status: status, @@ -179,9 +168,9 @@ class Event extends MatrixEvent { final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']); final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']); return Event( - status: jsonPayload['status'] ?? + status: eventStatusFromInt(jsonPayload['status'] ?? unsigned[messageSendingStatusKey] ?? - defaultStatus, + defaultStatus.intValue), stateKey: jsonPayload['state_key'], prevContent: prevContent, content: content, @@ -301,10 +290,11 @@ class Event extends MatrixEvent { .toList(); } - /// Removes this event if the status is < 1. This event will just be removed - /// from the database and the timelines. Returns false if not removed. + /// Removes this event if the status is [sending], [error] or [removed]. + /// This event will just be removed from the database and the timelines. + /// Returns [false] if not removed. Future remove() async { - if (status < 1) { + if (!status.isSent) { await room.client.database?.removeEvent(eventId, room.id); room.client.onEvent.add(EventUpdate( @@ -312,7 +302,7 @@ class Event extends MatrixEvent { type: EventUpdateType.timeline, content: { 'event_id': eventId, - 'status': -2, + 'status': EventStatus.removed.intValue, 'content': {'body': 'Removed...'} }, )); @@ -321,9 +311,9 @@ class Event extends MatrixEvent { return false; } - /// Try to send this event again. Only works with events of status -1. + /// Try to send this event again. Only works with events of `EventStatus.isError`. Future sendAgain({String txid}) async { - if (status != -1) return null; + if (!status.isError) return null; // we do not remove the event here. It will automatically be updated // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2 final newEventId = await room.sendEvent( @@ -382,7 +372,7 @@ class Event extends MatrixEvent { /// Returns if a file events thumbnail is encrypted bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map; - /// Gets the mimetipe of the attachment of a file event, or a blank string if not present + /// Gets the mimetype of the attachment of a file event, or a blank string if not present String get attachmentMimetype => infoMap['mimetype'] is String ? infoMap['mimetype'].toLowerCase() : (content['file'] is Map && content['file']['mimetype'] is String @@ -397,16 +387,16 @@ class Event extends MatrixEvent { ? infoMap['thumbnail_file']['mimetype'] : ''); - /// Gets the underyling mxc url of an attachment of a file event, or null if not present + /// Gets the underlying mxc url of an attachment of a file event, or null if not present Uri get attachmentMxcUrl => Uri.parse( isAttachmentEncrypted ? content['file']['url'] : content['url']); - /// Gets the underyling mxc url of a thumbnail of a file event, or null if not present + /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present Uri get thumbnailMxcUrl => Uri.parse(isThumbnailEncrypted ? infoMap['thumbnail_file']['url'] : infoMap['thumbnail_url']); - /// Gets the mxc url of an attachemnt/thumbnail of a file event, taking sizes into account, or null if not present + /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present Uri attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) { if (getThumbnail && infoMap['size'] is int && @@ -493,7 +483,7 @@ class Event extends MatrixEvent { return uint8list != null; } - /// Downloads (and decryptes if necessary) the attachment of this + /// Downloads (and decrypts if necessary) the attachment of this /// event and returns it as a [MatrixFile]. If this event doesn't /// contain an attachment, this throws an error. Set [getThumbnail] to /// true to download the thumbnail instead. @@ -671,7 +661,7 @@ class Event extends MatrixEvent { return null; } - /// Get wether this event has aggregated events from a certain [type] + /// Get whether this event has aggregated events from a certain [type] /// To be able to do that you need to pass a [timeline] bool hasAggregatedEvents(Timeline timeline, String type) => timeline.aggregatedEvents.containsKey(eventId) && diff --git a/lib/src/event_status.dart b/lib/src/event_status.dart new file mode 100644 index 00000000..9bec269f --- /dev/null +++ b/lib/src/event_status.dart @@ -0,0 +1,70 @@ +/// Defines event status: +/// - removed +/// - error: (http request failed) +/// - sending: (http request started) +/// - sent: (http request successful) +/// - synced: (event came from sync loop) +/// - roomState +enum EventStatus { + removed, + error, + sending, + sent, + synced, + roomState, +} + +/// Returns `EventStatusEnum` value from `intValue`. +/// +/// - -2 == removed; +/// - -1 == error; +/// - 0 == sending; +/// - 1 == sent; +/// - 2 == synced; +/// - 3 == roomState; +EventStatus eventStatusFromInt(int intValue) => + EventStatus.values[intValue + 2]; + +/// Takes two [EventStatus] values and returns the one with higher +/// (better in terms of message sending) status. +EventStatus latestEventStatus(EventStatus status1, EventStatus status2) => + status1.intValue > status2.intValue ? status1 : status2; + +extension EventStatusExtension on EventStatus { + /// Returns int value of the event status. + /// + /// - -2 == removed; + /// - -1 == error; + /// - 0 == sending; + /// - 1 == sent; + /// - 2 == synced; + /// - 3 == roomState; + int get intValue => (index - 2); + + /// Return `true` if the `EventStatus` equals `removed`. + bool get isRemoved => this == EventStatus.removed; + + /// Return `true` if the `EventStatus` equals `error`. + bool get isError => this == EventStatus.error; + + /// Return `true` if the `EventStatus` equals `sending`. + bool get isSending => this == EventStatus.sending; + + /// Return `true` if the `EventStatus` equals `roomState`. + bool get isRoomState => this == EventStatus.roomState; + + /// Returns `true` if the status is sent or later: + /// [EventStatus.sent], [EventStatus.synced] or [EventStatus.roomState]. + bool get isSent => [ + EventStatus.sent, + EventStatus.synced, + EventStatus.roomState + ].contains(this); + + /// Returns `true` if the status is `synced` or `roomState`: + /// [EventStatus.synced] or [EventStatus.roomState]. + bool get isSynced => [ + EventStatus.synced, + EventStatus.roomState, + ].contains(this); +} diff --git a/lib/src/room.dart b/lib/src/room.dart index c935aa03..f2cb7a9a 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -26,6 +26,7 @@ import 'package:matrix/src/utils/space_child.dart'; import '../matrix.dart'; import 'client.dart'; import 'event.dart'; +import 'event_status.dart'; import 'timeline.dart'; import 'user.dart'; import 'voip_content.dart'; @@ -819,7 +820,7 @@ class Room { senderId: client.userID, originServerTs: sentDate, unsigned: { - messageSendingStatusKey: 0, + messageSendingStatusKey: EventStatus.sending.intValue, 'transaction_id': messageID, }, ) @@ -846,14 +847,14 @@ class Room { } else { Logs().w('[Client] Problem while sending message', e, s); syncUpdate.rooms.join.values.first.timeline.events.first - .unsigned[messageSendingStatusKey] = -1; + .unsigned[messageSendingStatusKey] = EventStatus.error.intValue; await _handleFakeSync(syncUpdate); return null; } } } syncUpdate.rooms.join.values.first.timeline.events.first - .unsigned[messageSendingStatusKey] = 1; + .unsigned[messageSendingStatusKey] = EventStatus.sent.intValue; syncUpdate.rooms.join.values.first.timeline.events.first.eventId = res; await _handleFakeSync(syncUpdate); diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index 09427a83..6ace2a09 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -20,6 +20,7 @@ import 'dart:async'; import '../matrix.dart'; import 'event.dart'; +import 'event_status.dart'; import 'room.dart'; import 'utils/event_update.dart'; @@ -243,11 +244,11 @@ class Timeline { eventUpdate.type != EventUpdateType.history) { return; } - final status = eventUpdate.content['status'] ?? + final status = eventStatusFromInt(eventUpdate.content['status'] ?? (eventUpdate.content['unsigned'] is Map ? eventUpdate.content['unsigned'][messageSendingStatusKey] : null) ?? - 2; + EventStatus.synced.intValue); // Redaction events are handled as modification for existing events. if (eventUpdate.content['type'] == EventTypes.Redaction) { final eventId = _findEvent(event_id: eventUpdate.content['redacts']); @@ -258,7 +259,7 @@ class Timeline { room, )); } - } else if (status == -2) { + } else if (status.isRemoved) { final i = _findEvent(event_id: eventUpdate.content['event_id']); if (i < events.length) { removeAggregatedEvent(events[i]); @@ -279,7 +280,8 @@ class Timeline { room, ); // do we preserve the status? we should allow 0 -> -1 updates and status increases - if (status < oldStatus && !(status == -1 && oldStatus == 0)) { + if ((latestEventStatus(status, oldStatus) == oldStatus) && + !(status.isError && oldStatus.isSending)) { events[i].status = oldStatus; } addAggregatedEvent(events[i]); @@ -315,7 +317,7 @@ class Timeline { extension on List { int get firstIndexWhereNotError { if (isEmpty) return 0; - final index = indexWhere((e) => e.status != -1); + final index = indexWhere((event) => !event.status.isError); if (index == -1) return length; return index; } diff --git a/lib/src/timeline.dart.orig b/lib/src/timeline.dart.orig new file mode 100644 index 00000000..fa8925bd --- /dev/null +++ b/lib/src/timeline.dart.orig @@ -0,0 +1,329 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; + +import '../matrix.dart'; +import 'event.dart'; +import 'event_status.dart'; +import 'room.dart'; +import 'utils/event_update.dart'; + +/// Represents the timeline of a room. The callback [onUpdate] will be triggered +/// automatically. The initial +/// event list will be retreived when created by the `room.getTimeline()` method. +class Timeline { + final Room room; + final List events; + + /// Map of event ID to map of type to set of aggregated events + final Map>> aggregatedEvents = {}; + + final void Function()? onUpdate; + final void Function(int insertID)? onInsert; + + StreamSubscription? sub; + StreamSubscription? roomSub; + StreamSubscription? sessionIdReceivedSub; + bool isRequestingHistory = false; + + final Map _eventCache = {}; + + /// Searches for the event in this timeline. If not + /// found, requests from the server. Requested events + /// are cached. + Future getEventById(String id) async { + for (final event in events) { + if (event.eventId == id) return event; + } + if (_eventCache.containsKey(id)) return _eventCache[id]; + final requestedEvent = await room.getEventById(id); + if (requestedEvent == null) return null; + _eventCache[id] = requestedEvent; + return _eventCache[id]; + } + + // When fetching history, we will collect them into the `_historyUpdates` set + // first, and then only process all events at once, once we have the full history. + // This ensures that the entire history fetching only triggers `onUpdate` only *once*, + // even if /sync's complete while history is being proccessed. + bool _collectHistoryUpdates = false; + + bool get canRequestHistory { + if (events.isEmpty) return true; + return events.last.type != EventTypes.RoomCreate; + } + + Future requestHistory( + {int historyCount = Room.defaultHistoryCount}) async { + if (isRequestingHistory) { + return; + } + isRequestingHistory = true; + onUpdate?.call(); + + try { + // Look up for events in hive first + final eventsFromStore = await room.client.database?.getEventList( + room, + start: events.length, + limit: Room.defaultHistoryCount, + ); + if (eventsFromStore != null && eventsFromStore.isNotEmpty) { + events.addAll(eventsFromStore); + } else { + Logs().v('No more events found in the store. Request from server...'); + await room.requestHistory( + historyCount: historyCount, + onHistoryReceived: () { + _collectHistoryUpdates = true; + }, + ); + } + } finally { + _collectHistoryUpdates = false; + isRequestingHistory = false; + onUpdate?.call(); + } + } + + Timeline( + {required this.room, List? events, this.onUpdate, this.onInsert}) + : events = events ?? [] { + sub = room.client.onEvent.stream.listen(_handleEventUpdate); + // If the timeline is limited we want to clear our events cache + roomSub = room.client.onSync.stream + .where((sync) => sync.rooms?.join?[room.id]?.timeline?.limited == true) + .listen((_) { + this.events.clear(); + aggregatedEvents.clear(); + }); + sessionIdReceivedSub = + room.onSessionKeyReceived.stream.listen(_sessionKeyReceived); + + // we want to populate our aggregated events + for (final e in this.events) { + addAggregatedEvent(e); + } + } + + /// Don't forget to call this before you dismiss this object! + void cancelSubscriptions() { + sub?.cancel(); + roomSub?.cancel(); + sessionIdReceivedSub?.cancel(); + } + + void _sessionKeyReceived(String sessionId) async { + var decryptAtLeastOneEvent = false; + final decryptFn = () async { + if (!room.client.encryptionEnabled) { + return; + } + for (var i = 0; i < events.length; i++) { + if (events[i].type == EventTypes.Encrypted && + events[i].messageType == MessageTypes.BadEncrypted && + events[i].content['session_id'] == sessionId) { + events[i] = await room.client.encryption + .decryptRoomEvent(room.id, events[i], store: true); + if (events[i].type != EventTypes.Encrypted) { + decryptAtLeastOneEvent = true; + } + } + } + }; + if (room.client.database != null) { + await room.client.database.transaction(decryptFn); + } else { + await decryptFn(); + } + if (decryptAtLeastOneEvent) onUpdate?.call(); + } + + /// Request the keys for undecryptable events of this timeline + void requestKeys() { + for (final event in events) { + if (event.type == EventTypes.Encrypted && + event.messageType == MessageTypes.BadEncrypted && + event.content['can_request_session'] == true) { + try { + room.client.encryption.keyManager.maybeAutoRequest(room.id, + event.content['session_id'], event.content['sender_key']); + } catch (_) { + // dispose + } + } + } + } + + int _findEvent({String? event_id, String? unsigned_txid}) { + // we want to find any existing event where either the passed event_id or the passed unsigned_txid + // matches either the event_id or transaction_id of the existing event. + // For that we create two sets, searchNeedle, what we search, and searchHaystack, where we check if there is a match. + // Now, after having these two sets, if the intersect between them is non-empty, we know that we have at least one match in one pair, + // thus meaning we found our element. + final searchNeedle = {}; + if (event_id != null) { + searchNeedle.add(event_id); + } + if (unsigned_txid != null) { + searchNeedle.add(unsigned_txid); + } + int i; + for (i = 0; i < events.length; i++) { + final searchHaystack = {}; + if (events[i].eventId != null) { + searchHaystack.add(events[i].eventId); + } + if (events[i].unsigned != null && + events[i].unsigned['transaction_id'] != null) { + searchHaystack.add(events[i].unsigned['transaction_id']); + } + if (searchNeedle.intersection(searchHaystack).isNotEmpty) { + break; + } + } + return i; + } + + void _removeEventFromSet(Set eventSet, Event event) { + eventSet.removeWhere((e) => + e.matchesEventOrTransactionId(event.eventId) || + (event.unsigned != null && + e.matchesEventOrTransactionId(event.unsigned['transaction_id']))); + } + + void addAggregatedEvent(Event event) { + // we want to add an event to the aggregation tree + if (event.relationshipType == null || event.relationshipEventId == null) { + return; // nothing to do + } + if (!aggregatedEvents.containsKey(event.relationshipEventId)) { + aggregatedEvents[event.relationshipEventId] = >{}; + } + final events = (aggregatedEvents[event.relationshipEventId] ??= + >{})[event.relationshipType] ??= {}; + // remove a potential old event + _removeEventFromSet(events, event); + // add the new one + events.add(event); + } + + void removeAggregatedEvent(Event event) { + aggregatedEvents.remove(event.eventId); + if (event.unsigned != null) { + aggregatedEvents.remove(event.unsigned['transaction_id']); + } + for (final types in aggregatedEvents.values) { + for (final events in types.values) { + _removeEventFromSet(events, event); + } + } + } + + void _handleEventUpdate(EventUpdate eventUpdate, {bool update = true}) { + try { + if (eventUpdate.roomID != room.id) return; + + if (eventUpdate.type != EventUpdateType.timeline && + eventUpdate.type != EventUpdateType.history) { + return; + } + final status = eventStatusFromInt(eventUpdate.content['status'] ?? + (eventUpdate.content['unsigned'] is Map + ? eventUpdate.content['unsigned'][messageSendingStatusKey] + : null) ?? + EventStatus.synced.intValue); + // Redaction events are handled as modification for existing events. + if (eventUpdate.content['type'] == EventTypes.Redaction) { + final eventId = _findEvent(event_id: eventUpdate.content['redacts']); + if (eventId < events.length) { + removeAggregatedEvent(events[eventId]); + events[eventId].setRedactionEvent(Event.fromJson( + eventUpdate.content, + room, + )); + } + } else if (status.isRemoved) { + final i = _findEvent(event_id: eventUpdate.content['event_id']); + if (i < events.length) { + removeAggregatedEvent(events[i]); + events.removeAt(i); + } + } else { + final i = _findEvent( + event_id: eventUpdate.content['event_id'], + unsigned_txid: eventUpdate.content['unsigned'] is Map + ? eventUpdate.content['unsigned']['transaction_id'] + : null); + + if (i < events.length) { + // if the old status is larger than the new one, we also want to preserve the old status + final oldStatus = events[i].status; + events[i] = Event.fromJson( + eventUpdate.content, + room, + ); + // do we preserve the status? we should allow 0 -> -1 updates and status increases + if ((latestEventStatus(status, oldStatus) == oldStatus) && + !(status.isError && oldStatus.isSending)) { + events[i].status = oldStatus; + } + addAggregatedEvent(events[i]); + } else { + final newEvent = Event.fromJson( + eventUpdate.content, + room, + ); + + if (eventUpdate.type == EventUpdateType.history && + events.indexWhere( + (e) => e.eventId == eventUpdate.content['event_id']) != + -1) return; + if (eventUpdate.type == EventUpdateType.history) { + events.add(newEvent); +<<<<<<< HEAD +======= + } else if (status.isError) { + events.insert(events.firstIndexWhereNotError, newEvent); +>>>>>>> 8fe85aca09b947ce7417672ce57ebfce80f3133c + } else { + events.insert(events.firstIndexWhereNotError, newEvent); + } + + addAggregatedEvent(newEvent); + onInsert?.call(0); + } + } + if (update && !_collectHistoryUpdates) { + onUpdate?.call(); + } + } catch (e, s) { + Logs().w('Handle event update failed', e, s); + } + } +} + +extension on List { + int get firstIndexWhereNotError { + if (isEmpty) return 0; + final index = indexWhere((event) => !event.status.isError); + if (index == -1) return length; + return index; + } +} diff --git a/lib/src/user.dart.orig b/lib/src/user.dart.orig new file mode 100644 index 00000000..6954d24d --- /dev/null +++ b/lib/src/user.dart.orig @@ -0,0 +1,241 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import '../matrix.dart'; + +import 'event.dart'; +import 'room.dart'; + +/// Represents a Matrix User which may be a participant in a Matrix Room. +class User extends Event { + factory User( + String id, { + String? membership, + String? displayName, + String? avatarUrl, + Room? room, + }) { + return User.fromState( + stateKey: id, + content: { + if (membership != null) 'membership': membership, + if (displayName != null) 'displayname': displayName, + if (avatarUrl != null) 'avatar_url': avatarUrl, + }, + typeKey: EventTypes.RoomMember, + roomId: room?.id, + room: room, + originServerTs: DateTime.now(), + ); + } + + User.fromState( + {dynamic prevContent, + required String stateKey, + dynamic content, + required String typeKey, + String? eventId, + String? roomId, + String? senderId, + required DateTime originServerTs, + dynamic unsigned, + Room? room}) + : super( + stateKey: stateKey, + prevContent: prevContent, + content: content, + type: typeKey, + eventId: eventId, + roomId: roomId, + senderId: senderId, + originServerTs: originServerTs, + unsigned: unsigned, + room: room); + + /// The full qualified Matrix ID in the format @username:server.abc. + String get id => stateKey; + + /// The displayname of the user if the user has set one. +<<<<<<< HEAD + String? get displayName => +======= + String get displayName => +>>>>>>> 8fe85aca09b947ce7417672ce57ebfce80f3133c + content?.tryGet('displayname') ?? + prevContent?.tryGet('displayname'); + + /// Returns the power level of this user. + int get powerLevel => room?.getPowerLevelByUserId(id) ?? 0; + + /// The membership status of the user. One of: + /// join + /// invite + /// leave + /// ban + Membership get membership => Membership.values.firstWhere((e) { + if (content['membership'] != null) { + return e.toString() == 'Membership.' + content['membership']; + } + return false; + }, orElse: () => Membership.join); + + /// The avatar if the user has one. + Uri? get avatarUrl => content != null && content.containsKey('avatar_url') + ? (content['avatar_url'] is String + ? Uri.tryParse(content['avatar_url']) + : null) + : (prevContent != null && prevContent['avatar_url'] is String + ? Uri.tryParse(prevContent['avatar_url']) + : null); + + /// Returns the displayname or the local part of the Matrix ID if the user + /// has no displayname. If [formatLocalpart] is true, then the localpart will + /// be formatted in the way, that all "_" characters are becomming white spaces and + /// the first character of each word becomes uppercase. + /// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown + /// if there is no other displayname available. If not then this will return "Unknown user". + String calcDisplayname({ + bool? formatLocalpart, + bool? mxidLocalPartFallback, + }) { + formatLocalpart ??= room?.client?.formatLocalpart ?? true; + mxidLocalPartFallback ??= room?.client?.mxidLocalPartFallback ?? true; + final displayName = this.displayName; + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + final stateKey = this.stateKey; + if (stateKey != null && mxidLocalPartFallback) { + if (!formatLocalpart) { + return stateKey.localpart ?? ''; + } + final words = stateKey.localpart?.replaceAll('_', ' ').split(' ') ?? []; + for (var i = 0; i < words.length; i++) { + if (words[i].isNotEmpty) { + words[i] = words[i][0].toUpperCase() + words[i].substring(1); + } + } + return words.join(' '); + } + return 'Unknown user'; + } + + /// Call the Matrix API to kick this user from this room. + Future kick() => room.kick(id); + + /// Call the Matrix API to ban this user from this room. + Future ban() => room.ban(id); + + /// Call the Matrix API to unban this banned user from this room. + Future unban() => room.unban(id); + + /// Call the Matrix API to change the power level of this user. + Future setPower(int power) => room.setPower(id, power); + + /// Returns an existing direct chat ID with this user or creates a new one. + /// Returns null on error. + Future startDirectChat() => room.client.startDirectChat(id); + + /// The newest presence of this user if there is any and null if not. + Presence? get presence => room.client.presences[id]; + + /// Whether the client is able to ban/unban this user. + bool get canBan => room.canBan && powerLevel < room.ownPowerLevel; + + /// Whether the client is able to kick this user. + bool get canKick => + [Membership.join, Membership.invite].contains(membership) && + room.canKick && + powerLevel < room.ownPowerLevel; + + /// Whether the client is allowed to change the power level of this user. + /// Please be aware that you can only set the power level to at least your own! + bool get canChangePowerLevel => + room.canChangePowerLevel && powerLevel < room.ownPowerLevel; + + @override + bool operator ==(dynamic other) => (other is User && + other.id == id && + other.room == room && + other.membership == membership); + + /// Get the mention text to use in a plain text body to mention this specific user + /// in this specific room + String get mention { + // if the displayname has [ or ] or : we can't build our more fancy stuff, so fall back to the id + // [] is used for the delimitors + // If we allowed : we could get collissions with the mxid fallbacks + final displayName = this.displayName; + if (displayName == null || + displayName.isEmpty || + {'[', ']', ':'}.any(displayName.contains)) { + return id; + } + + final identifier = '@' + + // if we have non-word characters we need to surround with [] + (RegExp(r'^\w+$').hasMatch(displayName) + ? displayName + : '[$displayName]'); + + // get all the users with the same display name + final allUsersWithSameDisplayname = room.getParticipants(); + allUsersWithSameDisplayname.removeWhere((user) => + user.id == id || + (user.displayName?.isEmpty ?? true) || + user.displayName != displayName); + if (allUsersWithSameDisplayname.isEmpty) { + return identifier; + } + // ok, we have multiple users with the same display name....time to calculate a hash + final hashes = allUsersWithSameDisplayname.map((u) => _hash(u.id)); + final ourHash = _hash(id); + // hash collission...just return our own mxid again + if (hashes.contains(ourHash)) { + return id; + } + return '$identifier#$ourHash'; + } + + /// Get the mention fragments for this user. + Set get mentionFragments { + final displayName = this.displayName; + if (displayName == null || + displayName.isEmpty || + {'[', ']', ':'}.any(displayName.contains)) { + return {}; + } + final identifier = '@' + + // if we have non-word characters we need to surround with [] + (RegExp(r'^\w+$').hasMatch(displayName) + ? displayName + : '[$displayName]'); + + final hash = _hash(id); + return {identifier, '$identifier#$hash'}; + } +} + +<<<<<<< HEAD +const _maximumHashLength = 10000; +String _hash(String s) => + (s.codeUnits.fold(0, (a, b) => a + b) % _maximumHashLength).toString(); +======= +String _hash(String s) => + (s.codeUnits.fold(0, (a, b) => a + b) % 10000).toString(); +>>>>>>> 8fe85aca09b947ce7417672ce57ebfce80f3133c diff --git a/test/event_test.dart b/test/event_test.dart index ac599f53..3433be93 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -23,6 +23,7 @@ import 'dart:typed_data'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/event.dart'; +import 'package:matrix/src/event_status.dart'; import 'package:olm/olm.dart' as olm; import 'package:test/test.dart'; @@ -53,7 +54,7 @@ void main() { 'origin_server_ts': timestamp, 'type': type, 'room_id': '1234', - 'status': 2, + 'status': EventStatus.synced.intValue, 'content': contentJson, }; final client = Client('testclient', httpClient: FakeMatrixApi()); @@ -79,7 +80,7 @@ void main() { expect(event.eventId, id); expect(event.senderId, senderID); - expect(event.status, 2); + expect(event.status, EventStatus.synced); expect(event.text, body); expect(event.formattedText, formatted_body); expect(event.body, body); @@ -89,7 +90,7 @@ void main() { final state = Event.fromJson(jsonObj, null); expect(state.eventId, id); expect(state.stateKey, ''); - expect(state.status, 2); + expect(state.status, EventStatus.synced); }); test('Test all EventTypes', () async { Event event; @@ -259,7 +260,7 @@ void main() { final event = Event.fromJson( jsonObj, Room(id: '1234', client: Client('testclient'))); final removed1 = await event.remove(); - event.status = 0; + event.status = EventStatus.sending; final removed2 = await event.remove(); expect(removed1, false); expect(removed2, true); @@ -276,7 +277,7 @@ void main() { final event = Event.fromJson( jsonObj, Room(id: '!1234:example.com', client: matrix)); final resp1 = await event.sendAgain(); - event.status = -1; + event.status = EventStatus.error; final resp2 = await event.sendAgain(txid: '1234'); expect(resp1, null); expect(resp2.startsWith('\$event'), true); @@ -308,7 +309,7 @@ void main() { 'origin_server_ts': timestamp, 'type': 'm.room.encrypted', 'room_id': '1234', - 'status': 2, + 'status': EventStatus.synced.intValue, 'content': json.encode({ 'msgtype': 'm.bad.encrypted', 'body': DecryptException.unknownSession, diff --git a/test/matrix_database_test.dart b/test/matrix_database_test.dart index aca1897c..360d6d39 100644 --- a/test/matrix_database_test.dart +++ b/test/matrix_database_test.dart @@ -19,6 +19,7 @@ */ import 'package:matrix/matrix.dart'; +import 'package:matrix/src/event_status.dart'; import 'package:test/test.dart'; import 'fake_database.dart'; @@ -69,7 +70,7 @@ void main() { 'content': {'blah': 'blubb'}, 'event_id': 'transaction-1', 'sender': '@blah:blubb', - 'status': 0, + 'status': EventStatus.sending.intValue, }, ); await database.storeEventUpdate(update); @@ -87,7 +88,7 @@ void main() { 'unsigned': { 'transaction_id': 'transaction-1', }, - 'status': 1, + 'status': EventStatus.sent.intValue, }, ); await database.storeEventUpdate(update); @@ -105,7 +106,7 @@ void main() { 'content': {'blah': 'blubb'}, 'event_id': '\$event-3', 'sender': '@blah:blubb', - 'status': 0, + 'status': EventStatus.sending.intValue, }, ); await database.storeEventUpdate(update); @@ -120,7 +121,7 @@ void main() { 'content': {'blah': 'blubb'}, 'event_id': '\$event-3', 'sender': '@blah:blubb', - 'status': 1, + 'status': EventStatus.sent.intValue, 'unsigned': { 'transaction_id': 'transaction-2', }, @@ -129,7 +130,7 @@ void main() { await database.storeEventUpdate(update); event = await database.getEventById('\$event-3', room); expect(event.eventId, '\$event-3'); - expect(event.status, 1); + expect(event.status, EventStatus.sent); event = await database.getEventById('transaction-2', room); expect(event, null); @@ -143,7 +144,7 @@ void main() { 'content': {'blah': 'blubb'}, 'event_id': '\$event-4', 'sender': '@blah:blubb', - 'status': 2, + 'status': EventStatus.synced.intValue, }, ); await database.storeEventUpdate(update); @@ -158,7 +159,7 @@ void main() { 'content': {'blah': 'blubb'}, 'event_id': '\$event-4', 'sender': '@blah:blubb', - 'status': 1, + 'status': EventStatus.sent.intValue, 'unsigned': { 'transaction_id': 'transaction-3', }, @@ -167,7 +168,7 @@ void main() { await database.storeEventUpdate(update); event = await database.getEventById('\$event-4', room); expect(event.eventId, '\$event-4'); - expect(event.status, 2); + expect(event.status, EventStatus.synced); event = await database.getEventById('transaction-3', room); expect(event, null); }); diff --git a/test/timeline_test.dart b/test/timeline_test.dart index 2df20a58..0532ae17 100644 --- a/test/timeline_test.dart +++ b/test/timeline_test.dart @@ -18,6 +18,7 @@ */ import 'package:matrix/matrix.dart'; +import 'package:matrix/src/event_status.dart'; import 'package:test/test.dart'; import 'package:matrix/src/client.dart'; @@ -75,7 +76,7 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': '2', 'origin_server_ts': testTimeStamp - 1000 }, @@ -87,7 +88,7 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': '1', 'origin_server_ts': testTimeStamp }, @@ -160,7 +161,7 @@ void main() { expect(insertList.length, timeline.events.length); final eventId = timeline.events[0].eventId; expect(eventId.startsWith('\$event'), true); - expect(timeline.events[0].status, 1); + expect(timeline.events[0].status, EventStatus.sent); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -169,7 +170,7 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'test'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': eventId, 'unsigned': {'transaction_id': '1234'}, 'origin_server_ts': DateTime.now().millisecondsSinceEpoch @@ -182,7 +183,7 @@ void main() { expect(insertList, [0, 0, 0]); expect(insertList.length, timeline.events.length); expect(timeline.events[0].eventId, eventId); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); }); test('Send message with error', () async { @@ -193,7 +194,7 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 0, + 'status': EventStatus.sending.intValue, 'event_id': 'abc', 'origin_server_ts': testTimeStamp }, @@ -213,9 +214,9 @@ void main() { expect(updateCount, 13); expect(insertList, [0, 0, 0, 0, 0, 0, 0]); expect(insertList.length, timeline.events.length); - expect(timeline.events[0].status, -1); - expect(timeline.events[1].status, -1); - expect(timeline.events[2].status, -1); + expect(timeline.events[0].status, EventStatus.error); + expect(timeline.events[1].status, EventStatus.error); + expect(timeline.events[2].status, EventStatus.error); }); test('Remove message', () async { @@ -227,7 +228,7 @@ void main() { expect(insertList, [0, 0, 0, 0, 0, 0, 0]); expect(timeline.events.length, 6); - expect(timeline.events[0].status, -1); + expect(timeline.events[0].status, EventStatus.error); }); test('getEventById', () async { @@ -256,14 +257,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': -1, + 'status': EventStatus.error.intValue, 'event_id': 'new-test-event', 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'newresend'}, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, -1); + expect(timeline.events[0].status, EventStatus.error); await timeline.events[0].sendAgain(); await Future.delayed(Duration(milliseconds: 50)); @@ -272,7 +273,7 @@ void main() { expect(insertList, [0, 0, 0, 0, 0, 0, 0, 0]); expect(timeline.events.length, 1); - expect(timeline.events[0].status, 1); + expect(timeline.events[0].status, EventStatus.sent); }); test('Request history', () async { @@ -317,7 +318,7 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': -1, + 'status': EventStatus.error.intValue, 'event_id': 'abc', 'origin_server_ts': testTimeStamp }, @@ -329,14 +330,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': 'def', 'origin_server_ts': testTimeStamp + 5 }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, -1); - expect(timeline.events[1].status, 2); + expect(timeline.events[0].status, EventStatus.error); + expect(timeline.events[1].status, EventStatus.synced); }); test('sending event to failed update', () async { @@ -348,13 +349,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 0, + 'status': EventStatus.sending.intValue, 'event_id': 'will-fail', 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 0); + expect(timeline.events[0].status, EventStatus.sending); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -363,13 +364,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': -1, + 'status': EventStatus.error.intValue, 'event_id': 'will-fail', 'origin_server_ts': testTimeStamp }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, -1); + expect(timeline.events[0].status, EventStatus.error); expect(timeline.events.length, 1); }); test('sending an event and the http request finishes first, 0 -> 1 -> 2', @@ -382,13 +383,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 0, + 'status': EventStatus.sending.intValue, 'event_id': 'transaction', 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 0); + expect(timeline.events[0].status, EventStatus.sending); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -397,14 +398,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 1, + 'status': EventStatus.sent.intValue, 'event_id': '\$event', 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'} }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 1); + expect(timeline.events[0].status, EventStatus.sent); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -413,14 +414,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': '\$event', 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'} }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); }); test('sending an event where the sync reply arrives first, 0 -> 2 -> 1', @@ -436,13 +437,13 @@ void main() { 'event_id': 'transaction', 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, 'unsigned': { - messageSendingStatusKey: 0, + messageSendingStatusKey: EventStatus.sending.intValue, 'transaction_id': 'transaction', }, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 0); + expect(timeline.events[0].status, EventStatus.sending); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -455,12 +456,12 @@ void main() { 'origin_server_ts': testTimeStamp, 'unsigned': { 'transaction_id': 'transaction', - messageSendingStatusKey: 2, + messageSendingStatusKey: EventStatus.synced.intValue, }, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -473,12 +474,12 @@ void main() { 'origin_server_ts': testTimeStamp, 'unsigned': { 'transaction_id': 'transaction', - messageSendingStatusKey: 1, + messageSendingStatusKey: EventStatus.sent.intValue, }, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); }); test('sending an event 0 -> -1 -> 2', () async { @@ -490,13 +491,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 0, + 'status': EventStatus.sending.intValue, 'event_id': 'transaction', 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 0); + expect(timeline.events[0].status, EventStatus.sending); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -505,13 +506,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': -1, + 'status': EventStatus.error.intValue, 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'}, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, -1); + expect(timeline.events[0].status, EventStatus.error); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -520,14 +521,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': '\$event', 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'}, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); }); test('sending an event 0 -> 2 -> -1', () async { @@ -539,13 +540,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 0, + 'status': EventStatus.sending.intValue, 'event_id': 'transaction', 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 0); + expect(timeline.events[0].status, EventStatus.sending); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -554,14 +555,14 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': 2, + 'status': EventStatus.synced.intValue, 'event_id': '\$event', 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'}, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); client.onEvent.add(EventUpdate( type: EventUpdateType.timeline, @@ -570,13 +571,13 @@ void main() { 'type': 'm.room.message', 'content': {'msgtype': 'm.text', 'body': 'Testcase'}, 'sender': '@alice:example.com', - 'status': -1, + 'status': EventStatus.error.intValue, 'origin_server_ts': testTimeStamp, 'unsigned': {'transaction_id': 'transaction'}, }, )); await Future.delayed(Duration(milliseconds: 50)); - expect(timeline.events[0].status, 2); + expect(timeline.events[0].status, EventStatus.synced); expect(timeline.events.length, 1); }); test('logout', () async {