/* * 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 '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/src/utils/run_benchmarked.dart'; /// 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 = eventUpdate.content.tryGet('status') ?? eventUpdate.content .tryGetMap('unsigned') ?.tryGet(messageSendingStatusKey) ?? 2; // 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) { return; } final status = newStatus == -1 || prevEvent == null || prevEvent.status == null ? newStatus : max(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; // 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 >= 1 ? '' : '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 == 2 && prevEvent?.status == 1 && 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 >= 1) { 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 != -1 && status != 0 && 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); }