matrix-dart-sdk/lib/src/database/matrix_sdk_database.dart

1629 lines
53 KiB
Dart

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:sqflite_common/sqflite.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/utils/copy_map.dart';
import 'package:matrix/src/utils/queued_to_device_event.dart';
import 'package:matrix/src/utils/run_benchmarked.dart';
import 'package:matrix/src/database/indexeddb_box.dart'
if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart';
class MatrixSdkDatabase extends DatabaseApi {
static const int version = 6;
final String name;
late BoxCollection _collection;
late Box<String> _clientBox;
late Box<Map> _accountDataBox;
late Box<Map> _roomsBox;
late Box<Map> _toDeviceQueueBox;
/// Key is a tuple as TupleKey(roomId, type) where stateKey can be
/// an empty string. Must contain only states of type
/// client.importantRoomStates.
late Box<Map> _preloadRoomStateBox;
/// Key is a tuple as TupleKey(roomId, type) where stateKey can be
/// an empty string. Must NOT contain states of a type from
/// client.importantRoomStates.
late Box<Map> _nonPreloadRoomStateBox;
/// Key is a tuple as TupleKey(roomId, userId)
late Box<Map> _roomMembersBox;
/// Key is a tuple as TupleKey(roomId, type)
late Box<Map> _roomAccountDataBox;
late Box<Map> _inboundGroupSessionsBox;
late Box<Map> _outboundGroupSessionsBox;
late Box<Map> _olmSessionsBox;
/// Key is a tuple as TupleKey(userId, deviceId)
late Box<Map> _userDeviceKeysBox;
/// Key is the user ID as a String
late Box<bool> _userDeviceKeysOutdatedBox;
/// Key is a tuple as TupleKey(userId, publicKey)
late Box<Map> _userCrossSigningKeysBox;
late Box<Map> _ssssCacheBox;
late Box<Map> _presencesBox;
/// Key is a tuple as Multikey(roomId, fragmentId) while the default
/// fragmentId is an empty String
late Box<List> _timelineFragmentsBox;
/// Key is a tuple as TupleKey(roomId, eventId)
late Box<Map> _eventsBox;
/// Key is a tuple as TupleKey(userId, deviceId)
late Box<String> _seenDeviceIdsBox;
late Box<String> _seenDeviceKeysBox;
@override
bool get supportsFileStoring => fileStoragePath != null;
@override
final int maxFileSize;
final Directory? fileStoragePath;
final Duration? deleteFilesAfterDuration;
static const String _clientBoxName = 'box_client';
static const String _accountDataBoxName = 'box_account_data';
static const String _roomsBoxName = 'box_rooms';
static const String _toDeviceQueueBoxName = 'box_to_device_queue';
static const String _preloadRoomStateBoxName = 'box_preload_room_states';
static const String _nonPreloadRoomStateBoxName =
'box_non_preload_room_states';
static const String _roomMembersBoxName = 'box_room_members';
static const String _roomAccountDataBoxName = 'box_room_account_data';
static const String _inboundGroupSessionsBoxName =
'box_inbound_group_session';
static const String _outboundGroupSessionsBoxName =
'box_outbound_group_session';
static const String _olmSessionsBoxName = 'box_olm_session';
static const String _userDeviceKeysBoxName = 'box_user_device_keys';
static const String _userDeviceKeysOutdatedBoxName =
'box_user_device_keys_outdated';
static const String _userCrossSigningKeysBoxName = 'box_cross_signing_keys';
static const String _ssssCacheBoxName = 'box_ssss_cache';
static const String _presencesBoxName = 'box_presences';
static const String _timelineFragmentsBoxName = 'box_timeline_fragments';
static const String _eventsBoxName = 'box_events';
static const String _seenDeviceIdsBoxName = 'box_seen_device_ids';
static const String _seenDeviceKeysBoxName = 'box_seen_device_keys';
Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would
/// lead to an error to import "dart:indexed_db" so this is dynamically
/// typed.
final dynamic idbFactory;
/// Custom SQFlite Database Factory used for high level operations on IO
/// like delete. Set it if you want to use sqlite FFI.
final DatabaseFactory? sqfliteFactory;
MatrixSdkDatabase(
this.name, {
this.database,
this.idbFactory,
this.sqfliteFactory,
this.maxFileSize = 0,
this.fileStoragePath,
this.deleteFilesAfterDuration,
});
Future<void> open() async {
_collection = await BoxCollection.open(
name,
{
_clientBoxName,
_accountDataBoxName,
_roomsBoxName,
_toDeviceQueueBoxName,
_preloadRoomStateBoxName,
_nonPreloadRoomStateBoxName,
_roomMembersBoxName,
_roomAccountDataBoxName,
_inboundGroupSessionsBoxName,
_outboundGroupSessionsBoxName,
_olmSessionsBoxName,
_userDeviceKeysBoxName,
_userDeviceKeysOutdatedBoxName,
_userCrossSigningKeysBoxName,
_ssssCacheBoxName,
_presencesBoxName,
_timelineFragmentsBoxName,
_eventsBoxName,
_seenDeviceIdsBoxName,
_seenDeviceKeysBoxName,
},
sqfliteDatabase: database,
sqfliteFactory: sqfliteFactory,
idbFactory: idbFactory,
);
_clientBox = _collection.openBox<String>(
_clientBoxName,
);
_accountDataBox = _collection.openBox<Map>(
_accountDataBoxName,
);
_roomsBox = _collection.openBox<Map>(
_roomsBoxName,
);
_preloadRoomStateBox = _collection.openBox(
_preloadRoomStateBoxName,
);
_nonPreloadRoomStateBox = _collection.openBox(
_nonPreloadRoomStateBoxName,
);
_roomMembersBox = _collection.openBox(
_roomMembersBoxName,
);
_toDeviceQueueBox = _collection.openBox(
_toDeviceQueueBoxName,
);
_roomAccountDataBox = _collection.openBox(
_roomAccountDataBoxName,
);
_inboundGroupSessionsBox = _collection.openBox(
_inboundGroupSessionsBoxName,
);
_outboundGroupSessionsBox = _collection.openBox(
_outboundGroupSessionsBoxName,
);
_olmSessionsBox = _collection.openBox(
_olmSessionsBoxName,
);
_userDeviceKeysBox = _collection.openBox(
_userDeviceKeysBoxName,
);
_userDeviceKeysOutdatedBox = _collection.openBox(
_userDeviceKeysOutdatedBoxName,
);
_userCrossSigningKeysBox = _collection.openBox(
_userCrossSigningKeysBoxName,
);
_ssssCacheBox = _collection.openBox(
_ssssCacheBoxName,
);
_presencesBox = _collection.openBox(
_presencesBoxName,
);
_timelineFragmentsBox = _collection.openBox(
_timelineFragmentsBoxName,
);
_eventsBox = _collection.openBox(
_eventsBoxName,
);
_seenDeviceIdsBox = _collection.openBox(
_seenDeviceIdsBoxName,
);
_seenDeviceKeysBox = _collection.openBox(
_seenDeviceKeysBoxName,
);
// Check version and check if we need a migration
final currentVersion = int.tryParse(await _clientBox.get('version') ?? '');
if (currentVersion == null) {
await _clientBox.put('version', version.toString());
} else if (currentVersion != version) {
await _migrateFromVersion(currentVersion);
}
return;
}
Future<void> _migrateFromVersion(int currentVersion) async {
Logs().i('Migrate store database from version $currentVersion to $version');
await clearCache();
await _clientBox.put('version', version.toString());
}
@override
Future<void> clear() => _collection.clear();
@override
Future<void> clearCache() => transaction(() async {
await _roomsBox.clear();
await _accountDataBox.clear();
await _roomAccountDataBox.clear();
await _preloadRoomStateBox.clear();
await _nonPreloadRoomStateBox.clear();
await _roomMembersBox.clear();
await _eventsBox.clear();
await _timelineFragmentsBox.clear();
await _outboundGroupSessionsBox.clear();
await _presencesBox.clear();
await _clientBox.delete('prev_batch');
});
@override
Future<void> clearSSSSCache() => _ssssCacheBox.clear();
@override
Future<void> close() async => _collection.close();
@override
Future<void> deleteFromToDeviceQueue(int id) async {
await _toDeviceQueueBox.delete(id.toString());
return;
}
@override
Future<void> deleteOldFiles(int savedAt) async {
final dir = fileStoragePath;
final deleteFilesAfterDuration = this.deleteFilesAfterDuration;
if (!supportsFileStoring ||
dir == null ||
deleteFilesAfterDuration == null) {
return;
}
final entities = await dir.list().toList();
for (final file in entities) {
if (file is! File) continue;
final stat = await file.stat();
if (DateTime.now().difference(stat.modified) > deleteFilesAfterDuration) {
Logs().v('Delete old file', file.path);
await file.delete();
}
}
}
@override
Future<void> forgetRoom(String roomId) async {
await _timelineFragmentsBox.delete(TupleKey(roomId, '').toString());
final eventsBoxKeys = await _eventsBox.getAllKeys();
for (final key in eventsBoxKeys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
await _eventsBox.delete(key);
}
final preloadRoomStateBoxKeys = await _preloadRoomStateBox.getAllKeys();
for (final key in preloadRoomStateBoxKeys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
await _preloadRoomStateBox.delete(key);
}
final nonPreloadRoomStateBoxKeys =
await _nonPreloadRoomStateBox.getAllKeys();
for (final key in nonPreloadRoomStateBoxKeys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
await _nonPreloadRoomStateBox.delete(key);
}
final roomMembersBoxKeys = await _roomMembersBox.getAllKeys();
for (final key in roomMembersBoxKeys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
await _roomMembersBox.delete(key);
}
final roomAccountDataBoxKeys = await _roomAccountDataBox.getAllKeys();
for (final key in roomAccountDataBoxKeys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
await _roomAccountDataBox.delete(key);
}
await _roomsBox.delete(roomId);
}
@override
Future<Map<String, BasicEvent>> getAccountData() =>
runBenchmarked<Map<String, BasicEvent>>('Get all account data from store',
() async {
final accountData = <String, BasicEvent>{};
final raws = await _accountDataBox.getAllValues();
for (final entry in raws.entries) {
accountData[entry.key] = BasicEvent(
type: entry.key,
content: copyMap(entry.value),
);
}
return accountData;
});
@override
Future<Map<String, dynamic>?> getClient(String name) =>
runBenchmarked('Get Client from store', () async {
final map = <String, dynamic>{};
final keys = await _clientBox.getAllKeys();
for (final key in keys) {
if (key == 'version') continue;
final value = await _clientBox.get(key);
if (value != null) map[key] = value;
}
if (map.isEmpty) return null;
return map;
});
@override
Future<Event?> getEventById(String eventId, Room room) async {
final raw = await _eventsBox.get(TupleKey(room.id, eventId).toString());
if (raw == null) return null;
return Event.fromJson(copyMap(raw), room);
}
/// Loads a whole list of events at once from the store for a specific room
Future<List<Event>> _getEventsByIds(List<String> eventIds, Room room) async {
final keys = eventIds
.map(
(eventId) => TupleKey(room.id, eventId).toString(),
)
.toList();
final rawEvents = await _eventsBox.getAll(keys);
return rawEvents
.whereType<Map>()
.map((rawEvent) => Event.fromJson(copyMap(rawEvent), room))
.toList();
}
@override
Future<List<Event>> getEventList(
Room room, {
int start = 0,
bool onlySending = false,
int? limit,
}) =>
runBenchmarked<List<Event>>('Get event list', () async {
// Get the synced event IDs from the store
final timelineKey = TupleKey(room.id, '').toString();
final timelineEventIds =
(await _timelineFragmentsBox.get(timelineKey) ?? []);
// Get the local stored SENDING events from the store
late final List sendingEventIds;
if (start != 0) {
sendingEventIds = [];
} else {
final sendingTimelineKey = TupleKey(room.id, 'SENDING').toString();
sendingEventIds =
(await _timelineFragmentsBox.get(sendingTimelineKey) ?? []);
}
// Combine those two lists while respecting the start and limit parameters.
final end = min(timelineEventIds.length,
start + (limit ?? timelineEventIds.length));
final eventIds = [
...sendingEventIds,
if (!onlySending && start < timelineEventIds.length)
...timelineEventIds.getRange(start, end),
];
return await _getEventsByIds(eventIds.cast<String>(), room);
});
@override
Future<Uint8List?> getFile(Uri mxcUri) async {
final fileStoragePath = this.fileStoragePath;
if (!supportsFileStoring || fileStoragePath == null) return null;
final file =
File('${fileStoragePath.path}/${mxcUri.toString().split('/').last}');
if (await file.exists()) return await file.readAsBytes();
return null;
}
@override
Future<StoredInboundGroupSession?> getInboundGroupSession(
String roomId,
String sessionId,
) async {
final raw = await _inboundGroupSessionsBox.get(sessionId);
if (raw == null) return null;
return StoredInboundGroupSession.fromJson(copyMap(raw));
}
@override
Future<List<StoredInboundGroupSession>>
getInboundGroupSessionsToUpload() async {
final sessions = (await _inboundGroupSessionsBox.getAllValues())
.values
.where((rawSession) => rawSession['uploaded'] == false)
.take(50)
.map(
(json) => StoredInboundGroupSession.fromJson(
copyMap(json),
),
)
.toList();
return sessions;
}
@override
Future<List<String>> getLastSentMessageUserDeviceKey(
String userId, String deviceId) async {
final raw =
await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString());
if (raw == null) return <String>[];
return <String>[raw['last_sent_message']];
}
@override
Future<void> storeOlmSession(String identityKey, String sessionId,
String pickle, int lastReceived) async {
final rawSessions = copyMap((await _olmSessionsBox.get(identityKey)) ?? {});
rawSessions[sessionId] = {
'identity_key': identityKey,
'pickle': pickle,
'session_id': sessionId,
'last_received': lastReceived,
};
await _olmSessionsBox.put(identityKey, rawSessions);
return;
}
@override
Future<List<OlmSession>> getOlmSessions(
String identityKey, String userId) async {
final rawSessions = await _olmSessionsBox.get(identityKey);
if (rawSessions == null || rawSessions.isEmpty) return <OlmSession>[];
return rawSessions.values
.map((json) => OlmSession.fromJson(copyMap(json), userId))
.toList();
}
@override
Future<Map<String, Map>> getAllOlmSessions() =>
_olmSessionsBox.getAllValues();
@override
Future<List<OlmSession>> getOlmSessionsForDevices(
List<String> identityKeys, String userId) async {
final sessions = await Future.wait(
identityKeys.map((identityKey) => getOlmSessions(identityKey, userId)));
return <OlmSession>[for (final sublist in sessions) ...sublist];
}
@override
Future<OutboundGroupSession?> getOutboundGroupSession(
String roomId, String userId) async {
final raw = await _outboundGroupSessionsBox.get(roomId);
if (raw == null) return null;
return OutboundGroupSession.fromJson(copyMap(raw), userId);
}
@override
Future<Room?> getSingleRoom(Client client, String roomId,
{bool loadImportantStates = true}) async {
// Get raw room from database:
final roomData = await _roomsBox.get(roomId);
if (roomData == null) return null;
final room = Room.fromJson(copyMap(roomData), client);
// Get important states:
if (loadImportantStates) {
final dbKeys = client.importantStateEvents
.map((state) => TupleKey(roomId, state).toString())
.toList();
final rawStates = await _preloadRoomStateBox.getAll(dbKeys);
for (final rawState in rawStates) {
if (rawState == null || rawState[''] == null) continue;
room.setState(Event.fromJson(copyMap(rawState['']), room));
}
}
return room;
}
@override
Future<List<Room>> getRoomList(Client client) =>
runBenchmarked<List<Room>>('Get room list from store', () async {
final rooms = <String, Room>{};
final rawRooms = await _roomsBox.getAllValues();
for (final raw in rawRooms.values) {
// Get the room
final room = Room.fromJson(copyMap(raw), client);
// Add to the list and continue.
rooms[room.id] = room;
}
final roomStatesDataRaws = await _preloadRoomStateBox.getAllValues();
for (final entry in roomStatesDataRaws.entries) {
final keys = TupleKey.fromString(entry.key);
final roomId = keys.parts.first;
final room = rooms[roomId];
if (room == null) {
Logs().w('Found event in store for unknown room', entry.value);
continue;
}
final states = entry.value;
final stateEvents = states.values
.map((raw) => Event.fromJson(copyMap(raw), room))
.toList();
for (final state in stateEvents) {
room.setState(state);
}
}
// Get the room account data
final roomAccountDataRaws = await _roomAccountDataBox.getAllValues();
for (final entry in roomAccountDataRaws.entries) {
final keys = TupleKey.fromString(entry.key);
final basicRoomEvent = BasicRoomEvent.fromJson(
copyMap(entry.value),
);
final roomId = keys.parts.first;
if (rooms.containsKey(roomId)) {
rooms[roomId]!.roomAccountData[basicRoomEvent.type] =
basicRoomEvent;
} else {
Logs().w(
'Found account data for unknown room $roomId. Delete now...');
await _roomAccountDataBox
.delete(TupleKey(roomId, basicRoomEvent.type).toString());
}
}
return rooms.values.toList();
});
@override
Future<SSSSCache?> getSSSSCache(String type) async {
final raw = await _ssssCacheBox.get(type);
if (raw == null) return null;
return SSSSCache.fromJson(copyMap(raw));
}
@override
Future<List<QueuedToDeviceEvent>> getToDeviceEventQueue() async {
final raws = await _toDeviceQueueBox.getAllValues();
final copiedRaws = raws.entries.map((entry) {
final copiedRaw = copyMap(entry.value);
copiedRaw['id'] = int.parse(entry.key);
copiedRaw['content'] = jsonDecode(copiedRaw['content'] as String);
return copiedRaw;
}).toList();
return copiedRaws.map((raw) => QueuedToDeviceEvent.fromJson(raw)).toList();
}
@override
Future<List<Event>> getUnimportantRoomEventStatesForRoom(
List<String> events, Room room) async {
final keys = (await _nonPreloadRoomStateBox.getAllKeys()).where((key) {
final tuple = TupleKey.fromString(key);
return tuple.parts.first == room.id && !events.contains(tuple.parts[1]);
});
final unimportantEvents = <Event>[];
for (final key in keys) {
final states = await _nonPreloadRoomStateBox.get(key);
if (states == null) continue;
unimportantEvents.addAll(
states.values.map((raw) => Event.fromJson(copyMap(raw), room)));
}
return unimportantEvents;
}
@override
Future<User?> getUser(String userId, Room room) async {
final state =
await _roomMembersBox.get(TupleKey(room.id, userId).toString());
if (state == null) return null;
return Event.fromJson(copyMap(state), room).asUser;
}
@override
Future<Map<String, DeviceKeysList>> getUserDeviceKeys(Client client) =>
runBenchmarked<Map<String, DeviceKeysList>>(
'Get all user device keys from store', () async {
final deviceKeysOutdated =
await _userDeviceKeysOutdatedBox.getAllValues();
if (deviceKeysOutdated.isEmpty) {
return {};
}
final res = <String, DeviceKeysList>{};
final userDeviceKeys = await _userDeviceKeysBox.getAllValues();
final userCrossSigningKeys =
await _userCrossSigningKeysBox.getAllValues();
for (final userId in deviceKeysOutdated.keys) {
final deviceKeysBoxKeys = userDeviceKeys.keys.where((tuple) {
final tupleKey = TupleKey.fromString(tuple);
return tupleKey.parts.first == userId;
});
final crossSigningKeysBoxKeys =
userCrossSigningKeys.keys.where((tuple) {
final tupleKey = TupleKey.fromString(tuple);
return tupleKey.parts.first == userId;
});
final childEntries = deviceKeysBoxKeys.map(
(key) {
final userDeviceKey = userDeviceKeys[key];
if (userDeviceKey == null) return null;
return copyMap(userDeviceKey);
},
);
final crossSigningEntries = crossSigningKeysBoxKeys.map(
(key) {
final crossSigningKey = userCrossSigningKeys[key];
if (crossSigningKey == null) return null;
return copyMap(crossSigningKey);
},
);
res[userId] = DeviceKeysList.fromDbJson(
{
'client_id': client.id,
'user_id': userId,
'outdated': deviceKeysOutdated[userId],
},
childEntries
.where((c) => c != null)
.toList()
.cast<Map<String, dynamic>>(),
crossSigningEntries
.where((c) => c != null)
.toList()
.cast<Map<String, dynamic>>(),
client);
}
return res;
});
@override
Future<List<User>> getUsers(Room room) async {
final users = <User>[];
final keys = (await _roomMembersBox.getAllKeys())
.where((key) => TupleKey.fromString(key).parts.first == room.id)
.toList();
final states = await _roomMembersBox.getAll(keys);
states.removeWhere((state) => state == null);
for (final state in states) {
users.add(Event.fromJson(copyMap(state!), room).asUser);
}
return users;
}
@override
Future<int> insertClient(
String name,
String homeserverUrl,
String token,
String? refreshToken,
String userId,
String? deviceId,
String? deviceName,
String? prevBatch,
String? olmAccount) async {
await transaction(() async {
await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
await _clientBox.put('user_id', userId);
if (deviceId == null) {
await _clientBox.delete('device_id');
} else {
await _clientBox.put('device_id', deviceId);
}
if (deviceName == null) {
await _clientBox.delete('device_name');
} else {
await _clientBox.put('device_name', deviceName);
}
if (prevBatch == null) {
await _clientBox.delete('prev_batch');
} else {
await _clientBox.put('prev_batch', prevBatch);
}
if (olmAccount == null) {
await _clientBox.delete('olm_account');
} else {
await _clientBox.put('olm_account', olmAccount);
}
await _clientBox.delete('sync_filter_id');
});
return 0;
}
@override
Future<int> insertIntoToDeviceQueue(
String type, String txnId, String content) async {
final id = DateTime.now().millisecondsSinceEpoch;
await _toDeviceQueueBox.put(id.toString(), {
'type': type,
'txn_id': txnId,
'content': content,
});
return id;
}
@override
Future<void> markInboundGroupSessionAsUploaded(
String roomId, String sessionId) async {
final raw = copyMap(
await _inboundGroupSessionsBox.get(sessionId) ?? {},
);
if (raw.isEmpty) {
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, raw);
return;
}
@override
Future<void> markInboundGroupSessionsAsNeedingUpload() async {
final keys = await _inboundGroupSessionsBox.getAllKeys();
for (final sessionId in keys) {
final raw = copyMap(
await _inboundGroupSessionsBox.get(sessionId) ?? {},
);
if (raw.isEmpty) continue;
raw['uploaded'] = false;
await _inboundGroupSessionsBox.put(sessionId, raw);
}
return;
}
@override
Future<void> removeEvent(String eventId, String roomId) async {
await _eventsBox.delete(TupleKey(roomId, eventId).toString());
final keys = await _timelineFragmentsBox.getAllKeys();
for (final key in keys) {
final multiKey = TupleKey.fromString(key);
if (multiKey.parts.first != roomId) continue;
final eventIds =
List<String>.from(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<void> removeOutboundGroupSession(String roomId) async {
await _outboundGroupSessionsBox.delete(roomId);
return;
}
@override
Future<void> removeUserCrossSigningKey(
String userId, String publicKey) async {
await _userCrossSigningKeysBox
.delete(TupleKey(userId, publicKey).toString());
return;
}
@override
Future<void> removeUserDeviceKey(String userId, String deviceId) async {
await _userDeviceKeysBox.delete(TupleKey(userId, deviceId).toString());
return;
}
@override
Future<void> setBlockedUserCrossSigningKey(
bool blocked, String userId, String publicKey) async {
final raw = copyMap(
await _userCrossSigningKeysBox
.get(TupleKey(userId, publicKey).toString()) ??
{},
);
raw['blocked'] = blocked;
await _userCrossSigningKeysBox.put(
TupleKey(userId, publicKey).toString(),
raw,
);
return;
}
@override
Future<void> setBlockedUserDeviceKey(
bool blocked, String userId, String deviceId) async {
final raw = copyMap(
await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()) ?? {},
);
raw['blocked'] = blocked;
await _userDeviceKeysBox.put(
TupleKey(userId, deviceId).toString(),
raw,
);
return;
}
@override
Future<void> setLastActiveUserDeviceKey(
int lastActive, String userId, String deviceId) async {
final raw = copyMap(
await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()) ?? {},
);
raw['last_active'] = lastActive;
await _userDeviceKeysBox.put(
TupleKey(userId, deviceId).toString(),
raw,
);
}
@override
Future<void> setLastSentMessageUserDeviceKey(
String lastSentMessage, String userId, String deviceId) async {
final raw = copyMap(
await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()) ?? {},
);
raw['last_sent_message'] = lastSentMessage;
await _userDeviceKeysBox.put(
TupleKey(userId, deviceId).toString(),
raw,
);
}
@override
Future<void> setRoomPrevBatch(
String? prevBatch, String roomId, Client client) async {
final raw = await _roomsBox.get(roomId);
if (raw == null) return;
final room = Room.fromJson(copyMap(raw), client);
room.prev_batch = prevBatch;
await _roomsBox.put(roomId, room.toJson());
return;
}
@override
Future<void> setVerifiedUserCrossSigningKey(
bool verified, String userId, String publicKey) async {
final raw = copyMap(
(await _userCrossSigningKeysBox
.get(TupleKey(userId, publicKey).toString())) ??
{},
);
raw['verified'] = verified;
await _userCrossSigningKeysBox.put(
TupleKey(userId, publicKey).toString(),
raw,
);
return;
}
@override
Future<void> setVerifiedUserDeviceKey(
bool verified, String userId, String deviceId) async {
final raw = copyMap(
await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()) ?? {},
);
raw['verified'] = verified;
await _userDeviceKeysBox.put(
TupleKey(userId, deviceId).toString(),
raw,
);
return;
}
@override
Future<void> storeAccountData(String type, String content) async {
await _accountDataBox.put(type, jsonDecode(content));
return;
}
@override
Future<void> storeEventUpdate(EventUpdate eventUpdate, Client client) async {
// Ephemerals should not be stored
if (eventUpdate.type == EventUpdateType.ephemeral) return;
final tmpRoom = client.getRoomById(eventUpdate.roomID) ??
Room(id: eventUpdate.roomID, client: client);
// In case of this is a redaction event
if (eventUpdate.content['type'] == EventTypes.Redaction) {
final eventId = eventUpdate.content.tryGet<String>('redacts');
final event =
eventId != null ? await getEventById(eventId, tmpRoom) : null;
if (event != null) {
event.setRedactionEvent(Event.fromJson(eventUpdate.content, tmpRoom));
await _eventsBox.put(
TupleKey(eventUpdate.roomID, event.eventId).toString(),
event.toJson());
if (tmpRoom.lastEvent?.eventId == event.eventId) {
if (client.importantStateEvents.contains(event.type)) {
await _preloadRoomStateBox.put(
TupleKey(eventUpdate.roomID, event.type).toString(),
{'': event.toJson()},
);
} else {
await _nonPreloadRoomStateBox.put(
TupleKey(eventUpdate.roomID, event.type).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 = await _eventsBox
.get(TupleKey(eventUpdate.roomID, eventId).toString());
final prevStatus = prevEvent == null
? null
: () {
final json = copyMap(prevEvent);
final statusInt = json.tryGet<int>('status') ??
json
.tryGetMap<String, dynamic>('unsigned')
?.tryGet<int>(messageSendingStatusKey);
return statusInt == null ? null : eventStatusFromInt(statusInt);
}();
// calculate the status
final newStatus = eventStatusFromInt(
eventUpdate.content.tryGet<int>('status') ??
eventUpdate.content
.tryGetMap<String, dynamic>('unsigned')
?.tryGet<int>(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 && prevStatus != null && prevStatus.isSynced) {
return;
}
final status = newStatus.isError || prevStatus == null
? newStatus
: latestEventStatus(
prevStatus,
newStatus,
);
// Add the status and the sort order to the content so it get stored
eventUpdate.content['unsigned'] ??= <String, dynamic>{};
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<String, dynamic>('unsigned')
?.tryGet<String>('transaction_id');
await _eventsBox.put(TupleKey(eventUpdate.roomID, eventId).toString(),
eventUpdate.content);
// Update timeline fragments
final key = TupleKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING')
.toString();
final eventIds =
List<String>.from(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 &&
prevStatus != null &&
prevStatus.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 = TupleKey(eventUpdate.roomID, 'SENDING').toString();
final eventIds =
List<String>.from(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);
}
}
final stateKey =
client.roomPreviewLastEvents.contains(eventUpdate.content['type'])
? ''
: eventUpdate.content['state_key'];
// Store a common state event
if ({
EventUpdateType.timeline,
EventUpdateType.state,
EventUpdateType.inviteState
}.contains(eventUpdate.type) &&
stateKey != null) {
if (eventUpdate.content['type'] == EventTypes.RoomMember) {
await _roomMembersBox.put(
TupleKey(
eventUpdate.roomID,
eventUpdate.content['state_key'],
).toString(),
eventUpdate.content);
} else {
final type = eventUpdate.content['type'] as String;
final roomStateBox = client.importantStateEvents.contains(type)
? _preloadRoomStateBox
: _nonPreloadRoomStateBox;
final key = TupleKey(
eventUpdate.roomID,
type,
).toString();
final stateMap = copyMap(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<String, dynamic>('content')
?.tryGetMap<String, dynamic>('m.relates_to') ==
null) {
stateMap[stateKey] = eventUpdate.content;
await roomStateBox.put(key, stateMap);
} else {
final editedEventRelationshipEventId = eventUpdate.content
.tryGetMap<String, dynamic>('content')
?.tryGetMap<String, dynamic>('m.relates_to')
?.tryGet<String>('event_id');
final tmpRoom = client.getRoomById(eventUpdate.roomID) ??
Room(id: eventUpdate.roomID, client: client);
if (eventUpdate.content['type'] !=
EventTypes
.Message || // send anything other than a message
eventUpdate.content
.tryGetMap<String, dynamic>('content')
?.tryGetMap<String, dynamic>('m.relates_to')
?.tryGet<String>('rel_type') !=
RelationshipTypes
.edit || // replies are always latest anyway
editedEventRelationshipEventId ==
tmpRoom.lastEvent
?.eventId || // edit of latest (original event) event
(tmpRoom.lastEvent?.relationshipType ==
RelationshipTypes.edit &&
editedEventRelationshipEventId ==
tmpRoom.lastEvent
?.relationshipEventId) // edit of latest (edited event) event
) {
stateMap[stateKey] = eventUpdate.content;
await roomStateBox.put(key, stateMap);
}
}
}
}
// Store a room account data event
if (eventUpdate.type == EventUpdateType.accountData) {
await _roomAccountDataBox.put(
TupleKey(
eventUpdate.roomID,
eventUpdate.content['type'],
).toString(),
eventUpdate.content,
);
}
}
@override
Future<void> storeFile(Uri mxcUri, Uint8List bytes, int time) async {
final fileStoragePath = this.fileStoragePath;
if (!supportsFileStoring || fileStoragePath == null) return;
final file =
File('${fileStoragePath.path}/${mxcUri.toString().split('/').last}');
if (await file.exists()) return;
await file.writeAsBytes(bytes);
}
@override
Future<void> storeInboundGroupSession(
String roomId,
String sessionId,
String pickle,
String content,
String indexes,
String allowedAtIndex,
String senderKey,
String senderClaimedKey) async {
await _inboundGroupSessionsBox.put(
sessionId,
StoredInboundGroupSession(
roomId: roomId,
sessionId: sessionId,
pickle: pickle,
content: content,
indexes: indexes,
allowedAtIndex: allowedAtIndex,
senderKey: senderKey,
senderClaimedKeys: senderClaimedKey,
uploaded: false,
).toJson());
return;
}
@override
Future<void> storeOutboundGroupSession(
String roomId, String pickle, String deviceIds, int creationTime) async {
await _outboundGroupSessionsBox.put(roomId, <String, dynamic>{
'room_id': roomId,
'pickle': pickle,
'device_ids': deviceIds,
'creation_time': creationTime,
});
return;
}
@override
Future<void> storePrevBatch(
String prevBatch,
) async {
if ((await _clientBox.getAllKeys()).isEmpty) return;
await _clientBox.put('prev_batch', prevBatch);
return;
}
@override
Future<void> storeRoomUpdate(
String roomId, SyncRoomUpdate roomUpdate, Client client) 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
final currentRawRoom = await _roomsBox.get(roomId);
if (currentRawRoom == null) {
await _roomsBox.put(
roomId,
roomUpdate is JoinedRoomUpdate
? Room(
client: client,
id: roomId,
membership: membership,
highlightCount:
roomUpdate.unreadNotifications?.highlightCount?.toInt() ??
0,
notificationCount: roomUpdate
.unreadNotifications?.notificationCount
?.toInt() ??
0,
prev_batch: roomUpdate.timeline?.prevBatch,
summary: roomUpdate.summary,
).toJson()
: Room(
client: client,
id: roomId,
membership: membership,
).toJson());
} else if (roomUpdate is JoinedRoomUpdate) {
final currentRoom = Room.fromJson(copyMap(currentRawRoom), client);
await _roomsBox.put(
roomId,
Room(
client: client,
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(TupleKey(roomId, '').toString());
}
}
@override
Future<void> 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<void> storeSyncFilterId(
String syncFilterId,
) async {
await _clientBox.put('sync_filter_id', syncFilterId);
}
@override
Future<void> storeUserCrossSigningKey(String userId, String publicKey,
String content, bool verified, bool blocked) async {
await _userCrossSigningKeysBox.put(
TupleKey(userId, publicKey).toString(),
{
'user_id': userId,
'public_key': publicKey,
'content': content,
'verified': verified,
'blocked': blocked,
},
);
}
@override
Future<void> storeUserDeviceKey(String userId, String deviceId,
String content, bool verified, bool blocked, int lastActive) async {
await _userDeviceKeysBox.put(TupleKey(userId, deviceId).toString(), {
'user_id': userId,
'device_id': deviceId,
'content': content,
'verified': verified,
'blocked': blocked,
'last_active': lastActive,
'last_sent_message': '',
});
return;
}
@override
Future<void> storeUserDeviceKeysInfo(String userId, bool outdated) async {
await _userDeviceKeysOutdatedBox.put(userId, outdated);
return;
}
@override
Future<void> transaction(Future<void> Function() action) =>
_collection.transaction(action);
@override
Future<void> updateClient(
String homeserverUrl,
String token,
String? refreshToken,
String userId,
String? deviceId,
String? deviceName,
String? prevBatch,
String? olmAccount,
) async {
await transaction(() async {
await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
await _clientBox.put('user_id', userId);
if (deviceId == null) {
await _clientBox.delete('device_id');
} else {
await _clientBox.put('device_id', deviceId);
}
if (deviceName == null) {
await _clientBox.delete('device_name');
} else {
await _clientBox.put('device_name', deviceName);
}
if (prevBatch == null) {
await _clientBox.delete('prev_batch');
} else {
await _clientBox.put('prev_batch', prevBatch);
}
if (olmAccount == null) {
await _clientBox.delete('olm_account');
} else {
await _clientBox.put('olm_account', olmAccount);
}
});
return;
}
@override
Future<void> updateClientKeys(
String olmAccount,
) async {
await _clientBox.put('olm_account', olmAccount);
return;
}
@override
Future<void> updateInboundGroupSessionAllowedAtIndex(
String allowedAtIndex, String roomId, String sessionId) async {
final raw = await _inboundGroupSessionsBox.get(sessionId);
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, raw);
return;
}
@override
Future<void> updateInboundGroupSessionIndexes(
String indexes, String roomId, String sessionId) async {
final raw = await _inboundGroupSessionsBox.get(sessionId);
if (raw == null) {
Logs().w(
'Tried to update inbound group session indexes of a session which was not found in the database!');
return;
}
final json = copyMap(raw);
json['indexes'] = indexes;
await _inboundGroupSessionsBox.put(sessionId, json);
return;
}
@override
Future<List<StoredInboundGroupSession>> getAllInboundGroupSessions() async {
final rawSessions = await _inboundGroupSessionsBox.getAllValues();
return rawSessions.values
.map((raw) => StoredInboundGroupSession.fromJson(copyMap(raw)))
.toList();
}
@override
Future<void> addSeenDeviceId(
String userId,
String deviceId,
String publicKeys,
) =>
_seenDeviceIdsBox.put(TupleKey(userId, deviceId).toString(), publicKeys);
@override
Future<void> addSeenPublicKey(
String publicKey,
String deviceId,
) =>
_seenDeviceKeysBox.put(publicKey, deviceId);
@override
Future<String?> deviceIdSeen(userId, deviceId) async {
final raw =
await _seenDeviceIdsBox.get(TupleKey(userId, deviceId).toString());
if (raw == null) return null;
return raw;
}
@override
Future<String?> publicKeySeen(String publicKey) async {
final raw = await _seenDeviceKeysBox.get(publicKey);
if (raw == null) return null;
return raw;
}
@override
Future<String> exportDump() async {
final dataMap = {
_clientBoxName: await _clientBox.getAllValues(),
_accountDataBoxName: await _accountDataBox.getAllValues(),
_roomsBoxName: await _roomsBox.getAllValues(),
_preloadRoomStateBoxName: await _preloadRoomStateBox.getAllValues(),
_nonPreloadRoomStateBoxName: await _nonPreloadRoomStateBox.getAllValues(),
_roomMembersBoxName: await _roomMembersBox.getAllValues(),
_toDeviceQueueBoxName: await _toDeviceQueueBox.getAllValues(),
_roomAccountDataBoxName: await _roomAccountDataBox.getAllValues(),
_inboundGroupSessionsBoxName:
await _inboundGroupSessionsBox.getAllValues(),
_outboundGroupSessionsBoxName:
await _outboundGroupSessionsBox.getAllValues(),
_olmSessionsBoxName: await _olmSessionsBox.getAllValues(),
_userDeviceKeysBoxName: await _userDeviceKeysBox.getAllValues(),
_userDeviceKeysOutdatedBoxName:
await _userDeviceKeysOutdatedBox.getAllValues(),
_userCrossSigningKeysBoxName:
await _userCrossSigningKeysBox.getAllValues(),
_ssssCacheBoxName: await _ssssCacheBox.getAllValues(),
_presencesBoxName: await _presencesBox.getAllValues(),
_timelineFragmentsBoxName: await _timelineFragmentsBox.getAllValues(),
_eventsBoxName: await _eventsBox.getAllValues(),
_seenDeviceIdsBoxName: await _seenDeviceIdsBox.getAllValues(),
_seenDeviceKeysBoxName: await _seenDeviceKeysBox.getAllValues(),
};
final json = jsonEncode(dataMap);
await clear();
return json;
}
@override
Future<bool> importDump(String export) async {
try {
await clear();
await open();
final json = Map.from(jsonDecode(export)).cast<String, Map>();
for (final key in json[_clientBoxName]!.keys) {
await _clientBox.put(key, json[_clientBoxName]![key]);
}
for (final key in json[_accountDataBoxName]!.keys) {
await _accountDataBox.put(key, json[_accountDataBoxName]![key]);
}
for (final key in json[_roomsBoxName]!.keys) {
await _roomsBox.put(key, json[_roomsBoxName]![key]);
}
for (final key in json[_preloadRoomStateBoxName]!.keys) {
await _preloadRoomStateBox.put(
key, json[_preloadRoomStateBoxName]![key]);
}
for (final key in json[_nonPreloadRoomStateBoxName]!.keys) {
await _nonPreloadRoomStateBox.put(
key, json[_nonPreloadRoomStateBoxName]![key]);
}
for (final key in json[_roomMembersBoxName]!.keys) {
await _roomMembersBox.put(key, json[_roomMembersBoxName]![key]);
}
for (final key in json[_toDeviceQueueBoxName]!.keys) {
await _toDeviceQueueBox.put(key, json[_toDeviceQueueBoxName]![key]);
}
for (final key in json[_roomAccountDataBoxName]!.keys) {
await _roomAccountDataBox.put(key, json[_roomAccountDataBoxName]![key]);
}
for (final key in json[_inboundGroupSessionsBoxName]!.keys) {
await _inboundGroupSessionsBox.put(
key, json[_inboundGroupSessionsBoxName]![key]);
}
for (final key in json[_outboundGroupSessionsBoxName]!.keys) {
await _outboundGroupSessionsBox.put(
key, json[_outboundGroupSessionsBoxName]![key]);
}
for (final key in json[_olmSessionsBoxName]!.keys) {
await _olmSessionsBox.put(key, json[_olmSessionsBoxName]![key]);
}
for (final key in json[_userDeviceKeysBoxName]!.keys) {
await _userDeviceKeysBox.put(key, json[_userDeviceKeysBoxName]![key]);
}
for (final key in json[_userDeviceKeysOutdatedBoxName]!.keys) {
await _userDeviceKeysOutdatedBox.put(
key, json[_userDeviceKeysOutdatedBoxName]![key]);
}
for (final key in json[_userCrossSigningKeysBoxName]!.keys) {
await _userCrossSigningKeysBox.put(
key, json[_userCrossSigningKeysBoxName]![key]);
}
for (final key in json[_ssssCacheBoxName]!.keys) {
await _ssssCacheBox.put(key, json[_ssssCacheBoxName]![key]);
}
for (final key in json[_presencesBoxName]!.keys) {
await _presencesBox.put(key, json[_presencesBoxName]![key]);
}
for (final key in json[_timelineFragmentsBoxName]!.keys) {
await _timelineFragmentsBox.put(
key, json[_timelineFragmentsBoxName]![key]);
}
for (final key in json[_seenDeviceIdsBoxName]!.keys) {
await _seenDeviceIdsBox.put(key, json[_seenDeviceIdsBoxName]![key]);
}
for (final key in json[_seenDeviceKeysBoxName]!.keys) {
await _seenDeviceKeysBox.put(key, json[_seenDeviceKeysBoxName]![key]);
}
return true;
} catch (e, s) {
Logs().e('Database import error: ', e, s);
return false;
}
}
@override
Future<List<String>> getEventIdList(
Room room, {
int start = 0,
bool includeSending = false,
int? limit,
}) =>
runBenchmarked<List<String>>('Get event id list', () async {
// Get the synced event IDs from the store
final timelineKey = TupleKey(room.id, '').toString();
final timelineEventIds = List<String>.from(
(await _timelineFragmentsBox.get(timelineKey)) ?? []);
// Get the local stored SENDING events from the store
late final List<String> sendingEventIds;
if (!includeSending) {
sendingEventIds = [];
} else {
final sendingTimelineKey = TupleKey(room.id, 'SENDING').toString();
sendingEventIds = List<String>.from(
(await _timelineFragmentsBox.get(sendingTimelineKey)) ?? []);
}
// Combine those two lists while respecting the start and limit parameters.
// Create a new list object instead of concatonating list to prevent
// random type errors.
final eventIds = [
...sendingEventIds,
...timelineEventIds,
];
if (limit != null && eventIds.length > limit) {
eventIds.removeRange(limit, eventIds.length);
}
return eventIds;
});
@override
Future<void> storePresence(String userId, CachedPresence presence) =>
_presencesBox.put(userId, presence.toJson());
@override
Future<CachedPresence?> getPresence(String userId) async {
final rawPresence = await _presencesBox.get(userId);
if (rawPresence == null) return null;
return CachedPresence.fromJson(copyMap(rawPresence));
}
@override
Future<void> delete() => _collection.delete();
}