From fac91f8618437c1ee1d4f98e4dab7d65106262f1 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Wed, 10 Nov 2021 14:02:29 +0100 Subject: [PATCH 01/26] chore: Bump version --- CHANGELOG.md | 7 +++++++ pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c41a26d..a85c05a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.7.0-nullsafety.5] - 10nd Nov 2021 +- fix: Edits as lastEvent do not update +- fix: JSON parsing in decryptRoomEvent method +- fix: Wrong null check in hive database +- fix: crash on invalid displaynames +- chore: Update matrix_api_lite + ## [0.7.0-nullsafety.4] - 09nd Nov 2021 - feat: More advanced create chat methods (encryption is now enabled by default) - feat: Make waiting on init db optional diff --git a/pubspec.yaml b/pubspec.yaml index 40246e4c..5c56c5e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.4 +version: 0.7.0-nullsafety.5 homepage: https://famedly.com environment: From f052957c0adeeda2f84d6b865d218c18a8f10d8c Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 11 Nov 2021 09:48:50 +0100 Subject: [PATCH 02/26] fix: Change eventstatus of edits in prevEvent Unfortunately the last fix was not working. This fixes it for real now and also adds a test case to make sure it never breaks again. --- lib/src/room.dart | 3 ++- test/room_test.dart | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/src/room.dart b/lib/src/room.dart index b8271112..43c98a16 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -187,7 +187,8 @@ class Room { state.relationshipEventId != null && state.relationshipType == RelationshipTypes.edit && lastEvent != null && - !lastEvent.matchesEventOrTransactionId(state.relationshipEventId)) { + !state.matchesEventOrTransactionId(lastEvent.eventId) && + lastEvent.eventId != state.relationshipEventId) { return; } diff --git a/test/room_test.dart b/test/room_test.dart index b68f08de..651aa83f 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -234,9 +234,35 @@ void main() { 'm.relates_to': {'rel_type': 'm.replace', 'event_id': '2'}, }, stateKey: '', + status: EventStatus.sending, ), ); expect(room.lastEvent?.body, 'edited cdc'); + expect(room.lastEvent?.status, EventStatus.sending); + expect(room.lastEvent?.eventId, '4'); + + // Status update on edits working? + room.setState( + Event( + senderId: '@test:example.com', + type: 'm.room.encrypted', + room: room, + eventId: '5', + unsigned: {'transaction_id': '4'}, + originServerTs: DateTime.now(), + content: { + 'msgtype': 'm.text', + 'body': 'edited cdc', + 'm.new_content': {'msgtype': 'm.text', 'body': 'edited cdc'}, + 'm.relates_to': {'rel_type': 'm.replace', 'event_id': '2'}, + }, + stateKey: '', + status: EventStatus.sent, + ), + ); + expect(room.lastEvent?.eventId, '5'); + expect(room.lastEvent?.body, 'edited cdc'); + expect(room.lastEvent?.status, EventStatus.sent); }); test('lastEvent when reply parent edited', () async { room.setState( From c8c4562f7067459a2fd033ea3517e061d11a1795 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Sat, 13 Nov 2021 11:58:07 +0100 Subject: [PATCH 03/26] fix: Dont enable e2ee without encryption support This also adds a missing visibility parameter to the createGroupChat method. --- lib/src/client.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index cc46ce78..36b1b494 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -568,7 +568,8 @@ class Client extends MatrixApi { final directChatRoomId = getDirectChatFromUserId(mxid); if (directChatRoomId != null) return directChatRoomId; - enableEncryption ??= await userOwnsEncryptionKeys(mxid); + enableEncryption ??= + encryptionEnabled && await userOwnsEncryptionKeys(mxid); if (enableEncryption) { initialState ??= []; if (!initialState.any((s) => s.type == EventTypes.Encryption)) { @@ -609,6 +610,7 @@ class Client extends MatrixApi { List? invite, CreateRoomPreset preset = CreateRoomPreset.privateChat, List? initialState, + Visibility? visibility, bool waitForSync = true, }) async { enableEncryption ??= @@ -629,6 +631,7 @@ class Client extends MatrixApi { preset: preset, name: groupName, initialState: initialState, + visibility: visibility, ); if (waitForSync) { From b7565af56ff12308d5bafa85a39c782310ba7c8d Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Mon, 15 Nov 2021 10:57:55 +0100 Subject: [PATCH 04/26] fix: use originServerTs to check if state event is old Due to server bugs or whatever it sometimes happens that old state events appear in the setState method in the room class. Previously we checked if we already know this event ID, but for this we needed to check the timeline which is very fluid. Also this is a database operation in a non-async method which works in Hive but not in Sembast. Using originServerTs is not 100% safe as well but should be more stable because the chance that servers have veeery wrong time (which is necessary here) is much lower than the risk that the timeline is not long enough to know the old event. --- lib/src/database/database_api.dart | 2 -- lib/src/database/hive_database.dart | 4 ---- lib/src/room.dart | 3 ++- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 368f1ce8..7bdc6872 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -67,8 +67,6 @@ abstract class DatabaseApi { Future getEventById(String eventId, Room room); - bool eventIsKnown(String eventId, String roomId); - Future forgetRoom(String roomId); Future clearCache(); diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 90891efb..884b2c53 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -371,10 +371,6 @@ class FamedlySdkHiveDatabase extends DatabaseApi { 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 diff --git a/lib/src/room.dart b/lib/src/room.dart index 43c98a16..b674bf81 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -203,7 +203,8 @@ class Room { final prevEvent = getState(state.type, stateKey); if (prevEvent != null && prevEvent.eventId != state.eventId && - client.database?.eventIsKnown(state.eventId, roomId) == true) { + prevEvent.originServerTs.millisecondsSinceEpoch > + state.originServerTs.millisecondsSinceEpoch) { return; } From 13658b7da85b8853ba75b96460a57589e2914f8e Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Mon, 15 Nov 2021 13:08:31 +0100 Subject: [PATCH 05/26] chore: Trim formatted username fallback A user has a mxid with a trailing "-" which becomes a whitespace here. We should trim those whitespaces after formatting. --- lib/src/user.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/user.dart b/lib/src/user.dart index 8147a0d7..2f9c53cc 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -129,7 +129,7 @@ class User extends Event { words[i] = words[i][0].toUpperCase() + words[i].substring(1); } } - return words.join(' '); + return words.join(' ').trim(); } return 'Unknown user'; } From 66bf8e6ace796337ea0c4eec6a2a783db89578f5 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 16 Nov 2021 08:18:07 +0100 Subject: [PATCH 06/26] fix: HtmlToText crashes with an empty code block --- lib/src/utils/html_to_text.dart | 24 ++--- test/html_to_text_test.dart | 149 ++++++++++++++++---------------- 2 files changed, 89 insertions(+), 84 deletions(-) diff --git a/lib/src/utils/html_to_text.dart b/lib/src/utils/html_to_text.dart index 2abbf0a4..aa4d0bf2 100644 --- a/lib/src/utils/html_to_text.dart +++ b/lib/src/utils/html_to_text.dart @@ -50,11 +50,13 @@ class HtmlToText { .firstMatch(text); if (match == null) { text = HtmlUnescape().convert(text); - if (text[0] != '\n') { - text = '\n$text'; - } - if (text[text.length - 1] != '\n') { - text += '\n'; + if (text.isNotEmpty) { + if (text[0] != '\n') { + text = '\n$text'; + } + if (text[text.length - 1] != '\n') { + text += '\n'; + } } return text; } @@ -64,11 +66,13 @@ class HtmlToText { text = text.replaceAll( RegExp(r'$', multiLine: false, caseSensitive: false), ''); text = HtmlUnescape().convert(text); - if (text[0] != '\n') { - text = '\n$text'; - } - if (text[text.length - 1] != '\n') { - text += '\n'; + if (text.isNotEmpty) { + if (text[0] != '\n') { + text = '\n$text'; + } + if (text[text.length - 1] != '\n') { + text += '\n'; + } } final language = RegExp(r'language-(\w+)', multiLine: false, caseSensitive: false) diff --git a/test/html_to_text_test.dart b/test/html_to_text_test.dart index fc29a6a3..9dde56e7 100644 --- a/test/html_to_text_test.dart +++ b/test/html_to_text_test.dart @@ -21,80 +21,81 @@ import 'package:test/test.dart'; void main() { group('htmlToText', () { - test('stuff', () async { - final testMap = { - '': '', - 'hello world\nthis is a test': 'hello world\nthis is a test', - 'That\'s not a test, this is a test': - '*That\'s* not a test, **this** is a test', - 'Visit our website (outdated)': - 'Visit ~~πŸ”—our website~~ (outdated)', - '(cw spiders) spiders are pretty cool': - '(cw spiders) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ', - 'spiders are pretty cool': - '(cw spiders) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ', - 'a test case': 'a test case', - 'List of cute animals:\n
    \n
  • Kittens
  • \n
  • Puppies
  • \n
  • Snakes
    (I think they\'re cute!)
  • \n
\n(This list is incomplete, you can help by adding to it!)': - 'List of cute animals:\n● Kittens\n● Puppies\n● Snakes\n (I think they\'re cute!)\n(This list is incomplete, you can help by adding to it!)', - 'fox': '*fox*', - 'fox': '*fox*', - 'fox': '**fox**', - 'fox': '**fox**', - 'fox': '__fox__', - 'fox': '__fox__', - 'fox': '~~fox~~', - 'fox': '~~fox~~', - 'fox': '~~fox~~', - '>fox': '`>fox`', - '
meep
': '```\nmeep\n```', - '
meep\n
': '```\nmeep\n```', - '
meep
': - '```floof\nmeep\n```', - 'before
code
after': 'before\n```\ncode\n```\nafter', - '

before

code

after

': - 'before\n```\ncode\n```\nafter', - '

fox

': 'fox', - '

fox

floof

': 'fox\n\nfloof', - 'website': 'πŸ”—website', - 'fox': 'fox', - 'fox': 'fox', - ':wave:': ':wave:', - 'fox
floof': 'fox\nfloof', - '
fox
floof': '> fox\nfloof', - '

fox

floof': '> fox\nfloof', - '

fox

floof

': '> fox\nfloof', - 'a
fox
floof': 'a\n> fox\nfloof', - '
fox
floof
fluff': - '> > fox\n> floof\nfluff', - '
  • hey
    • a
    • b
  • foxies
': - '● hey\n β—‹ a\n β—‹ b\n● foxies', - '
  1. a
  2. b
': '1. a\n2. b', - '
  1. a
  2. b
': '42. a\n43. b', - '
  1. a
    1. aa
    2. bb
  2. b
': - '1. a\n 1. aa\n 2. bb\n2. b', - '
  1. a
    • aa
    • bb
  2. b
': - '1. a\n β—‹ aa\n β—‹ bb\n2. b', - '
  • a
    1. aa
    2. bb
  • b
': - '● a\n 1. aa\n 2. bb\n● b', - 'bunnyfox': 'fox', - 'fox
floof': 'fox\n----------\nfloof', - '

fox


floof

': 'fox\n----------\nfloof', - '

fox

floof': '# fox\nfloof', - '

fox

floof

': '# fox\nfloof', - 'floof

fox

': 'floof\n# fox', - '

floof

fox

': 'floof\n# fox', - '

fox

': '## fox', - '

fox

': '### fox', - '

fox

': '#### fox', - '
fox
': '##### fox', - '
fox
': '###### fox', - 'fox': 'fox', - '

fox

\n

floof

': 'fox\n\nfloof', - 'beep

fox

\n

floof

': 'fox\n\nfloof', - }; - for (final entry in testMap.entries) { + final testMap = { + '': '', + 'hello world\nthis is a test': 'hello world\nthis is a test', + 'That\'s not a test, this is a test': + '*That\'s* not a test, **this** is a test', + 'Visit our website (outdated)': + 'Visit ~~πŸ”—our website~~ (outdated)', + '(cw spiders) spiders are pretty cool': + '(cw spiders) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ', + 'spiders are pretty cool': + '(cw spiders) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ', + 'a test case': 'a test case', + 'List of cute animals:\n
    \n
  • Kittens
  • \n
  • Puppies
  • \n
  • Snakes
    (I think they\'re cute!)
  • \n
\n(This list is incomplete, you can help by adding to it!)': + 'List of cute animals:\n● Kittens\n● Puppies\n● Snakes\n (I think they\'re cute!)\n(This list is incomplete, you can help by adding to it!)', + 'fox': '*fox*', + 'fox': '*fox*', + 'fox': '**fox**', + 'fox': '**fox**', + 'fox': '__fox__', + 'fox': '__fox__', + 'fox': '~~fox~~', + 'fox': '~~fox~~', + 'fox': '~~fox~~', + '>fox': '`>fox`', + '
meep
': '```\nmeep\n```', + '
meep\n
': '```\nmeep\n```', + '
meep
': + '```floof\nmeep\n```', + 'before
code
after': 'before\n```\ncode\n```\nafter', + '

before

code

after

': + 'before\n```\ncode\n```\nafter', + '

fox

': 'fox', + '

fox

floof

': 'fox\n\nfloof', + 'website': 'πŸ”—website', + 'fox': 'fox', + 'fox': 'fox', + ':wave:': ':wave:', + 'fox
floof': 'fox\nfloof', + '
fox
floof': '> fox\nfloof', + '

fox

floof': '> fox\nfloof', + '

fox

floof

': '> fox\nfloof', + 'a
fox
floof': 'a\n> fox\nfloof', + '
fox
floof
fluff': + '> > fox\n> floof\nfluff', + '
  • hey
    • a
    • b
  • foxies
': + '● hey\n β—‹ a\n β—‹ b\n● foxies', + '
  1. a
  2. b
': '1. a\n2. b', + '
  1. a
  2. b
': '42. a\n43. b', + '
  1. a
    1. aa
    2. bb
  2. b
': + '1. a\n 1. aa\n 2. bb\n2. b', + '
  1. a
    • aa
    • bb
  2. b
': + '1. a\n β—‹ aa\n β—‹ bb\n2. b', + '
  • a
    1. aa
    2. bb
  • b
': + '● a\n 1. aa\n 2. bb\n● b', + 'bunnyfox': 'fox', + 'fox
floof': 'fox\n----------\nfloof', + '

fox


floof

': 'fox\n----------\nfloof', + '

fox

floof': '# fox\nfloof', + '

fox

floof

': '# fox\nfloof', + 'floof

fox

': 'floof\n# fox', + '

floof

fox

': 'floof\n# fox', + '

fox

': '## fox', + '

fox

': '### fox', + '

fox

': '#### fox', + '
fox
': '##### fox', + '
fox
': '###### fox', + 'fox': 'fox', + '

fox

\n

floof

': 'fox\n\nfloof', + 'beep

fox

\n

floof

': 'fox\n\nfloof', + '
': '``````', + }; + for (final entry in testMap.entries) { + test(entry.key, () async { expect(HtmlToText.convert(entry.key), entry.value); - } - }); + }); + } }); } From f5051a5afe0986c205ad3a03154ba4b4f3c87c12 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 11 Nov 2021 11:05:33 +0100 Subject: [PATCH 07/26] feat: Implement sembast store refactor: Use typed store --- lib/matrix.dart | 1 + lib/src/database/sembast_database.dart | 1528 ++++++++++++++++++++++++ pubspec.yaml | 1 + test/database_api_test.dart | 5 + test/fake_database.dart | 7 + 5 files changed, 1542 insertions(+) create mode 100644 lib/src/database/sembast_database.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index bbd34d5a..374d9319 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -24,6 +24,7 @@ export 'package:matrix_api_lite/matrix_api_lite.dart'; export 'src/client.dart'; export 'src/database/database_api.dart'; export 'src/database/hive_database.dart'; +export 'src/database/sembast_database.dart'; export 'src/event.dart'; export 'src/event_status.dart'; export 'src/room.dart'; diff --git a/lib/src/database/sembast_database.dart b/lib/src/database/sembast_database.dart new file mode 100644 index 00000000..c11b2c4a --- /dev/null +++ b/lib/src/database/sembast_database.dart @@ -0,0 +1,1528 @@ +/* + * 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:sembast/sembast.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' hide Filter; +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:sembast/sembast_memory.dart'; +import 'package:sembast/utils/value_utils.dart'; + +/// Sembast implementation of the DatabaseAPI. You need to pass through the +/// correct dbfactory. By default it uses an in-memory database so there is no +/// persistent storage. Learn more on: https://pub.dev/packages/sembast +class MatrixSembastDatabase extends DatabaseApi { + static const int version = 5; + final String name; + final String path; + late final Database _database; + Transaction? _currentTransaction; + + /// The transaction to use here. If there is a real transaction ongoing it + /// will use it and otherwise just use the default which is the database + /// object itself. + DatabaseClient get txn => (_transactionLock?.isCompleted ?? true) + ? _database + : _currentTransaction ?? _database; + + final DatabaseFactory _dbFactory; + + late final StoreRef _clientBox = StoreRef(_clientBoxName); + late final StoreRef> _accountDataBox = + StoreRef(_accountDataBoxName); + late final StoreRef> _roomsBox = + StoreRef(_roomsBoxName); + late final StoreRef> _toDeviceQueueBox = + StoreRef(_toDeviceQueueBoxName); + + /// Key is a tuple as SembastKey(roomId, type) where stateKey can be + /// an empty string. + late final StoreRef> _roomStateBox = + StoreRef(_roomStateBoxName); + + /// Key is a tuple as SembastKey(roomId, userId) + late final StoreRef> _roomMembersBox = + StoreRef(_roomMembersBoxName); + + /// Key is a tuple as SembastKey(roomId, type) + late final StoreRef> _roomAccountDataBox = + StoreRef(_roomAccountDataBoxName); + late final StoreRef> _inboundGroupSessionsBox = + StoreRef(_inboundGroupSessionsBoxName); + late final StoreRef> _outboundGroupSessionsBox = + StoreRef(_outboundGroupSessionsBoxName); + late final StoreRef> _olmSessionsBox = + StoreRef(_olmSessionsBoxName); + + /// Key is a tuple as SembastKey(userId, deviceId) + late final StoreRef> _userDeviceKeysBox = + StoreRef(_userDeviceKeysBoxName); + + /// Key is the user ID as a String + late final StoreRef _userDeviceKeysOutdatedBox = + StoreRef(_userDeviceKeysOutdatedBoxName); + + /// Key is a tuple as SembastKey(userId, publicKey) + late final StoreRef> _userCrossSigningKeysBox = + StoreRef(_userCrossSigningKeysBoxName); + late final StoreRef> _ssssCacheBox = + StoreRef(_ssssCacheBoxName); + late final StoreRef> _presencesBox = + StoreRef(_presencesBoxName); + + /// Key is a tuple as Multikey(roomId, fragmentId) while the default + /// fragmentId is an empty String + late final StoreRef> _timelineFragmentsBox = + StoreRef(_timelineFragmentsBoxName); + + /// Key is a tuple as SembastKey(roomId, eventId) + late final StoreRef> _eventsBox = + StoreRef(_eventsBoxName); + + /// Key is a tuple as SembastKey(userId, deviceId) + late final StoreRef _seenDeviceIdsBox = + StoreRef(_seenDeviceIdsBoxName); + + late final StoreRef _seenDeviceKeysBox = + StoreRef(_seenDeviceKeysBoxName); + + 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 SembastCodec? codec; + + MatrixSembastDatabase( + this.name, { + this.path = './database.db', + this.codec, + DatabaseFactory? dbFactory, + }) : _dbFactory = dbFactory ?? databaseFactoryMemory; + + @override + int get maxFileSize => 0; + + Future _actionOnAllBoxes(Future Function(StoreRef 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 { + _database = await _dbFactory.openDatabase(path, codec: codec); + + // Check version and check if we need a migration + final currentVersion = + (await _clientBox.record('version').get(txn) as int?); + if (currentVersion == null) { + await _clientBox.record('version').put(txn, version); + } else if (currentVersion != version) { + await _migrateFromVersion(currentVersion); + } + + return; + } + + Future _migrateFromVersion(int currentVersion) async { + Logs() + .i('Migrate Sembast database from version $currentVersion to $version'); + if (version == 5) { + await _database.transaction((txn) async { + final keys = await _userDeviceKeysBox.findKeys(txn); + for (final key in keys) { + try { + final raw = await _userDeviceKeysBox.record(key).get(txn) as Map; + if (!raw.containsKey('keys')) continue; + final deviceKeys = DeviceKeys.fromJson( + cloneMap(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.record('version').put(txn, version); + } + + @override + Future clear() async { + Logs().i('Clear and close Sembast database...'); + await _actionOnAllBoxes((box) => box.delete(txn)); + return; + } + + @override + Future clearCache() async { + await _roomsBox.delete(txn); + await _accountDataBox.delete(txn); + await _roomStateBox.delete(txn); + await _roomMembersBox.delete(txn); + await _eventsBox.delete(txn); + await _timelineFragmentsBox.delete(txn); + await _outboundGroupSessionsBox.delete(txn); + await _presencesBox.delete(txn); + await _clientBox.record('prev_batch').delete(txn); + } + + @override + Future clearSSSSCache() async { + await _ssssCacheBox.delete(txn); + } + + @override + Future close() async { + // We never close a sembast database + // https://github.com/tekartik/sembast.dart/issues/219 + } + + @override + Future deleteFromToDeviceQueue(int id) async { + await _toDeviceQueueBox.record(id).delete(txn); + return; + } + + @override + Future deleteOldFiles(int savedAt) async { + return; + } + + @override + Future forgetRoom(String roomId) async { + await _timelineFragmentsBox + .record(SembastKey(roomId, '').toString()) + .delete(txn); + final eventKeys = await _eventsBox.findKeys(txn); + for (final key in eventKeys) { + final multiKey = SembastKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _eventsBox.record(key).delete(txn); + } + final roomStateKeys = await _roomStateBox.findKeys(txn); + for (final key in roomStateKeys) { + final multiKey = SembastKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomStateBox.record(key).delete(txn); + } + final roomMembersKeys = await _roomMembersBox.findKeys(txn); + for (final key in roomMembersKeys) { + final multiKey = SembastKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomMembersBox.record(key).delete(txn); + } + final roomAccountData = await _roomAccountDataBox.findKeys(txn); + for (final key in roomAccountData) { + final multiKey = SembastKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomAccountDataBox.record(key).delete(txn); + } + await _roomsBox.record(roomId).delete(txn); + } + + @override + Future> getAccountData() async { + // We can probably remove this benchmark once we know that findKeys is + // nearly instant anyway. + final keys = await runBenchmarked( + 'Get account data keys from Sembast', + () => _accountDataBox.findKeys(txn), + ); + return runBenchmarked>( + 'Get all account data from Sembast', () async { + final accountData = {}; + await _database.transaction((txn) async { + for (final key in keys) { + final raw = await _accountDataBox.record(key).get(txn); + if (raw == null) continue; + accountData[key.toString()] = BasicEvent( + type: key.toString(), + content: cloneMap(raw), + ); + } + }); + return accountData; + }, keys.length); + } + + @override + Future?> getClient(String name) => + runBenchmarked('Get Client from Sembast', () async { + final map = {}; + final keys = await _clientBox.findKeys(txn); + for (final key in keys) { + if (key == 'version') continue; + map[key] = await _clientBox.record(key).get(txn); + } + if (map.isEmpty) return null; + return map; + }); + + @override + Future getEventById(String eventId, Room room) async { + final raw = await _eventsBox + .record(SembastKey(room.id, eventId).toString()) + .get(txn); + if (raw == null) return null; + return Event.fromJson(cloneMap(raw), room); + } + + /// 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( + cloneMap( + (await _eventsBox + .record(SembastKey(room.id, eventId).toString()) + .get(txn))!, + ), + room, + ), + ) + .toList()); + + @override + Future> getEventList( + Room room, { + int start = 0, + int? limit, + }) => + runBenchmarked>('Get event list', () async { + // Get the synced event IDs from the store + final timelineKey = SembastKey(room.id, '').toString(); + final timelineEventIds = + (await _timelineFragmentsBox.record(timelineKey).get(txn) ?? []); + + // Get the local stored SENDING events from the store + late final List sendingEventIds; + if (start != 0) { + sendingEventIds = []; + } else { + final sendingTimelineKey = SembastKey(room.id, 'SENDING').toString(); + sendingEventIds = (await _timelineFragmentsBox + .record(sendingTimelineKey) + .get(txn) ?? + []); + } + + // 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.record(sessionId).get(txn); + if (raw == null) return null; + return StoredInboundGroupSession.fromJson(cloneMap(raw)); + } + + @override + Future> + getInboundGroupSessionsToUpload() async { + final sessions = await _inboundGroupSessionsBox.find( + txn, + finder: Finder( + limit: 50, + filter: Filter.equals('uploaded', false), + ), + ); + return sessions + .map((json) => StoredInboundGroupSession.fromJson(cloneMap(json.value))) + .toList(); + } + + @override + Future> getLastSentMessageUserDeviceKey( + String userId, String deviceId) async { + final raw = await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) return []; + return [raw['last_sent_message'] as String]; + } + + @override + Future storeOlmSession(String identityKey, String sessionId, + String pickle, int lastReceived) async { + final rawSessions = + cloneMap(await _olmSessionsBox.record(identityKey).get(txn) ?? {}); + rawSessions[sessionId] = { + 'identity_key': identityKey, + 'pickle': pickle, + 'session_id': sessionId, + 'last_received': lastReceived, + }; + await _olmSessionsBox.record(identityKey).put(txn, rawSessions); + return; + } + + @override + Future> getOlmSessions( + String identityKey, String userId) async { + final rawSessions = await _olmSessionsBox.record(identityKey).get(txn); + if (rawSessions == null || rawSessions.isEmpty) return []; + return rawSessions.values + .map((json) => OlmSession.fromJson(cloneMap(json as Map), 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.record(roomId).get(txn); + if (raw == null) return null; + return OutboundGroupSession.fromJson(cloneMap(raw), userId); + } + + @override + Future> getRoomList(Client client) async { + // We can probably remove this benchmark once we know that findKeys is + // nearly instant anyway. + final keys = await runBenchmarked( + 'Get rooms box keys', + () => _roomsBox.findKeys(txn), + ); + return runBenchmarked>('Get room list from Sembast', () async { + final rooms = {}; + await _database.transaction((txn) async { + final userID = client.userID; + final importantRoomStates = client.importantStateEvents; + for (final key in keys) { + // Get the room + final raw = await _roomsBox.record(key).get(txn); + if (raw == null) continue; + final room = Room.fromJson(cloneMap(raw), client); + + // let's see if we need any m.room.member events + // We always need the member event for ourself + final membersToPostload = {if (userID != null) 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 + final lastEvent = room.getState(EventTypes.Message); + if (lastEvent != null && !room.isDirectChat) { + membersToPostload.add(lastEvent.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 + .record(SembastKey(room.id, userId).toString()) + .get(txn); + if (state == null) { + Logs().w('Unable to post load member $userId'); + continue; + } + room.setState(Event.fromJson(cloneMap(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 + .record(SembastKey(room.id, type).toString()) + .get(txn); + if (states == null) continue; + final stateEvents = states.values + .map((raw) => Event.fromJson(cloneMap(raw as Map), 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 + final accountDataKeys = await _roomAccountDataBox.findKeys(txn); + for (final key in accountDataKeys) { + final roomId = SembastKey.fromString(key).parts.first; + if (rooms.containsKey(roomId)) { + final raw = await _roomAccountDataBox.record(key).get(txn); + if (raw == null) continue; + final basicRoomEvent = BasicRoomEvent.fromJson( + cloneMap(raw), + ); + rooms[roomId]!.roomAccountData[basicRoomEvent.type] = + basicRoomEvent; + } else { + Logs().w( + 'Found account data for unknown room $roomId. Delete now...'); + await _roomAccountDataBox.record(key).delete(txn); + } + } + }); + return rooms.values.toList(); + }, keys.length); + } + + @override + Future getSSSSCache(String type) async { + final raw = await _ssssCacheBox.record(type).get(txn); + if (raw == null) return null; + return SSSSCache.fromJson(cloneMap(raw)); + } + + @override + Future> getToDeviceEventQueue() async { + final keys = await _toDeviceQueueBox.findKeys(txn); + return await Future.wait(keys.map((i) async { + final raw = await _toDeviceQueueBox.record(i).get(txn); + final json = cloneMap(raw!); + json['id'] = i; + return QueuedToDeviceEvent.fromJson(json); + }).toList()); + } + + @override + Future> getUnimportantRoomEventStatesForRoom( + List events, Room room) async { + final keys = (await _roomStateBox.findKeys(txn)).where((key) { + final tuple = SembastKey.fromString(key); + return tuple.parts.first == room.id && !events.contains(tuple.parts[1]); + }); + + final unimportantEvents = []; + for (final key in keys) { + final states = await _roomStateBox.record(key).get(txn); + if (states == null) continue; + unimportantEvents.addAll(states.values + .map((raw) => Event.fromJson(cloneMap(raw as Map), room))); + } + return unimportantEvents; + } + + @override + Future getUser(String userId, Room room) async { + final state = await _roomMembersBox + .record(SembastKey(room.id, userId).toString()) + .get(txn); + if (state == null) return null; + return Event.fromJson(cloneMap(state), room).asUser; + } + + @override + Future> getUserDeviceKeys(Client client) async { + // We can probably remove this benchmark once we know that findKeys is + // nearly instant anyway. + final keys = await runBenchmarked( + 'Get user device keys box keys', + () => _userDeviceKeysBox.findKeys(txn), + ); + return runBenchmarked>( + 'Get all user device keys from Sembast', () async { + final deviceKeysOutdated = await _userDeviceKeysOutdatedBox.findKeys(txn); + if (deviceKeysOutdated.isEmpty) { + return {}; + } + final res = {}; + await _database.transaction((txn) async { + for (final userId in deviceKeysOutdated) { + final deviceKeysBoxKeys = keys.where((tuple) { + final tupleKey = SembastKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + final crossSigningKeysBoxKeys = + (await _userCrossSigningKeysBox.findKeys(txn)).where((tuple) { + final tupleKey = SembastKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + res[userId] = DeviceKeysList.fromDbJson( + { + 'client_id': client.id, + 'user_id': userId, + 'outdated': + await _userDeviceKeysOutdatedBox.record(userId).get(txn), + }, + await Future.wait(deviceKeysBoxKeys.map((key) async => + cloneMap((await _userDeviceKeysBox.record(key).get(txn))!))), + await Future.wait(crossSigningKeysBoxKeys.map((key) async => + cloneMap( + (await _userCrossSigningKeysBox.record(key).get(txn))!))), + client); + } + }); + return res; + }, keys.length); + } + + @override + Future> getUsers(Room room) async { + final users = []; + await _database.transaction((txn) async { + final keys = await _roomMembersBox.findKeys(txn); + for (final key in keys) { + final statesKey = SembastKey.fromString(key); + if (statesKey.parts[0] != room.id) continue; + final state = await _roomMembersBox.record(key).get(txn); + if (state == null) continue; + users.add(Event.fromJson(cloneMap(state), room).asUser); + } + }); + return users; + } + + @override + Future insertClient( + String name, + String homeserverUrl, + String token, + String userId, + String? deviceId, + String? deviceName, + String? prevBatch, + String? olmAccount) => + _database.transaction((txn) async { + await _clientBox.record('homeserver_url').put(txn, homeserverUrl); + await _clientBox.record('token').put(txn, token); + await _clientBox.record('user_id').put(txn, userId); + if (deviceId == null) { + await _clientBox.record('device_id').delete(txn); + } else { + await _clientBox.record('device_id').put(txn, deviceId); + } + if (deviceName == null) { + await _clientBox.record('device_name').delete(txn); + } else { + await _clientBox.record('device_name').put(txn, deviceName); + } + if (prevBatch == null) { + await _clientBox.record('prev_batch').delete(txn); + } else { + await _clientBox.record('prev_batch').put(txn, prevBatch); + } + if (olmAccount == null) { + await _clientBox.record('olm_account').delete(txn); + } else { + await _clientBox.record('olm_account').put(txn, olmAccount); + } + await _clientBox.record('sync_filter_id').delete(txn); + }); + + @override + Future insertIntoToDeviceQueue( + String type, String txnId, String content) async { + return await _toDeviceQueueBox.add(txn, { + 'type': type, + 'txn_id': txnId, + 'content': content, + }); + } + + @override + Future markInboundGroupSessionAsUploaded( + String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); + if (raw == null) { + Logs().w( + 'Tried to mark inbound group session as uploaded which was not found in the database!'); + return; + } + final json = cloneMap(raw); + json['uploaded'] = true; + await _inboundGroupSessionsBox.record(sessionId).put(txn, json); + return; + } + + @override + Future markInboundGroupSessionsAsNeedingUpload() async { + await _database.transaction((txn) async { + final keys = await _inboundGroupSessionsBox.findKeys(txn); + for (final sessionId in keys) { + final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); + if (raw == null) continue; + final json = cloneMap(raw); + json['uploaded'] = false; + await _inboundGroupSessionsBox.record(sessionId).put(txn, json); + } + }); + return; + } + + @override + Future removeEvent(String eventId, String roomId) async { + await _eventsBox.record(SembastKey(roomId, eventId).toString()).delete(txn); + final keys = await _timelineFragmentsBox.findKeys(txn); + for (final key in keys) { + final multiKey = SembastKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + final eventIds = List.from( + await _timelineFragmentsBox.record(key).get(txn) ?? []); + final prevLength = eventIds.length; + eventIds.removeWhere((id) => id == eventId); + if (eventIds.length < prevLength) { + await _timelineFragmentsBox.record(key).put(txn, eventIds); + } + } + return; + } + + @override + Future removeOutboundGroupSession(String roomId) async { + await _outboundGroupSessionsBox.record(roomId).delete(txn); + return; + } + + @override + Future removeUserCrossSigningKey( + String userId, String publicKey) async { + await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .delete(txn); + return; + } + + @override + Future removeUserDeviceKey(String userId, String deviceId) async { + await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .delete(txn); + return; + } + + @override + Future resetNotificationCount(String roomId) async { + final raw = await _roomsBox.record(roomId).get(txn); + if (raw == null) return; + final json = cloneMap(raw); + json['notification_count'] = json['highlight_count'] = 0; + await _roomsBox.record(roomId).put(txn, json); + return; + } + + @override + Future setBlockedUserCrossSigningKey( + bool blocked, String userId, String publicKey) async { + final raw = await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .get(txn); + if (raw == null) { + Logs().w('User cross signing key $publicKey of $userId not found'); + return; + } + final json = cloneMap(raw); + json['blocked'] = blocked; + await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .put( + txn, + json, + ); + return; + } + + @override + Future setBlockedUserDeviceKey( + bool blocked, String userId, String deviceId) async { + final raw = await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) { + Logs().w('Device key $deviceId of $userId not found'); + return; + } + final json = cloneMap(raw); + json['blocked'] = blocked; + await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .put( + txn, + json, + ); + return; + } + + @override + Future setLastActiveUserDeviceKey( + int lastActive, String userId, String deviceId) async { + final raw = await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) { + Logs().w('Device key $deviceId of $userId not found'); + return; + } + final json = cloneMap(raw); + json['last_active'] = lastActive; + await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .put( + txn, + json, + ); + } + + @override + Future setLastSentMessageUserDeviceKey( + String lastSentMessage, String userId, String deviceId) async { + final raw = await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) { + Logs().w('Device key $deviceId of $userId not found'); + return; + } + final json = cloneMap(raw); + json['last_sent_message'] = lastSentMessage; + await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .put( + txn, + json, + ); + } + + @override + Future setRoomPrevBatch( + String prevBatch, String roomId, Client client) async { + final raw = await _roomsBox.record(roomId).get(txn); + if (raw == null) return; + final room = Room.fromJson(cloneMap(raw), client); + room.prev_batch = prevBatch; + await _roomsBox.record(roomId).put(txn, room.toJson()); + return; + } + + @override + Future setVerifiedUserCrossSigningKey( + bool verified, String userId, String publicKey) async { + final raw = await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .get(txn) ?? + {}; + final json = cloneMap(raw); + json['verified'] = verified; + await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .put( + txn, + json, + ); + return; + } + + @override + Future setVerifiedUserDeviceKey( + bool verified, String userId, String deviceId) async { + final raw = await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) { + Logs().w('Device key $deviceId of $userId not found'); + return; + } + final json = cloneMap(raw); + json['verified'] = verified; + await _userDeviceKeysBox + .record(SembastKey(userId, deviceId).toString()) + .put( + txn, + json, + ); + return; + } + + @override + Future storeAccountData(String type, String content) async { + await _accountDataBox.record(type).put(txn, cloneMap(jsonDecode(content))); + return; + } + + @override + Future storeEventUpdate(EventUpdate eventUpdate, Client client) async { + // Ephemerals should not be stored + if (eventUpdate.type == EventUpdateType.ephemeral) return; + final tmpRoom = Room(id: eventUpdate.roomID, client: client); + + // In case of this is a redaction event + if (eventUpdate.content['type'] == EventTypes.Redaction) { + final event = await getEventById(eventUpdate.content['redacts'], tmpRoom); + if (event != null) { + event.setRedactionEvent(Event.fromJson(eventUpdate.content, tmpRoom)); + await _eventsBox + .record(SembastKey(eventUpdate.roomID, event.eventId).toString()) + .put(txn, 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 + .record(SembastKey(eventUpdate.roomID, eventId).toString()) + .get(txn); + final prevStatus = prevEvent == null + ? null + : () { + final json = cloneMap(prevEvent); + final statusInt = json.tryGet('status') ?? + json + .tryGetMap('unsigned') + ?.tryGet(messageSendingStatusKey); + return statusInt == null ? null : eventStatusFromInt(statusInt); + }(); + + // 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 && 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'] ??= {}; + 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 + .record(SembastKey(eventUpdate.roomID, eventId).toString()) + .put(txn, eventUpdate.content); + + // Update timeline fragments + final key = SembastKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING') + .toString(); + + final eventIds = List.from( + await _timelineFragmentsBox.record(key).get(txn) ?? []); + + if (!eventIds.contains(eventId)) { + if (eventUpdate.type == EventUpdateType.history) { + eventIds.add(eventId); + } else { + eventIds.insert(0, eventId); + } + await _timelineFragmentsBox.record(key).put(txn, 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 = SembastKey(eventUpdate.roomID, 'SENDING').toString(); + final eventIds = List.from( + await _timelineFragmentsBox.record(key).get(txn) ?? []); + final i = eventIds.indexWhere((id) => id == eventId); + if (i != -1) { + await _timelineFragmentsBox + .record(key) + .put(txn, 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, + EventUpdateType.inviteState + }.contains(eventUpdate.type)) { + if (eventUpdate.content['type'] == EventTypes.RoomMember) { + await _roomMembersBox + .record(SembastKey( + eventUpdate.roomID, + eventUpdate.content['state_key'], + ).toString()) + .put(txn, eventUpdate.content); + } else { + final key = SembastKey( + eventUpdate.roomID, + eventUpdate.content['type'], + ).toString(); + final stateMap = + cloneMap(await _roomStateBox.record(key).get(txn) ?? {}); + // 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.record(key).put(txn, stateMap); + } else { + final editedEventRelationshipEventId = eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') + ?.tryGet('event_id'); + final state = stateMap[''] == null + ? null + : Event.fromJson(stateMap[''] as Map, tmpRoom); + if (eventUpdate.content['type'] != EventTypes.Message || + eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') + ?.tryGet('rel_type') != + RelationshipTypes.edit || + editedEventRelationshipEventId == state?.eventId || + ((state?.relationshipType == RelationshipTypes.edit && + editedEventRelationshipEventId == + state?.relationshipEventId))) { + stateMap[eventUpdate.content['state_key'] ?? ''] = + eventUpdate.content; + await _roomStateBox.record(key).put(txn, stateMap); + } + } + } + } + + // Store a room account data event + if (eventUpdate.type == EventUpdateType.accountData) { + await _roomAccountDataBox + .record(SembastKey( + eventUpdate.roomID, + eventUpdate.content['type'], + ).toString()) + .put( + txn, + 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.record(sessionId).put( + txn, + 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.record(roomId).put(txn, { + 'room_id': roomId, + 'pickle': pickle, + 'device_ids': deviceIds, + 'creation_time': creationTime, + }); + return; + } + + @override + Future storePrevBatch( + String prevBatch, + ) async { + final keys = await _clientBox.findKeys(txn); + if (keys.isEmpty) return; + await _clientBox.record('prev_batch').put(txn, prevBatch); + return; + } + + @override + Future 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 roomsBoxKeys = await _roomsBox.findKeys(txn); + if (!roomsBoxKeys.contains(roomId)) { + await _roomsBox.record(roomId).put( + txn, + 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 currentRawRoom = await _roomsBox.record(roomId).get(txn); + final currentRoom = Room.fromJson(cloneMap(currentRawRoom!), client); + await _roomsBox.record(roomId).put( + txn, + 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 + .record(SembastKey(roomId, '').toString()) + .delete(txn); + } + } + + @override + Future storeSSSSCache( + String type, String keyId, String ciphertext, String content) async { + await _ssssCacheBox.record(type).put( + txn, + SSSSCache( + type: type, + keyId: keyId, + ciphertext: ciphertext, + content: content, + ).toJson()); + } + + @override + Future storeSyncFilterId( + String syncFilterId, + ) async { + await _clientBox.record('sync_filter_id').put(txn, syncFilterId); + } + + @override + Future storeUserCrossSigningKey(String userId, String publicKey, + String content, bool verified, bool blocked) async { + await _userCrossSigningKeysBox + .record(SembastKey(userId, publicKey).toString()) + .put( + txn, + { + '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 + .record(SembastKey(userId, deviceId).toString()) + .put(txn, { + '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.record(userId).put(txn, 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); + var future; + await _database.transaction((txn) async { + _currentTransaction = txn; + try { + future = await action(); + } finally { + _currentTransaction = null; + } + }); + return future; + } 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, + ) => + _database.transaction((txn) async { + await _clientBox.record('homeserver_url').put(txn, homeserverUrl); + await _clientBox.record('token').put(txn, token); + await _clientBox.record('user_id').put(txn, userId); + if (deviceId == null) { + await _clientBox.record('device_id').delete(txn); + } else { + await _clientBox.record('device_id').put(txn, deviceId); + } + if (deviceName == null) { + await _clientBox.record('device_name').delete(txn); + } else { + await _clientBox.record('device_name').put(txn, deviceName); + } + if (prevBatch == null) { + await _clientBox.record('prev_batch').delete(txn); + } else { + await _clientBox.record('prev_batch').put(txn, prevBatch); + } + if (olmAccount == null) { + await _clientBox.record('olm_account').delete(txn); + } else { + await _clientBox.record('olm_account').put(txn, olmAccount); + } + }); + + @override + Future updateClientKeys( + String olmAccount, + ) async { + await _clientBox.record('olm_account').put(txn, olmAccount); + return; + } + + @override + Future updateInboundGroupSessionAllowedAtIndex( + String allowedAtIndex, String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); + if (raw == null) { + Logs().w( + 'Tried to update inbound group session as uploaded which wasnt found in the database!'); + return; + } + final json = cloneMap(raw); + json['allowed_at_index'] = allowedAtIndex; + await _inboundGroupSessionsBox.record(sessionId).put(txn, json); + return; + } + + @override + Future updateInboundGroupSessionIndexes( + String indexes, String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); + 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 = cloneMap(raw); + json['indexes'] = indexes; + await _inboundGroupSessionsBox.record(sessionId).put(txn, json); + return; + } + + @override + Future updateRoomSortOrder( + double oldestSortOrder, double newestSortOrder, String roomId) async { + final raw = await _roomsBox.record(roomId).get(txn); + if (raw == null) return; + final json = cloneMap(raw); + json['oldest_sort_order'] = oldestSortOrder; + json['newest_sort_order'] = newestSortOrder; + await _roomsBox.record(roomId).put(txn, json); + return; + } + + @override + Future> getAllInboundGroupSessions() async { + final keys = await _inboundGroupSessionsBox.findKeys(txn); + final rawSessions = await Future.wait( + keys.map((key) => _inboundGroupSessionsBox.record(key).get(txn))); + return rawSessions + .map((raw) => StoredInboundGroupSession.fromJson(cloneMap(raw!))) + .toList(); + } + + @override + Future addSeenDeviceId( + String userId, + String deviceId, + String publicKeysHash, + ) => + _seenDeviceIdsBox + .record(SembastKey(userId, deviceId).toString()) + .put(txn, publicKeysHash); + + @override + Future addSeenPublicKey( + String publicKey, + String deviceId, + ) => + _seenDeviceKeysBox.record(publicKey).put(txn, deviceId); + + @override + Future deviceIdSeen(userId, deviceId) async { + final raw = await _seenDeviceIdsBox + .record(SembastKey(userId, deviceId).toString()) + .get(txn); + if (raw == null) return null; + return raw; + } + + @override + Future publicKeySeen(String publicKey) async { + final raw = await _seenDeviceKeysBox.record(publicKey).get(txn); + if (raw == null) return null; + return raw; + } +} + +class SembastKey { + final List parts; + + SembastKey(String key1, [String? key2, String? key3]) + : parts = [ + key1, + if (key2 != null) key2, + if (key3 != null) key3, + ]; + + const SembastKey.byParts(this.parts); + + SembastKey.fromString(String multiKeyString) + : parts = multiKeyString.split('|').toList(); + + @override + String toString() => parts.join('|'); + + @override + bool operator ==(other) => parts.toString() == other.toString(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 5c56c5e9..ec3f50e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: slugify: ^2.0.0 html: ^0.15.0 collection: ^1.15.0 + sembast: ^3.1.1 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/test/database_api_test.dart b/test/database_api_test.dart index ca02d873..159e479f 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -27,6 +27,11 @@ import 'fake_database.dart'; void main() { /// All Tests related to the ChatTime + group('Sembast Database Test', () { + testDatabase( + getSembastDatabase(null), + ); + }); group('Hive Database Test', () { testDatabase( getHiveDatabase(null), diff --git a/test/fake_database.dart b/test/fake_database.dart index a5dad91b..3824dbdb 100644 --- a/test/fake_database.dart +++ b/test/fake_database.dart @@ -23,11 +23,18 @@ import 'package:matrix/matrix.dart'; import 'package:matrix/src/database/hive_database.dart'; import 'package:file/memory.dart'; import 'package:hive/hive.dart'; +import 'package:matrix/src/database/sembast_database.dart'; Future getDatabase(Client? _) => getHiveDatabase(_); bool hiveInitialized = false; +Future getSembastDatabase(Client? c) async { + final db = MatrixSembastDatabase('unit_test.${c?.hashCode}'); + await db.open(); + return db; +} + Future getHiveDatabase(Client? c) async { if (!hiveInitialized) { final fileSystem = MemoryFileSystem(); From 1e3068249f0e70a491840f856acd207552cccaf5 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 16 Nov 2021 10:57:52 +0100 Subject: [PATCH 08/26] chore: Bump version --- CHANGELOG.md | 8 ++++++++ pubspec.yaml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a85c05a9..b746a905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [0.7.0-nullsafety.6] - 16nd Nov 2021 +- feat: Implement sembast store +- fix: HtmlToText crashes with an empty code block +- fix: use originServerTs to check if state event is old +- fix: Dont enable e2ee in new direct chats without encryption support +- fix: Change eventstatus of edits in prevEvent +- chore: Trim formatted username fallback + ## [0.7.0-nullsafety.5] - 10nd Nov 2021 - fix: Edits as lastEvent do not update - fix: JSON parsing in decryptRoomEvent method diff --git a/pubspec.yaml b/pubspec.yaml index ec3f50e5..fbdd27bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.5 +version: 0.7.0-nullsafety.6 homepage: https://famedly.com environment: From b99a78476a8738301fe3d88cb79c0950b524f9d0 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 18 Nov 2021 19:55:21 +0100 Subject: [PATCH 09/26] refactor: Remove Sembast database implementation It was a nice experiment but it loading everything into memory is just too slow for big accounts. --- lib/matrix.dart | 1 - lib/src/database/sembast_database.dart | 1528 ------------------------ pubspec.yaml | 1 - test/database_api_test.dart | 6 - test/fake_database.dart | 7 - 5 files changed, 1543 deletions(-) delete mode 100644 lib/src/database/sembast_database.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index 374d9319..bbd34d5a 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -24,7 +24,6 @@ export 'package:matrix_api_lite/matrix_api_lite.dart'; export 'src/client.dart'; export 'src/database/database_api.dart'; export 'src/database/hive_database.dart'; -export 'src/database/sembast_database.dart'; export 'src/event.dart'; export 'src/event_status.dart'; export 'src/room.dart'; diff --git a/lib/src/database/sembast_database.dart b/lib/src/database/sembast_database.dart deleted file mode 100644 index c11b2c4a..00000000 --- a/lib/src/database/sembast_database.dart +++ /dev/null @@ -1,1528 +0,0 @@ -/* - * 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:sembast/sembast.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' hide Filter; -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:sembast/sembast_memory.dart'; -import 'package:sembast/utils/value_utils.dart'; - -/// Sembast implementation of the DatabaseAPI. You need to pass through the -/// correct dbfactory. By default it uses an in-memory database so there is no -/// persistent storage. Learn more on: https://pub.dev/packages/sembast -class MatrixSembastDatabase extends DatabaseApi { - static const int version = 5; - final String name; - final String path; - late final Database _database; - Transaction? _currentTransaction; - - /// The transaction to use here. If there is a real transaction ongoing it - /// will use it and otherwise just use the default which is the database - /// object itself. - DatabaseClient get txn => (_transactionLock?.isCompleted ?? true) - ? _database - : _currentTransaction ?? _database; - - final DatabaseFactory _dbFactory; - - late final StoreRef _clientBox = StoreRef(_clientBoxName); - late final StoreRef> _accountDataBox = - StoreRef(_accountDataBoxName); - late final StoreRef> _roomsBox = - StoreRef(_roomsBoxName); - late final StoreRef> _toDeviceQueueBox = - StoreRef(_toDeviceQueueBoxName); - - /// Key is a tuple as SembastKey(roomId, type) where stateKey can be - /// an empty string. - late final StoreRef> _roomStateBox = - StoreRef(_roomStateBoxName); - - /// Key is a tuple as SembastKey(roomId, userId) - late final StoreRef> _roomMembersBox = - StoreRef(_roomMembersBoxName); - - /// Key is a tuple as SembastKey(roomId, type) - late final StoreRef> _roomAccountDataBox = - StoreRef(_roomAccountDataBoxName); - late final StoreRef> _inboundGroupSessionsBox = - StoreRef(_inboundGroupSessionsBoxName); - late final StoreRef> _outboundGroupSessionsBox = - StoreRef(_outboundGroupSessionsBoxName); - late final StoreRef> _olmSessionsBox = - StoreRef(_olmSessionsBoxName); - - /// Key is a tuple as SembastKey(userId, deviceId) - late final StoreRef> _userDeviceKeysBox = - StoreRef(_userDeviceKeysBoxName); - - /// Key is the user ID as a String - late final StoreRef _userDeviceKeysOutdatedBox = - StoreRef(_userDeviceKeysOutdatedBoxName); - - /// Key is a tuple as SembastKey(userId, publicKey) - late final StoreRef> _userCrossSigningKeysBox = - StoreRef(_userCrossSigningKeysBoxName); - late final StoreRef> _ssssCacheBox = - StoreRef(_ssssCacheBoxName); - late final StoreRef> _presencesBox = - StoreRef(_presencesBoxName); - - /// Key is a tuple as Multikey(roomId, fragmentId) while the default - /// fragmentId is an empty String - late final StoreRef> _timelineFragmentsBox = - StoreRef(_timelineFragmentsBoxName); - - /// Key is a tuple as SembastKey(roomId, eventId) - late final StoreRef> _eventsBox = - StoreRef(_eventsBoxName); - - /// Key is a tuple as SembastKey(userId, deviceId) - late final StoreRef _seenDeviceIdsBox = - StoreRef(_seenDeviceIdsBoxName); - - late final StoreRef _seenDeviceKeysBox = - StoreRef(_seenDeviceKeysBoxName); - - 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 SembastCodec? codec; - - MatrixSembastDatabase( - this.name, { - this.path = './database.db', - this.codec, - DatabaseFactory? dbFactory, - }) : _dbFactory = dbFactory ?? databaseFactoryMemory; - - @override - int get maxFileSize => 0; - - Future _actionOnAllBoxes(Future Function(StoreRef 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 { - _database = await _dbFactory.openDatabase(path, codec: codec); - - // Check version and check if we need a migration - final currentVersion = - (await _clientBox.record('version').get(txn) as int?); - if (currentVersion == null) { - await _clientBox.record('version').put(txn, version); - } else if (currentVersion != version) { - await _migrateFromVersion(currentVersion); - } - - return; - } - - Future _migrateFromVersion(int currentVersion) async { - Logs() - .i('Migrate Sembast database from version $currentVersion to $version'); - if (version == 5) { - await _database.transaction((txn) async { - final keys = await _userDeviceKeysBox.findKeys(txn); - for (final key in keys) { - try { - final raw = await _userDeviceKeysBox.record(key).get(txn) as Map; - if (!raw.containsKey('keys')) continue; - final deviceKeys = DeviceKeys.fromJson( - cloneMap(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.record('version').put(txn, version); - } - - @override - Future clear() async { - Logs().i('Clear and close Sembast database...'); - await _actionOnAllBoxes((box) => box.delete(txn)); - return; - } - - @override - Future clearCache() async { - await _roomsBox.delete(txn); - await _accountDataBox.delete(txn); - await _roomStateBox.delete(txn); - await _roomMembersBox.delete(txn); - await _eventsBox.delete(txn); - await _timelineFragmentsBox.delete(txn); - await _outboundGroupSessionsBox.delete(txn); - await _presencesBox.delete(txn); - await _clientBox.record('prev_batch').delete(txn); - } - - @override - Future clearSSSSCache() async { - await _ssssCacheBox.delete(txn); - } - - @override - Future close() async { - // We never close a sembast database - // https://github.com/tekartik/sembast.dart/issues/219 - } - - @override - Future deleteFromToDeviceQueue(int id) async { - await _toDeviceQueueBox.record(id).delete(txn); - return; - } - - @override - Future deleteOldFiles(int savedAt) async { - return; - } - - @override - Future forgetRoom(String roomId) async { - await _timelineFragmentsBox - .record(SembastKey(roomId, '').toString()) - .delete(txn); - final eventKeys = await _eventsBox.findKeys(txn); - for (final key in eventKeys) { - final multiKey = SembastKey.fromString(key); - if (multiKey.parts.first != roomId) continue; - await _eventsBox.record(key).delete(txn); - } - final roomStateKeys = await _roomStateBox.findKeys(txn); - for (final key in roomStateKeys) { - final multiKey = SembastKey.fromString(key); - if (multiKey.parts.first != roomId) continue; - await _roomStateBox.record(key).delete(txn); - } - final roomMembersKeys = await _roomMembersBox.findKeys(txn); - for (final key in roomMembersKeys) { - final multiKey = SembastKey.fromString(key); - if (multiKey.parts.first != roomId) continue; - await _roomMembersBox.record(key).delete(txn); - } - final roomAccountData = await _roomAccountDataBox.findKeys(txn); - for (final key in roomAccountData) { - final multiKey = SembastKey.fromString(key); - if (multiKey.parts.first != roomId) continue; - await _roomAccountDataBox.record(key).delete(txn); - } - await _roomsBox.record(roomId).delete(txn); - } - - @override - Future> getAccountData() async { - // We can probably remove this benchmark once we know that findKeys is - // nearly instant anyway. - final keys = await runBenchmarked( - 'Get account data keys from Sembast', - () => _accountDataBox.findKeys(txn), - ); - return runBenchmarked>( - 'Get all account data from Sembast', () async { - final accountData = {}; - await _database.transaction((txn) async { - for (final key in keys) { - final raw = await _accountDataBox.record(key).get(txn); - if (raw == null) continue; - accountData[key.toString()] = BasicEvent( - type: key.toString(), - content: cloneMap(raw), - ); - } - }); - return accountData; - }, keys.length); - } - - @override - Future?> getClient(String name) => - runBenchmarked('Get Client from Sembast', () async { - final map = {}; - final keys = await _clientBox.findKeys(txn); - for (final key in keys) { - if (key == 'version') continue; - map[key] = await _clientBox.record(key).get(txn); - } - if (map.isEmpty) return null; - return map; - }); - - @override - Future getEventById(String eventId, Room room) async { - final raw = await _eventsBox - .record(SembastKey(room.id, eventId).toString()) - .get(txn); - if (raw == null) return null; - return Event.fromJson(cloneMap(raw), room); - } - - /// 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( - cloneMap( - (await _eventsBox - .record(SembastKey(room.id, eventId).toString()) - .get(txn))!, - ), - room, - ), - ) - .toList()); - - @override - Future> getEventList( - Room room, { - int start = 0, - int? limit, - }) => - runBenchmarked>('Get event list', () async { - // Get the synced event IDs from the store - final timelineKey = SembastKey(room.id, '').toString(); - final timelineEventIds = - (await _timelineFragmentsBox.record(timelineKey).get(txn) ?? []); - - // Get the local stored SENDING events from the store - late final List sendingEventIds; - if (start != 0) { - sendingEventIds = []; - } else { - final sendingTimelineKey = SembastKey(room.id, 'SENDING').toString(); - sendingEventIds = (await _timelineFragmentsBox - .record(sendingTimelineKey) - .get(txn) ?? - []); - } - - // 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.record(sessionId).get(txn); - if (raw == null) return null; - return StoredInboundGroupSession.fromJson(cloneMap(raw)); - } - - @override - Future> - getInboundGroupSessionsToUpload() async { - final sessions = await _inboundGroupSessionsBox.find( - txn, - finder: Finder( - limit: 50, - filter: Filter.equals('uploaded', false), - ), - ); - return sessions - .map((json) => StoredInboundGroupSession.fromJson(cloneMap(json.value))) - .toList(); - } - - @override - Future> getLastSentMessageUserDeviceKey( - String userId, String deviceId) async { - final raw = await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) return []; - return [raw['last_sent_message'] as String]; - } - - @override - Future storeOlmSession(String identityKey, String sessionId, - String pickle, int lastReceived) async { - final rawSessions = - cloneMap(await _olmSessionsBox.record(identityKey).get(txn) ?? {}); - rawSessions[sessionId] = { - 'identity_key': identityKey, - 'pickle': pickle, - 'session_id': sessionId, - 'last_received': lastReceived, - }; - await _olmSessionsBox.record(identityKey).put(txn, rawSessions); - return; - } - - @override - Future> getOlmSessions( - String identityKey, String userId) async { - final rawSessions = await _olmSessionsBox.record(identityKey).get(txn); - if (rawSessions == null || rawSessions.isEmpty) return []; - return rawSessions.values - .map((json) => OlmSession.fromJson(cloneMap(json as Map), 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.record(roomId).get(txn); - if (raw == null) return null; - return OutboundGroupSession.fromJson(cloneMap(raw), userId); - } - - @override - Future> getRoomList(Client client) async { - // We can probably remove this benchmark once we know that findKeys is - // nearly instant anyway. - final keys = await runBenchmarked( - 'Get rooms box keys', - () => _roomsBox.findKeys(txn), - ); - return runBenchmarked>('Get room list from Sembast', () async { - final rooms = {}; - await _database.transaction((txn) async { - final userID = client.userID; - final importantRoomStates = client.importantStateEvents; - for (final key in keys) { - // Get the room - final raw = await _roomsBox.record(key).get(txn); - if (raw == null) continue; - final room = Room.fromJson(cloneMap(raw), client); - - // let's see if we need any m.room.member events - // We always need the member event for ourself - final membersToPostload = {if (userID != null) 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 - final lastEvent = room.getState(EventTypes.Message); - if (lastEvent != null && !room.isDirectChat) { - membersToPostload.add(lastEvent.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 - .record(SembastKey(room.id, userId).toString()) - .get(txn); - if (state == null) { - Logs().w('Unable to post load member $userId'); - continue; - } - room.setState(Event.fromJson(cloneMap(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 - .record(SembastKey(room.id, type).toString()) - .get(txn); - if (states == null) continue; - final stateEvents = states.values - .map((raw) => Event.fromJson(cloneMap(raw as Map), 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 - final accountDataKeys = await _roomAccountDataBox.findKeys(txn); - for (final key in accountDataKeys) { - final roomId = SembastKey.fromString(key).parts.first; - if (rooms.containsKey(roomId)) { - final raw = await _roomAccountDataBox.record(key).get(txn); - if (raw == null) continue; - final basicRoomEvent = BasicRoomEvent.fromJson( - cloneMap(raw), - ); - rooms[roomId]!.roomAccountData[basicRoomEvent.type] = - basicRoomEvent; - } else { - Logs().w( - 'Found account data for unknown room $roomId. Delete now...'); - await _roomAccountDataBox.record(key).delete(txn); - } - } - }); - return rooms.values.toList(); - }, keys.length); - } - - @override - Future getSSSSCache(String type) async { - final raw = await _ssssCacheBox.record(type).get(txn); - if (raw == null) return null; - return SSSSCache.fromJson(cloneMap(raw)); - } - - @override - Future> getToDeviceEventQueue() async { - final keys = await _toDeviceQueueBox.findKeys(txn); - return await Future.wait(keys.map((i) async { - final raw = await _toDeviceQueueBox.record(i).get(txn); - final json = cloneMap(raw!); - json['id'] = i; - return QueuedToDeviceEvent.fromJson(json); - }).toList()); - } - - @override - Future> getUnimportantRoomEventStatesForRoom( - List events, Room room) async { - final keys = (await _roomStateBox.findKeys(txn)).where((key) { - final tuple = SembastKey.fromString(key); - return tuple.parts.first == room.id && !events.contains(tuple.parts[1]); - }); - - final unimportantEvents = []; - for (final key in keys) { - final states = await _roomStateBox.record(key).get(txn); - if (states == null) continue; - unimportantEvents.addAll(states.values - .map((raw) => Event.fromJson(cloneMap(raw as Map), room))); - } - return unimportantEvents; - } - - @override - Future getUser(String userId, Room room) async { - final state = await _roomMembersBox - .record(SembastKey(room.id, userId).toString()) - .get(txn); - if (state == null) return null; - return Event.fromJson(cloneMap(state), room).asUser; - } - - @override - Future> getUserDeviceKeys(Client client) async { - // We can probably remove this benchmark once we know that findKeys is - // nearly instant anyway. - final keys = await runBenchmarked( - 'Get user device keys box keys', - () => _userDeviceKeysBox.findKeys(txn), - ); - return runBenchmarked>( - 'Get all user device keys from Sembast', () async { - final deviceKeysOutdated = await _userDeviceKeysOutdatedBox.findKeys(txn); - if (deviceKeysOutdated.isEmpty) { - return {}; - } - final res = {}; - await _database.transaction((txn) async { - for (final userId in deviceKeysOutdated) { - final deviceKeysBoxKeys = keys.where((tuple) { - final tupleKey = SembastKey.fromString(tuple); - return tupleKey.parts.first == userId; - }); - final crossSigningKeysBoxKeys = - (await _userCrossSigningKeysBox.findKeys(txn)).where((tuple) { - final tupleKey = SembastKey.fromString(tuple); - return tupleKey.parts.first == userId; - }); - res[userId] = DeviceKeysList.fromDbJson( - { - 'client_id': client.id, - 'user_id': userId, - 'outdated': - await _userDeviceKeysOutdatedBox.record(userId).get(txn), - }, - await Future.wait(deviceKeysBoxKeys.map((key) async => - cloneMap((await _userDeviceKeysBox.record(key).get(txn))!))), - await Future.wait(crossSigningKeysBoxKeys.map((key) async => - cloneMap( - (await _userCrossSigningKeysBox.record(key).get(txn))!))), - client); - } - }); - return res; - }, keys.length); - } - - @override - Future> getUsers(Room room) async { - final users = []; - await _database.transaction((txn) async { - final keys = await _roomMembersBox.findKeys(txn); - for (final key in keys) { - final statesKey = SembastKey.fromString(key); - if (statesKey.parts[0] != room.id) continue; - final state = await _roomMembersBox.record(key).get(txn); - if (state == null) continue; - users.add(Event.fromJson(cloneMap(state), room).asUser); - } - }); - return users; - } - - @override - Future insertClient( - String name, - String homeserverUrl, - String token, - String userId, - String? deviceId, - String? deviceName, - String? prevBatch, - String? olmAccount) => - _database.transaction((txn) async { - await _clientBox.record('homeserver_url').put(txn, homeserverUrl); - await _clientBox.record('token').put(txn, token); - await _clientBox.record('user_id').put(txn, userId); - if (deviceId == null) { - await _clientBox.record('device_id').delete(txn); - } else { - await _clientBox.record('device_id').put(txn, deviceId); - } - if (deviceName == null) { - await _clientBox.record('device_name').delete(txn); - } else { - await _clientBox.record('device_name').put(txn, deviceName); - } - if (prevBatch == null) { - await _clientBox.record('prev_batch').delete(txn); - } else { - await _clientBox.record('prev_batch').put(txn, prevBatch); - } - if (olmAccount == null) { - await _clientBox.record('olm_account').delete(txn); - } else { - await _clientBox.record('olm_account').put(txn, olmAccount); - } - await _clientBox.record('sync_filter_id').delete(txn); - }); - - @override - Future insertIntoToDeviceQueue( - String type, String txnId, String content) async { - return await _toDeviceQueueBox.add(txn, { - 'type': type, - 'txn_id': txnId, - 'content': content, - }); - } - - @override - Future markInboundGroupSessionAsUploaded( - String roomId, String sessionId) async { - final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); - if (raw == null) { - Logs().w( - 'Tried to mark inbound group session as uploaded which was not found in the database!'); - return; - } - final json = cloneMap(raw); - json['uploaded'] = true; - await _inboundGroupSessionsBox.record(sessionId).put(txn, json); - return; - } - - @override - Future markInboundGroupSessionsAsNeedingUpload() async { - await _database.transaction((txn) async { - final keys = await _inboundGroupSessionsBox.findKeys(txn); - for (final sessionId in keys) { - final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); - if (raw == null) continue; - final json = cloneMap(raw); - json['uploaded'] = false; - await _inboundGroupSessionsBox.record(sessionId).put(txn, json); - } - }); - return; - } - - @override - Future removeEvent(String eventId, String roomId) async { - await _eventsBox.record(SembastKey(roomId, eventId).toString()).delete(txn); - final keys = await _timelineFragmentsBox.findKeys(txn); - for (final key in keys) { - final multiKey = SembastKey.fromString(key); - if (multiKey.parts.first != roomId) continue; - final eventIds = List.from( - await _timelineFragmentsBox.record(key).get(txn) ?? []); - final prevLength = eventIds.length; - eventIds.removeWhere((id) => id == eventId); - if (eventIds.length < prevLength) { - await _timelineFragmentsBox.record(key).put(txn, eventIds); - } - } - return; - } - - @override - Future removeOutboundGroupSession(String roomId) async { - await _outboundGroupSessionsBox.record(roomId).delete(txn); - return; - } - - @override - Future removeUserCrossSigningKey( - String userId, String publicKey) async { - await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .delete(txn); - return; - } - - @override - Future removeUserDeviceKey(String userId, String deviceId) async { - await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .delete(txn); - return; - } - - @override - Future resetNotificationCount(String roomId) async { - final raw = await _roomsBox.record(roomId).get(txn); - if (raw == null) return; - final json = cloneMap(raw); - json['notification_count'] = json['highlight_count'] = 0; - await _roomsBox.record(roomId).put(txn, json); - return; - } - - @override - Future setBlockedUserCrossSigningKey( - bool blocked, String userId, String publicKey) async { - final raw = await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .get(txn); - if (raw == null) { - Logs().w('User cross signing key $publicKey of $userId not found'); - return; - } - final json = cloneMap(raw); - json['blocked'] = blocked; - await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .put( - txn, - json, - ); - return; - } - - @override - Future setBlockedUserDeviceKey( - bool blocked, String userId, String deviceId) async { - final raw = await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) { - Logs().w('Device key $deviceId of $userId not found'); - return; - } - final json = cloneMap(raw); - json['blocked'] = blocked; - await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .put( - txn, - json, - ); - return; - } - - @override - Future setLastActiveUserDeviceKey( - int lastActive, String userId, String deviceId) async { - final raw = await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) { - Logs().w('Device key $deviceId of $userId not found'); - return; - } - final json = cloneMap(raw); - json['last_active'] = lastActive; - await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .put( - txn, - json, - ); - } - - @override - Future setLastSentMessageUserDeviceKey( - String lastSentMessage, String userId, String deviceId) async { - final raw = await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) { - Logs().w('Device key $deviceId of $userId not found'); - return; - } - final json = cloneMap(raw); - json['last_sent_message'] = lastSentMessage; - await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .put( - txn, - json, - ); - } - - @override - Future setRoomPrevBatch( - String prevBatch, String roomId, Client client) async { - final raw = await _roomsBox.record(roomId).get(txn); - if (raw == null) return; - final room = Room.fromJson(cloneMap(raw), client); - room.prev_batch = prevBatch; - await _roomsBox.record(roomId).put(txn, room.toJson()); - return; - } - - @override - Future setVerifiedUserCrossSigningKey( - bool verified, String userId, String publicKey) async { - final raw = await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .get(txn) ?? - {}; - final json = cloneMap(raw); - json['verified'] = verified; - await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .put( - txn, - json, - ); - return; - } - - @override - Future setVerifiedUserDeviceKey( - bool verified, String userId, String deviceId) async { - final raw = await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) { - Logs().w('Device key $deviceId of $userId not found'); - return; - } - final json = cloneMap(raw); - json['verified'] = verified; - await _userDeviceKeysBox - .record(SembastKey(userId, deviceId).toString()) - .put( - txn, - json, - ); - return; - } - - @override - Future storeAccountData(String type, String content) async { - await _accountDataBox.record(type).put(txn, cloneMap(jsonDecode(content))); - return; - } - - @override - Future storeEventUpdate(EventUpdate eventUpdate, Client client) async { - // Ephemerals should not be stored - if (eventUpdate.type == EventUpdateType.ephemeral) return; - final tmpRoom = Room(id: eventUpdate.roomID, client: client); - - // In case of this is a redaction event - if (eventUpdate.content['type'] == EventTypes.Redaction) { - final event = await getEventById(eventUpdate.content['redacts'], tmpRoom); - if (event != null) { - event.setRedactionEvent(Event.fromJson(eventUpdate.content, tmpRoom)); - await _eventsBox - .record(SembastKey(eventUpdate.roomID, event.eventId).toString()) - .put(txn, 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 - .record(SembastKey(eventUpdate.roomID, eventId).toString()) - .get(txn); - final prevStatus = prevEvent == null - ? null - : () { - final json = cloneMap(prevEvent); - final statusInt = json.tryGet('status') ?? - json - .tryGetMap('unsigned') - ?.tryGet(messageSendingStatusKey); - return statusInt == null ? null : eventStatusFromInt(statusInt); - }(); - - // 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 && 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'] ??= {}; - 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 - .record(SembastKey(eventUpdate.roomID, eventId).toString()) - .put(txn, eventUpdate.content); - - // Update timeline fragments - final key = SembastKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING') - .toString(); - - final eventIds = List.from( - await _timelineFragmentsBox.record(key).get(txn) ?? []); - - if (!eventIds.contains(eventId)) { - if (eventUpdate.type == EventUpdateType.history) { - eventIds.add(eventId); - } else { - eventIds.insert(0, eventId); - } - await _timelineFragmentsBox.record(key).put(txn, 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 = SembastKey(eventUpdate.roomID, 'SENDING').toString(); - final eventIds = List.from( - await _timelineFragmentsBox.record(key).get(txn) ?? []); - final i = eventIds.indexWhere((id) => id == eventId); - if (i != -1) { - await _timelineFragmentsBox - .record(key) - .put(txn, 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, - EventUpdateType.inviteState - }.contains(eventUpdate.type)) { - if (eventUpdate.content['type'] == EventTypes.RoomMember) { - await _roomMembersBox - .record(SembastKey( - eventUpdate.roomID, - eventUpdate.content['state_key'], - ).toString()) - .put(txn, eventUpdate.content); - } else { - final key = SembastKey( - eventUpdate.roomID, - eventUpdate.content['type'], - ).toString(); - final stateMap = - cloneMap(await _roomStateBox.record(key).get(txn) ?? {}); - // 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.record(key).put(txn, stateMap); - } else { - final editedEventRelationshipEventId = eventUpdate.content - .tryGetMap('content') - ?.tryGetMap('m.relates_to') - ?.tryGet('event_id'); - final state = stateMap[''] == null - ? null - : Event.fromJson(stateMap[''] as Map, tmpRoom); - if (eventUpdate.content['type'] != EventTypes.Message || - eventUpdate.content - .tryGetMap('content') - ?.tryGetMap('m.relates_to') - ?.tryGet('rel_type') != - RelationshipTypes.edit || - editedEventRelationshipEventId == state?.eventId || - ((state?.relationshipType == RelationshipTypes.edit && - editedEventRelationshipEventId == - state?.relationshipEventId))) { - stateMap[eventUpdate.content['state_key'] ?? ''] = - eventUpdate.content; - await _roomStateBox.record(key).put(txn, stateMap); - } - } - } - } - - // Store a room account data event - if (eventUpdate.type == EventUpdateType.accountData) { - await _roomAccountDataBox - .record(SembastKey( - eventUpdate.roomID, - eventUpdate.content['type'], - ).toString()) - .put( - txn, - 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.record(sessionId).put( - txn, - 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.record(roomId).put(txn, { - 'room_id': roomId, - 'pickle': pickle, - 'device_ids': deviceIds, - 'creation_time': creationTime, - }); - return; - } - - @override - Future storePrevBatch( - String prevBatch, - ) async { - final keys = await _clientBox.findKeys(txn); - if (keys.isEmpty) return; - await _clientBox.record('prev_batch').put(txn, prevBatch); - return; - } - - @override - Future 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 roomsBoxKeys = await _roomsBox.findKeys(txn); - if (!roomsBoxKeys.contains(roomId)) { - await _roomsBox.record(roomId).put( - txn, - 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 currentRawRoom = await _roomsBox.record(roomId).get(txn); - final currentRoom = Room.fromJson(cloneMap(currentRawRoom!), client); - await _roomsBox.record(roomId).put( - txn, - 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 - .record(SembastKey(roomId, '').toString()) - .delete(txn); - } - } - - @override - Future storeSSSSCache( - String type, String keyId, String ciphertext, String content) async { - await _ssssCacheBox.record(type).put( - txn, - SSSSCache( - type: type, - keyId: keyId, - ciphertext: ciphertext, - content: content, - ).toJson()); - } - - @override - Future storeSyncFilterId( - String syncFilterId, - ) async { - await _clientBox.record('sync_filter_id').put(txn, syncFilterId); - } - - @override - Future storeUserCrossSigningKey(String userId, String publicKey, - String content, bool verified, bool blocked) async { - await _userCrossSigningKeysBox - .record(SembastKey(userId, publicKey).toString()) - .put( - txn, - { - '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 - .record(SembastKey(userId, deviceId).toString()) - .put(txn, { - '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.record(userId).put(txn, 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); - var future; - await _database.transaction((txn) async { - _currentTransaction = txn; - try { - future = await action(); - } finally { - _currentTransaction = null; - } - }); - return future; - } 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, - ) => - _database.transaction((txn) async { - await _clientBox.record('homeserver_url').put(txn, homeserverUrl); - await _clientBox.record('token').put(txn, token); - await _clientBox.record('user_id').put(txn, userId); - if (deviceId == null) { - await _clientBox.record('device_id').delete(txn); - } else { - await _clientBox.record('device_id').put(txn, deviceId); - } - if (deviceName == null) { - await _clientBox.record('device_name').delete(txn); - } else { - await _clientBox.record('device_name').put(txn, deviceName); - } - if (prevBatch == null) { - await _clientBox.record('prev_batch').delete(txn); - } else { - await _clientBox.record('prev_batch').put(txn, prevBatch); - } - if (olmAccount == null) { - await _clientBox.record('olm_account').delete(txn); - } else { - await _clientBox.record('olm_account').put(txn, olmAccount); - } - }); - - @override - Future updateClientKeys( - String olmAccount, - ) async { - await _clientBox.record('olm_account').put(txn, olmAccount); - return; - } - - @override - Future updateInboundGroupSessionAllowedAtIndex( - String allowedAtIndex, String roomId, String sessionId) async { - final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); - if (raw == null) { - Logs().w( - 'Tried to update inbound group session as uploaded which wasnt found in the database!'); - return; - } - final json = cloneMap(raw); - json['allowed_at_index'] = allowedAtIndex; - await _inboundGroupSessionsBox.record(sessionId).put(txn, json); - return; - } - - @override - Future updateInboundGroupSessionIndexes( - String indexes, String roomId, String sessionId) async { - final raw = await _inboundGroupSessionsBox.record(sessionId).get(txn); - 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 = cloneMap(raw); - json['indexes'] = indexes; - await _inboundGroupSessionsBox.record(sessionId).put(txn, json); - return; - } - - @override - Future updateRoomSortOrder( - double oldestSortOrder, double newestSortOrder, String roomId) async { - final raw = await _roomsBox.record(roomId).get(txn); - if (raw == null) return; - final json = cloneMap(raw); - json['oldest_sort_order'] = oldestSortOrder; - json['newest_sort_order'] = newestSortOrder; - await _roomsBox.record(roomId).put(txn, json); - return; - } - - @override - Future> getAllInboundGroupSessions() async { - final keys = await _inboundGroupSessionsBox.findKeys(txn); - final rawSessions = await Future.wait( - keys.map((key) => _inboundGroupSessionsBox.record(key).get(txn))); - return rawSessions - .map((raw) => StoredInboundGroupSession.fromJson(cloneMap(raw!))) - .toList(); - } - - @override - Future addSeenDeviceId( - String userId, - String deviceId, - String publicKeysHash, - ) => - _seenDeviceIdsBox - .record(SembastKey(userId, deviceId).toString()) - .put(txn, publicKeysHash); - - @override - Future addSeenPublicKey( - String publicKey, - String deviceId, - ) => - _seenDeviceKeysBox.record(publicKey).put(txn, deviceId); - - @override - Future deviceIdSeen(userId, deviceId) async { - final raw = await _seenDeviceIdsBox - .record(SembastKey(userId, deviceId).toString()) - .get(txn); - if (raw == null) return null; - return raw; - } - - @override - Future publicKeySeen(String publicKey) async { - final raw = await _seenDeviceKeysBox.record(publicKey).get(txn); - if (raw == null) return null; - return raw; - } -} - -class SembastKey { - final List parts; - - SembastKey(String key1, [String? key2, String? key3]) - : parts = [ - key1, - if (key2 != null) key2, - if (key3 != null) key3, - ]; - - const SembastKey.byParts(this.parts); - - SembastKey.fromString(String multiKeyString) - : parts = multiKeyString.split('|').toList(); - - @override - String toString() => parts.join('|'); - - @override - bool operator ==(other) => parts.toString() == other.toString(); -} diff --git a/pubspec.yaml b/pubspec.yaml index fbdd27bc..bc030715 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,6 @@ dependencies: slugify: ^2.0.0 html: ^0.15.0 collection: ^1.15.0 - sembast: ^3.1.1 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 159e479f..3b96d7e5 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -26,12 +26,6 @@ import 'package:olm/olm.dart' as olm; import 'fake_database.dart'; void main() { - /// All Tests related to the ChatTime - group('Sembast Database Test', () { - testDatabase( - getSembastDatabase(null), - ); - }); group('Hive Database Test', () { testDatabase( getHiveDatabase(null), diff --git a/test/fake_database.dart b/test/fake_database.dart index 3824dbdb..a5dad91b 100644 --- a/test/fake_database.dart +++ b/test/fake_database.dart @@ -23,18 +23,11 @@ import 'package:matrix/matrix.dart'; import 'package:matrix/src/database/hive_database.dart'; import 'package:file/memory.dart'; import 'package:hive/hive.dart'; -import 'package:matrix/src/database/sembast_database.dart'; Future getDatabase(Client? _) => getHiveDatabase(_); bool hiveInitialized = false; -Future getSembastDatabase(Client? c) async { - final db = MatrixSembastDatabase('unit_test.${c?.hashCode}'); - await db.open(); - return db; -} - Future getHiveDatabase(Client? c) async { if (!hiveInitialized) { final fileSystem = MemoryFileSystem(); From a61e1ae4a3c670e835539a3d6849764cfe91b62c Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Fri, 19 Nov 2021 14:18:53 +0100 Subject: [PATCH 10/26] feat: Add commands to create chats --- lib/src/utils/commands_extension.dart | 13 +++++++++++++ test/commands_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index 4167ae9f..731c52a2 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -98,6 +98,19 @@ extension CommandsClientExtension on Client { txid: args.txid, ); }); + addCommand('dm', (CommandArgs args) async { + final parts = args.msg.split(' '); + return await args.room.client.startDirectChat( + parts.first, + enableEncryption: !parts.any((part) => part == '--no-encryption'), + ); + }); + addCommand('create', (CommandArgs args) async { + final parts = args.msg.split(' '); + return await args.room.client.createGroupChat( + enableEncryption: !parts.any((part) => part == '--no-encryption'), + ); + }); addCommand('plain', (CommandArgs args) async { return await args.room.sendTextEvent( args.msg, diff --git a/test/commands_test.dart b/test/commands_test.dart index 1084db88..87e94918 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -274,6 +274,28 @@ void main() { }); }); + test('dm', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/dm @alice:example.com --no-encryption'); + expect( + json.decode( + FakeMatrixApi.calledEndpoints['/client/r0/createRoom']?.first), + { + 'invite': ['@alice:example.com'], + 'is_direct': true, + 'preset': 'trusted_private_chat' + }); + }); + + test('create', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/create @alice:example.com --no-encryption'); + expect( + json.decode( + FakeMatrixApi.calledEndpoints['/client/r0/createRoom']?.first), + {'preset': 'private_chat'}); + }); + test('discardsession', () async { if (olmEnabled) { await client.encryption?.keyManager.createOutboundGroupSession(room.id); From f3775fa5ba5f24a13e116e6638ede2a60392bf10 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Mon, 22 Nov 2021 10:18:44 +0100 Subject: [PATCH 11/26] fix: Decrypt last event of a room --- lib/encryption/key_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index e2229f45..a531196c 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -182,7 +182,7 @@ class KeyManager { // attempt to decrypt the last event final event = room.getState(EventTypes.Encrypted); if (event != null && event.content['session_id'] == sessionId) { - encryption.decryptRoomEvent(roomId, event, store: true); + room.setState(encryption.decryptRoomEventSync(roomId, event)); } // and finally broadcast the new session room.onSessionKeyReceived.add(sessionId); From 27c03b4fad797fa657de5c11e2dff8c0feec2adb Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Mon, 22 Nov 2021 10:37:26 +0100 Subject: [PATCH 12/26] fix: Request history in archived rooms We have just forgotten to set the prevBatch token in the room object. --- lib/src/client.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/client.dart b/lib/src/client.dart index 36b1b494..c9a10128 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -775,6 +775,7 @@ class Client extends MatrixApi { leftRoom, )); }); + leftRoom.prev_batch = room.timeline?.prevBatch; room.state?.forEach((event) { leftRoom.setState(Event.fromMatrixEvent( event, From 3338da4e0999ba0f0067f987bd462756fc21db4a Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Mon, 22 Nov 2021 19:36:38 +0100 Subject: [PATCH 13/26] fix: Ability to remove avatar from room and account To remove an avatar the client needs to send an empty object. This is now possible by making the MatrixFile parameter nullable. --- lib/src/client.dart | 17 ++++++++++------- lib/src/room.dart | 13 ++++++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index c9a10128..75b0e220 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -821,13 +821,16 @@ class Client extends MatrixApi { } } - /// Uploads a new user avatar for this user. - Future setAvatar(MatrixFile file) async { - final uploadResp = await uploadContent( - file.bytes, - filename: file.name, - contentType: file.mimeType, - ); + /// Uploads a new user avatar for this user. Leave file null to remove the + /// current avatar. + Future setAvatar(MatrixFile? file) async { + final uploadResp = file == null + ? null + : await uploadContent( + file.bytes, + filename: file.name, + contentType: file.mimeType, + ); await setAvatarUrl(userID!, uploadResp); return; } diff --git a/lib/src/room.dart b/lib/src/room.dart index b674bf81..20e8abc0 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -1356,15 +1356,18 @@ class Room { } /// Uploads a new user avatar for this room. Returns the event ID of the new - /// m.room.avatar event. - Future setAvatar(MatrixFile file) async { - final uploadResp = - await client.uploadContent(file.bytes, filename: file.name); + /// m.room.avatar event. Leave empty to remove the current avatar. + Future setAvatar(MatrixFile? file) async { + final uploadResp = file == null + ? null + : await client.uploadContent(file.bytes, filename: file.name); return await client.setRoomStateWithKey( id, EventTypes.RoomAvatar, '', - {'url': uploadResp.toString()}, + { + if (uploadResp != null) 'url': uploadResp.toString(), + }, ); } From 9cbe1099e5149e27f95fb57c657b02c096e1e612 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 23 Nov 2021 09:06:30 +0100 Subject: [PATCH 14/26] fix: Limited timeline clears too much events This fixes the bug that the limited timeline flag also clears all events from the current SyncUpdate in an open timeline. --- lib/src/timeline.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index 5969d5e2..1e1e7b5e 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -120,13 +120,12 @@ class Timeline { this.onRemove, }) : 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(); - }); + .listen(_removeEventsNotInThisSync); + sessionIdReceivedSub = room.onSessionKeyReceived.stream.listen(_sessionKeyReceived); @@ -136,6 +135,13 @@ class Timeline { } } + /// Removes all entries from [events] which are not in this SyncUpdate. + void _removeEventsNotInThisSync(SyncUpdate sync) { + final newSyncEvents = sync.rooms?.join?[room.id]?.timeline?.events ?? []; + final keepEventIds = newSyncEvents.map((e) => e.eventId); + events.removeWhere((e) => !keepEventIds.contains(e.eventId)); + } + /// Don't forget to call this before you dismiss this object! void cancelSubscriptions() { sub?.cancel(); From 921c694888de472271984681c0831cef9118e958 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 23 Nov 2021 09:48:08 +0100 Subject: [PATCH 15/26] feat: Add clear cache command --- lib/src/utils/commands_extension.dart | 4 ++++ test/commands_test.dart | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index 731c52a2..038ab4e4 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -215,6 +215,10 @@ extension CommandsClientExtension on Client { .clearOrUseOutboundGroupSession(args.room.id, wipe: true); return ''; }); + addCommand('clearcache', (CommandArgs args) async { + await clearCache(); + return ''; + }); } } diff --git a/test/commands_test.dart b/test/commands_test.dart index 87e94918..30ae47f2 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -311,6 +311,11 @@ void main() { } }); + test('create', () async { + await room.sendTextEvent('/clearcache'); + expect(room.client.prevBatch, null); + }); + test('dispose client', () async { await client.dispose(closeDatabase: true); }); From ee21121a63612d915463dd49578579c941190ff2 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 23 Nov 2021 13:37:46 +0100 Subject: [PATCH 16/26] fix: Workaround for null boolean deviceKeysList.outdated is not nullable but we have seen this error in production: `Failed assertion: boolean expression must not be null` So this could either be a null safety bug in Dart or a result of using unsound null safety. The extra equal check `== true` should safe us here --- lib/src/client.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 75b0e220..0a0aa79b 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1813,7 +1813,13 @@ class Client extends MatrixApi { final deviceKeysList = _userDeviceKeys[userId] ??= DeviceKeysList(userId, this); final failure = _keyQueryFailures[userId.domain]; - if (deviceKeysList.outdated && + + // deviceKeysList.outdated is not nullable but we have seen this error + // in production: `Failed assertion: boolean expression must not be null` + // So this could either be a null safety bug in Dart or a result of + // using unsound null safety. The extra equal check `!= false` should + // save us here. + if (deviceKeysList.outdated != false && (failure == null || DateTime.now() .subtract(Duration(minutes: 5)) From 01eb8513643aa1cf7e77947d333c6e4e735d7f00 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 18 Nov 2021 18:35:47 +0100 Subject: [PATCH 17/26] fix: --- lib/matrix.dart | 1 + lib/src/database/fluffybox_database.dart | 1475 ++++++++++++++++++++++ pubspec.yaml | 1 + test/database_api_test.dart | 5 + test/fake_database.dart | 14 + 5 files changed, 1496 insertions(+) create mode 100644 lib/src/database/fluffybox_database.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index bbd34d5a..ad5d5c20 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -24,6 +24,7 @@ export 'package:matrix_api_lite/matrix_api_lite.dart'; export 'src/client.dart'; export 'src/database/database_api.dart'; export 'src/database/hive_database.dart'; +export 'src/database/fluffybox_database.dart'; export 'src/event.dart'; export 'src/event_status.dart'; export 'src/room.dart'; diff --git a/lib/src/database/fluffybox_database.dart b/lib/src/database/fluffybox_database.dart new file mode 100644 index 00000000..acfa3489 --- /dev/null +++ b/lib/src/database/fluffybox_database.dart @@ -0,0 +1,1475 @@ +/* + * 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:fluffybox/fluffybox.dart'; +import 'package:hive/hive.dart' show HiveCipher; +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'; + +/// This database does not support file caching! +class FluffyBoxDatabase extends DatabaseApi { + static const int version = 6; + final String name; + final String path; + final HiveCipher? key; + late final BoxCollection _collection; + late Box _clientBox; + late Box _accountDataBox; + late Box _roomsBox; + late Box _toDeviceQueueBox; + + /// Key is a tuple as TupleKey(roomId, type) where stateKey can be + /// an empty string. + late Box _roomStateBox; + + /// Key is a tuple as TupleKey(roomId, userId) + late Box _roomMembersBox; + + /// Key is a tuple as TupleKey(roomId, type) + late Box _roomAccountDataBox; + late Box _inboundGroupSessionsBox; + late Box _outboundGroupSessionsBox; + late Box _olmSessionsBox; + + /// Key is a tuple as TupleKey(userId, deviceId) + late Box _userDeviceKeysBox; + + /// Key is the user ID as a String + late Box _userDeviceKeysOutdatedBox; + + /// Key is a tuple as TupleKey(userId, publicKey) + late Box _userCrossSigningKeysBox; + late Box _ssssCacheBox; + late Box _presencesBox; + + /// Key is a tuple as Multikey(roomId, fragmentId) while the default + /// fragmentId is an empty String + late Box _timelineFragmentsBox; + + /// Key is a tuple as TupleKey(roomId, eventId) + late Box _eventsBox; + + /// Key is a tuple as TupleKey(userId, deviceId) + late Box _seenDeviceIdsBox; + + late Box _seenDeviceKeysBox; + + String get _clientBoxName => 'box_client'; + + String get _accountDataBoxName => 'box_account_data'; + + String get _roomsBoxName => 'box_rooms'; + + String get _toDeviceQueueBoxName => 'box_to_device_queue'; + + String get _roomStateBoxName => 'box_room_states'; + + String get _roomMembersBoxName => 'box_room_members'; + + String get _roomAccountDataBoxName => 'box_room_account_data'; + + String get _inboundGroupSessionsBoxName => 'box_inbound_group_session'; + + String get _outboundGroupSessionsBoxName => 'box_outbound_group_session'; + + String get _olmSessionsBoxName => 'box_olm_session'; + + String get _userDeviceKeysBoxName => 'box_user_device_keys'; + + String get _userDeviceKeysOutdatedBoxName => 'box_user_device_keys_outdated'; + + String get _userCrossSigningKeysBoxName => 'box_cross_signing_keys'; + + String get _ssssCacheBoxName => 'box_ssss_cache'; + + String get _presencesBoxName => 'box_presences'; + + String get _timelineFragmentsBoxName => 'box_timeline_fragments'; + + String get _eventsBoxName => 'box_events'; + + String get _seenDeviceIdsBoxName => 'box_seen_device_ids'; + + String get _seenDeviceKeysBoxName => 'box_seen_device_keys'; + + FluffyBoxDatabase(this.name, this.path, {this.key}); + + @override + int get maxFileSize => 0; + + Future open() async { + _collection = await BoxCollection.open( + name, + { + _clientBoxName, + _accountDataBoxName, + _roomsBoxName, + _toDeviceQueueBoxName, + _roomStateBoxName, + _roomMembersBoxName, + _roomAccountDataBoxName, + _inboundGroupSessionsBoxName, + _outboundGroupSessionsBoxName, + _olmSessionsBoxName, + _userDeviceKeysBoxName, + _userDeviceKeysOutdatedBoxName, + _userCrossSigningKeysBoxName, + _ssssCacheBoxName, + _presencesBoxName, + _timelineFragmentsBoxName, + _eventsBoxName, + _seenDeviceIdsBoxName, + _seenDeviceKeysBoxName, + }, + key: key, + path: path, + ); + _clientBox = await _collection.openBox( + _clientBoxName, + preload: true, + ); + _accountDataBox = await _collection.openBox( + _accountDataBoxName, + preload: true, + ); + _roomsBox = await _collection.openBox( + _roomsBoxName, + preload: true, + ); + _roomStateBox = await _collection.openBox( + _roomStateBoxName, + ); + _roomMembersBox = await _collection.openBox( + _roomMembersBoxName, + ); + _toDeviceQueueBox = await _collection.openBox( + _toDeviceQueueBoxName, + preload: true, + ); + _roomAccountDataBox = await _collection.openBox( + _roomAccountDataBoxName, + preload: true, + ); + _inboundGroupSessionsBox = await _collection.openBox( + _inboundGroupSessionsBoxName, + ); + _outboundGroupSessionsBox = await _collection.openBox( + _outboundGroupSessionsBoxName, + ); + _olmSessionsBox = await _collection.openBox( + _olmSessionsBoxName, + ); + _userDeviceKeysBox = await _collection.openBox( + _userDeviceKeysBoxName, + ); + _userDeviceKeysOutdatedBox = await _collection.openBox( + _userDeviceKeysOutdatedBoxName, + ); + _userCrossSigningKeysBox = await _collection.openBox( + _userCrossSigningKeysBoxName, + ); + _ssssCacheBox = await _collection.openBox( + _ssssCacheBoxName, + ); + _presencesBox = await _collection.openBox( + _presencesBoxName, + ); + _timelineFragmentsBox = await _collection.openBox( + _timelineFragmentsBoxName, + ); + _eventsBox = await _collection.openBox( + _eventsBoxName, + ); + _seenDeviceIdsBox = await _collection.openBox( + _seenDeviceIdsBoxName, + ); + _seenDeviceKeysBox = await _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 _migrateFromVersion(int currentVersion) async { + Logs().i('Migrate store database from version $currentVersion to $version'); + await clearCache(); + await _clientBox.put('version', version.toString()); + } + + @override + Future clear() => _collection.deleteFromDisk(); + + @override + Future clearCache() => transaction(() async { + await _roomsBox.clear(); + await _accountDataBox.clear(); + await _roomStateBox.clear(); + await _roomMembersBox.clear(); + await _eventsBox.clear(); + await _timelineFragmentsBox.clear(); + await _outboundGroupSessionsBox.clear(); + await _presencesBox.clear(); + await _clientBox.delete('prev_batch'); + }); + + @override + Future clearSSSSCache() => _ssssCacheBox.clear(); + + @override + Future close() async => _collection.close(); + + @override + Future deleteFromToDeviceQueue(int id) async { + await _toDeviceQueueBox.delete(id.toString()); + return; + } + + @override + Future deleteOldFiles(int savedAt) async { + return; + } + + @override + Future forgetRoom(String roomId) => transaction(() 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 roomStateBoxKeys = await _roomStateBox.getAllKeys(); + for (final key in roomStateBoxKeys) { + final multiKey = TupleKey.fromString(key); + if (multiKey.parts.first != roomId) continue; + await _roomStateBox.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> getAccountData() => + runBenchmarked>('Get all account data from store', + () async { + final accountData = {}; + 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?> getClient(String name) => + runBenchmarked('Get Client from store', () async { + final map = {}; + 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 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> _getEventsByIds(List eventIds, Room room) async { + final keys = eventIds + .map( + (eventId) => TupleKey(room.id, eventId).toString(), + ) + .toList(); + final rawEvents = await _eventsBox.getAll(keys); + return rawEvents + .map((rawEvent) => Event.fromJson(copyMap(rawEvent!), room)) + .toList(); + } + + @override + Future> getEventList( + Room room, { + int start = 0, + int? limit, + }) => + runBenchmarked>('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 + + (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); + if (raw == null) return null; + return StoredInboundGroupSession.fromJson(copyMap(raw)); + } + + @override + Future> + 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> getLastSentMessageUserDeviceKey( + String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(TupleKey(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)) ?? {}; + rawSessions[sessionId] = { + 'identity_key': identityKey, + 'pickle': pickle, + 'session_id': sessionId, + 'last_received': lastReceived, + }; + await _olmSessionsBox.put(identityKey, rawSessions); + return; + } + + @override + Future> getOlmSessions( + String identityKey, String userId) async { + final rawSessions = await _olmSessionsBox.get(identityKey); + if (rawSessions == null || rawSessions.isEmpty) return []; + return rawSessions.values + .map((json) => OlmSession.fromJson(copyMap(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); + if (raw == null) return null; + return OutboundGroupSession.fromJson(copyMap(raw), userId); + } + + @override + Future> getRoomList(Client client) => + runBenchmarked>('Get room list from store', () async { + final rooms = {}; + final userID = client.userID; + + final rawRooms = await _roomsBox.getAllValues(); + + final getRoomStateRequests = >{}; + final getRoomMembersRequests = >{}; + for (final raw in rawRooms.values) { + // Get the room + final room = Room.fromJson(copyMap(raw), client); + + final membersToPostload = {if (userID != null) userID}; + + // If the room is a direct chat, those IDs should be there too + if (room.isDirectChat) { + membersToPostload + .add(TupleKey(room.id, room.directChatMatrixID!).toString()); + } + // the lastEvent message preview might have an author we need to fetch, if it is a group chat + final lastEvent = room.getState(EventTypes.Message); + if (lastEvent != null && !room.isDirectChat) { + membersToPostload + .add(TupleKey(room.id, lastEvent.senderId).toString()); + } + // 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 + final heroes = room.summary.mHeroes; + if (heroes != null) { + heroes.forEach((hero) => membersToPostload.add(hero)); + } + } + + // Get the "important" room states. All other states will be loaded once + // `getUnimportantRoomStates()` is called. + final dbKeys = client.importantStateEvents + .map((state) => TupleKey(room.id, state).toString()) + .toList(); + getRoomStateRequests[room.id] = _roomStateBox.getAll( + dbKeys, + ); + + // Load members + final membersDbKeys = membersToPostload + .map((member) => TupleKey(room.id, member).toString()) + .toList(); + getRoomMembersRequests[room.id] = _roomMembersBox.getAll( + membersDbKeys, + ); + + // Add to the list and continue. + rooms[room.id] = room; + } + + for (final room in rooms.values) { + // Add states to the room + final statesList = await getRoomStateRequests[room.id]; + if (statesList != null) { + for (final states in statesList) { + if (states == null) continue; + final stateEvents = states.values + .map((raw) => Event.fromJson(copyMap(raw), room)) + .toList(); + for (final state in stateEvents) { + room.setState(state); + } + } + } + + // Add members to the room + final members = await getRoomMembersRequests[room.id]; + if (members != null) { + for (final member in members) { + if (member == null) continue; + room.setState(Event.fromJson(copyMap(member), room)); + } + } + } + + // 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 getSSSSCache(String type) async { + final raw = await _ssssCacheBox.get(type); + if (raw == null) return null; + return SSSSCache.fromJson(copyMap(raw)); + } + + @override + Future> 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']); + return copiedRaw; + }).toList(); + return copiedRaws.map((raw) => QueuedToDeviceEvent.fromJson(raw)).toList(); + } + + @override + Future> getUnimportantRoomEventStatesForRoom( + List events, Room room) async { + final keys = (await _roomStateBox.getAllKeys()).where((key) { + final tuple = TupleKey.fromString(key); + return tuple.parts.first == room.id && !events.contains(tuple.parts[1]); + }); + + final unimportantEvents = []; + for (final key in keys) { + final states = await _roomStateBox.get(key); + if (states == null) continue; + unimportantEvents.addAll( + states.values.map((raw) => Event.fromJson(copyMap(raw), room))); + } + return unimportantEvents; + } + + @override + Future 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> getUserDeviceKeys(Client client) => + runBenchmarked>( + 'Get all user device keys from store', () async { + final deviceKeysOutdated = + await _userDeviceKeysOutdatedBox.getAllKeys(); + if (deviceKeysOutdated.isEmpty) { + return {}; + } + final res = {}; + final userDeviceKeysBoxKeys = await _userDeviceKeysBox.getAllKeys(); + final userCrossSigningKeysBoxKeys = + await _userCrossSigningKeysBox.getAllKeys(); + for (final userId in deviceKeysOutdated) { + final deviceKeysBoxKeys = userDeviceKeysBoxKeys.where((tuple) { + final tupleKey = TupleKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + final crossSigningKeysBoxKeys = + userCrossSigningKeysBoxKeys.where((tuple) { + final tupleKey = TupleKey.fromString(tuple); + return tupleKey.parts.first == userId; + }); + final childEntries = await Future.wait( + deviceKeysBoxKeys.map( + (key) async { + final userDeviceKey = await _userDeviceKeysBox.get(key); + if (userDeviceKey == null) return null; + return copyMap(userDeviceKey); + }, + ), + ); + final crossSigningEntries = await Future.wait( + crossSigningKeysBoxKeys.map( + (key) async { + final crossSigningKey = await _userCrossSigningKeysBox.get(key); + if (crossSigningKey == null) return null; + return copyMap(crossSigningKey); + }, + ), + ); + res[userId] = DeviceKeysList.fromDbJson( + { + 'client_id': client.id, + 'user_id': userId, + 'outdated': await _userDeviceKeysOutdatedBox.get(userId), + }, + childEntries + .where((c) => c != null) + .toList() + .cast>(), + crossSigningEntries + .where((c) => c != null) + .toList() + .cast>(), + client); + } + return res; + }); + + @override + Future> getUsers(Room room) async { + final users = []; + 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); + states.forEach( + (state) => users.add(Event.fromJson(copyMap(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 transaction(() async { + await _clientBox.put('homeserver_url', homeserverUrl); + await _clientBox.put('token', token); + 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 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 markInboundGroupSessionAsUploaded( + String roomId, String sessionId) async { + final raw = await _inboundGroupSessionsBox.get(sessionId); + 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, raw); + return; + } + + @override + Future markInboundGroupSessionsAsNeedingUpload() async { + final keys = await _inboundGroupSessionsBox.getAllKeys(); + for (final sessionId in keys) { + final raw = await _inboundGroupSessionsBox.get(sessionId); + if (raw == null) continue; + raw['uploaded'] = false; + await _inboundGroupSessionsBox.put(sessionId, raw); + } + return; + } + + @override + Future 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 = 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); + return; + } + + @override + Future removeUserCrossSigningKey( + String userId, String publicKey) async { + await _userCrossSigningKeysBox + .delete(TupleKey(userId, publicKey).toString()); + return; + } + + @override + Future removeUserDeviceKey(String userId, String deviceId) async { + await _userDeviceKeysBox.delete(TupleKey(userId, deviceId).toString()); + return; + } + + @override + Future resetNotificationCount(String roomId) async { + final raw = await _roomsBox.get(roomId); + if (raw == null) return; + raw['notification_count'] = raw['highlight_count'] = 0; + await _roomsBox.put(roomId, raw); + return; + } + + @override + Future setBlockedUserCrossSigningKey( + bool blocked, String userId, String publicKey) async { + final raw = await _userCrossSigningKeysBox + .get(TupleKey(userId, publicKey).toString()); + raw!['blocked'] = blocked; + await _userCrossSigningKeysBox.put( + TupleKey(userId, publicKey).toString(), + raw, + ); + return; + } + + @override + Future setBlockedUserDeviceKey( + bool blocked, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()); + raw!['blocked'] = blocked; + await _userDeviceKeysBox.put( + TupleKey(userId, deviceId).toString(), + raw, + ); + return; + } + + @override + Future setLastActiveUserDeviceKey( + int lastActive, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()); + raw!['last_active'] = lastActive; + await _userDeviceKeysBox.put( + TupleKey(userId, deviceId).toString(), + raw, + ); + } + + @override + Future setLastSentMessageUserDeviceKey( + String lastSentMessage, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()); + raw!['last_sent_message'] = lastSentMessage; + await _userDeviceKeysBox.put( + TupleKey(userId, deviceId).toString(), + raw, + ); + } + + @override + Future 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 setVerifiedUserCrossSigningKey( + bool verified, String userId, String publicKey) async { + final raw = (await _userCrossSigningKeysBox + .get(TupleKey(userId, publicKey).toString())) ?? + {}; + raw['verified'] = verified; + await _userCrossSigningKeysBox.put( + TupleKey(userId, publicKey).toString(), + raw, + ); + return; + } + + @override + Future setVerifiedUserDeviceKey( + bool verified, String userId, String deviceId) async { + final raw = + await _userDeviceKeysBox.get(TupleKey(userId, deviceId).toString()); + raw!['verified'] = verified; + await _userDeviceKeysBox.put( + TupleKey(userId, deviceId).toString(), + raw, + ); + return; + } + + @override + Future storeAccountData(String type, String content) async { + await _accountDataBox.put(type, copyMap(jsonDecode(content))); + return; + } + + @override + Future storeEventUpdate(EventUpdate eventUpdate, Client client) async { + // Ephemerals should not be stored + if (eventUpdate.type == EventUpdateType.ephemeral) return; + final tmpRoom = Room(id: eventUpdate.roomID, client: client); + + // In case of this is a redaction event + if (eventUpdate.content['type'] == EventTypes.Redaction) { + final event = await getEventById(eventUpdate.content['redacts'], tmpRoom); + if (event != null) { + event.setRedactionEvent(Event.fromJson(eventUpdate.content, tmpRoom)); + await _eventsBox.put( + TupleKey(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 = await _eventsBox + .get(TupleKey(eventUpdate.roomID, eventId).toString()); + final prevStatus = prevEvent == null + ? null + : () { + final json = copyMap(prevEvent); + final statusInt = json.tryGet('status') ?? + json + .tryGetMap('unsigned') + ?.tryGet(messageSendingStatusKey); + return statusInt == null ? null : eventStatusFromInt(statusInt); + }(); + + // 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 && 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'] ??= {}; + 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(TupleKey(eventUpdate.roomID, eventId).toString(), + eventUpdate.content); + + // Update timeline fragments + final key = TupleKey(eventUpdate.roomID, status.isSent ? '' : 'SENDING') + .toString(); + + final eventIds = + List.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.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); + } + } + + // Store a common state event + if ({ + EventUpdateType.timeline, + EventUpdateType.state, + EventUpdateType.inviteState + }.contains(eventUpdate.type)) { + if (eventUpdate.content['type'] == EventTypes.RoomMember) { + await _roomMembersBox.put( + TupleKey( + eventUpdate.roomID, + eventUpdate.content['state_key'], + ).toString(), + eventUpdate.content); + } else { + final key = TupleKey( + eventUpdate.roomID, + eventUpdate.content['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('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'); + final state = stateMap[''] == null + ? null + : Event.fromJson(stateMap[''] as Map, tmpRoom); + if (eventUpdate.content['type'] != EventTypes.Message || + eventUpdate.content + .tryGetMap('content') + ?.tryGetMap('m.relates_to') + ?.tryGet('rel_type') != + RelationshipTypes.edit || + editedEventRelationshipEventId == state?.eventId || + ((state?.relationshipType == RelationshipTypes.edit && + editedEventRelationshipEventId == + state?.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( + TupleKey( + 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, + 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, { + 'room_id': roomId, + 'pickle': pickle, + 'device_ids': deviceIds, + 'creation_time': creationTime, + }); + return; + } + + @override + Future storePrevBatch( + String prevBatch, + ) async { + if ((await _clientBox.getAllKeys()).isEmpty) return; + await _clientBox.put('prev_batch', prevBatch); + return; + } + + @override + Future 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 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( + TupleKey(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(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 storeUserDeviceKeysInfo(String userId, bool outdated) async { + await _userDeviceKeysOutdatedBox.put(userId, 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); + late final T result; + await _collection.transaction(() async { + result = await action(); + }); + return result; + } 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 transaction(() async { + await _clientBox.put('homeserver_url', homeserverUrl); + await _clientBox.put('token', token); + 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 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); + 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 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 updateRoomSortOrder( + double oldestSortOrder, double newestSortOrder, String roomId) async { + final raw = await _roomsBox.get(roomId); + if (raw == null) throw ('Room not found'); + raw['oldest_sort_order'] = oldestSortOrder; + raw['newest_sort_order'] = newestSortOrder; + await _roomsBox.put(roomId, raw); + return; + } + + @override + Future> getAllInboundGroupSessions() async { + final rawSessions = await _inboundGroupSessionsBox.getAllValues(); + return rawSessions.values + .map((raw) => StoredInboundGroupSession.fromJson(copyMap(raw))) + .toList(); + } + + @override + Future addSeenDeviceId( + String userId, + String deviceId, + String publicKeysHash, + ) => + _seenDeviceIdsBox.put( + TupleKey(userId, deviceId).toString(), publicKeysHash); + + @override + Future addSeenPublicKey( + String publicKey, + String deviceId, + ) => + _seenDeviceKeysBox.put(publicKey, deviceId); + + @override + Future deviceIdSeen(userId, deviceId) async { + final raw = + await _seenDeviceIdsBox.get(TupleKey(userId, deviceId).toString()); + if (raw == null) return null; + return raw; + } + + @override + Future publicKeySeen(String publicKey) async { + final raw = await _seenDeviceKeysBox.get(publicKey); + if (raw == null) return null; + return raw; + } +} + +class TupleKey { + final List parts; + + TupleKey(String key1, [String? key2, String? key3]) + : parts = [ + key1, + if (key2 != null) key2, + if (key3 != null) key3, + ]; + + const TupleKey.byParts(this.parts); + + TupleKey.fromString(String multiKeyString) + : parts = multiKeyString.split('|').toList(); + + @override + String toString() => parts.join('|'); + + @override + bool operator ==(other) => parts.toString() == other.toString(); +} + +dynamic _castValue(dynamic value) { + if (value is Map) { + return copyMap(value); + } + if (value is List) { + return value.map(_castValue).toList(); + } + return value; +} + +/// The store always gives back an `_InternalLinkedHasMap`. This +/// creates a deep copy of the json and makes sure that the format is always +/// `Map`. +Map copyMap(Map map) { + final copy = Map.from(map); + for (final entry in copy.entries) { + copy[entry.key] = _castValue(entry.value); + } + return copy; +} diff --git a/pubspec.yaml b/pubspec.yaml index bc030715..08e67807 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: slugify: ^2.0.0 html: ^0.15.0 collection: ^1.15.0 + fluffybox: ^0.3.3 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 3b96d7e5..0517bcc2 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -26,6 +26,11 @@ import 'package:olm/olm.dart' as olm; import 'fake_database.dart'; void main() { + group('FluffyBox Database Test', () { + testDatabase( + getFluffyBoxDatabase(null), + ); + }); group('Hive Database Test', () { testDatabase( getHiveDatabase(null), diff --git a/test/fake_database.dart b/test/fake_database.dart index a5dad91b..64ee745b 100644 --- a/test/fake_database.dart +++ b/test/fake_database.dart @@ -23,11 +23,25 @@ import 'package:matrix/matrix.dart'; import 'package:matrix/src/database/hive_database.dart'; import 'package:file/memory.dart'; import 'package:hive/hive.dart'; +import 'package:matrix/src/database/fluffybox_database.dart'; Future getDatabase(Client? _) => getHiveDatabase(_); bool hiveInitialized = false; +Future getFluffyBoxDatabase(Client? c) async { + final fileSystem = MemoryFileSystem(); + final testHivePath = + '${fileSystem.path}/build/.test_store/${Random().nextDouble()}'; + Directory(testHivePath).createSync(recursive: true); + final db = FluffyBoxDatabase( + 'unit_test.${c?.hashCode}', + testHivePath, + ); + await db.open(); + return db; +} + Future getHiveDatabase(Client? c) async { if (!hiveInitialized) { final fileSystem = MemoryFileSystem(); From 3ec778ff0d7ea060002913aa9e91627ca01a75f9 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Tue, 23 Nov 2021 16:09:17 +0100 Subject: [PATCH 18/26] chore: Bump version --- CHANGELOG.md | 11 +++++++++++ pubspec.yaml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b746a905..e6cd6133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [0.7.0-nullsafety.7] - 23nd Nov 2021 +feat: Add commands to create chats +feat: Add clear cache command +feat: Implement new FluffyBox database API implementation +fix: Workaround for a null exception for a non nullable boolean while user device key updating +fix: Limited timeline clears too many events +fix: Ability to remove avatar from room and account +fix: Request history in archived rooms +fix: Decrypt last event of a room +refactor: Remove Sembast database implementation + ## [0.7.0-nullsafety.6] - 16nd Nov 2021 - feat: Implement sembast store - fix: HtmlToText crashes with an empty code block diff --git a/pubspec.yaml b/pubspec.yaml index 08e67807..04f6ca4f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.6 +version: 0.7.0-nullsafety.7 homepage: https://famedly.com environment: From b2281025e77d14493b8c1daf31a8669634d7a1fd Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Wed, 24 Nov 2021 13:02:34 +0100 Subject: [PATCH 19/26] chore: Update FluffyBox --- CHANGELOG.md | 3 +++ pubspec.yaml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cd6133..7fd10088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [0.7.0-nullsafety.8] - 23nd Nov 2021 +- chore: Update FluffyBox + ## [0.7.0-nullsafety.7] - 23nd Nov 2021 feat: Add commands to create chats feat: Add clear cache command diff --git a/pubspec.yaml b/pubspec.yaml index 04f6ca4f..3c8e3562 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.7 +version: 0.7.0-nullsafety.8 homepage: https://famedly.com environment: @@ -23,7 +23,7 @@ dependencies: slugify: ^2.0.0 html: ^0.15.0 collection: ^1.15.0 - fluffybox: ^0.3.3 + fluffybox: ^0.3.4 dev_dependencies: dart_code_metrics: ^4.4.0 From 79b74e2bbf0d1dbe0a53075b559f9dd6b4a9f21d Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 25 Nov 2021 09:00:59 +0100 Subject: [PATCH 20/26] fix: Remove user avatar --- lib/src/client.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 0a0aa79b..4ce9629e 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -824,13 +824,17 @@ class Client extends MatrixApi { /// Uploads a new user avatar for this user. Leave file null to remove the /// current avatar. Future setAvatar(MatrixFile? file) async { - final uploadResp = file == null - ? null - : await uploadContent( - file.bytes, - filename: file.name, - contentType: file.mimeType, - ); + if (file == null) { + // We send an empty String to remove the avatar. Sending Null **should** + // work but it doesn't with Synapse. See: + // https://gitlab.com/famedly/company/frontend/famedlysdk/-/issues/254 + return setAvatarUrl(userID!, Uri.parse('')); + } + final uploadResp = await uploadContent( + file.bytes, + filename: file.name, + contentType: file.mimeType, + ); await setAvatarUrl(userID!, uploadResp); return; } From 6c3741d59ef6d56492c561e0924b851d5af6aeba Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 25 Nov 2021 13:13:39 +0100 Subject: [PATCH 21/26] fix: Limited timeline clean up on web --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3c8e3562..2fb0e85d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: slugify: ^2.0.0 html: ^0.15.0 collection: ^1.15.0 - fluffybox: ^0.3.4 + fluffybox: ^0.3.5 dev_dependencies: dart_code_metrics: ^4.4.0 From cbf961aa9d95e6d3aede8a8f50034207ed206d5f Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 25 Nov 2021 13:14:48 +0100 Subject: [PATCH 22/26] chore: Update SDK --- CHANGELOG.md | 24 ++++++++++++++---------- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd10088..d5dba897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,20 @@ -## [0.7.0-nullsafety.8] - 23nd Nov 2021 +## [0.7.0-nullsafety.9] - 25nd Nov 2021 +- fix: Limited timeline clean up on web +- fix: Remove account avatar + +## [0.7.0-nullsafety.8] - 24nd Nov 2021 - chore: Update FluffyBox ## [0.7.0-nullsafety.7] - 23nd Nov 2021 -feat: Add commands to create chats -feat: Add clear cache command -feat: Implement new FluffyBox database API implementation -fix: Workaround for a null exception for a non nullable boolean while user device key updating -fix: Limited timeline clears too many events -fix: Ability to remove avatar from room and account -fix: Request history in archived rooms -fix: Decrypt last event of a room -refactor: Remove Sembast database implementation +- feat: Add commands to create chats +- feat: Add clear cache command +- feat: Implement new FluffyBox database API implementation +- fix: Workaround for a null exception for a non nullable boolean while user device key updating +- fix: Limited timeline clears too many events +- fix: Ability to remove avatar from room and account +- fix: Request history in archived rooms +- fix: Decrypt last event of a room +- refactor: Remove Sembast database implementation ## [0.7.0-nullsafety.6] - 16nd Nov 2021 - feat: Implement sembast store diff --git a/pubspec.yaml b/pubspec.yaml index 2fb0e85d..78b72543 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.8 +version: 0.7.0-nullsafety.9 homepage: https://famedly.com environment: From 03418bfe8bf0bc9b3d0b813021d647e54f017fe1 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Thu, 25 Nov 2021 15:35:36 +0100 Subject: [PATCH 23/26] chore: Enable E2EE recovery by default We have disabled it by default to prevent using workarounds as long time solutions and to not miss bugs. But in a federated context we can not be sure that we all Matrix clients are ever bug free and we have now the onEncryptionError Stream anyway. --- lib/encryption/encryption.dart | 5 +---- lib/encryption/key_manager.dart | 3 +-- lib/encryption/olm_manager.dart | 8 ++++---- lib/src/client.dart | 9 +++------ 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/lib/encryption/encryption.dart b/lib/encryption/encryption.dart index b40a4d76..f49a175c 100644 --- a/lib/encryption/encryption.dart +++ b/lib/encryption/encryption.dart @@ -33,7 +33,6 @@ import 'utils/bootstrap.dart'; class Encryption { final Client client; final bool debug; - final bool enableE2eeRecovery; bool get enabled => olmManager.enabled; @@ -53,7 +52,6 @@ class Encryption { Encryption({ required this.client, this.debug = false, - required this.enableE2eeRecovery, }) { ssss = SSSS(this); keyManager = KeyManager(this); @@ -232,8 +230,7 @@ class Encryption { decryptedPayload = json.decode(decryptResult.plaintext); } catch (exception) { // alright, if this was actually by our own outbound group session, we might as well clear it - if (client.enableE2eeRecovery && - exception.toString() != DecryptException.unknownSession && + if (exception.toString() != DecryptException.unknownSession && (keyManager .getOutboundGroupSession(roomId) ?.outboundGroupSession diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index a531196c..8e182ab0 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -219,8 +219,7 @@ class KeyManager { void maybeAutoRequest(String roomId, String sessionId, String senderKey) { final room = client.getRoomById(roomId); final requestIdent = '$roomId|$sessionId|$senderKey'; - if (client.enableE2eeRecovery && - room != null && + if (room != null && !_requestedSessionIds.contains(requestIdent) && !client.isUnknownSession) { // do e2ee recovery diff --git a/lib/encryption/olm_manager.dart b/lib/encryption/olm_manager.dart index 155be967..05478d18 100644 --- a/lib/encryption/olm_manager.dart +++ b/lib/encryption/olm_manager.dart @@ -514,10 +514,10 @@ class OlmManager { return _decryptToDeviceEvent(event); } catch (_) { // okay, the thing errored while decrypting. It is safe to assume that the olm session is corrupt and we should generate a new one - if (client.enableE2eeRecovery) { - // ignore: unawaited_futures - runInRoot(() => restoreOlmSession(event.senderId, senderKey)); - } + + // ignore: unawaited_futures + runInRoot(() => restoreOlmSession(event.senderId, senderKey)); + rethrow; } } diff --git a/lib/src/client.dart b/lib/src/client.dart index 4ce9629e..15b1db5b 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -74,8 +74,6 @@ class Client extends MatrixApi { DatabaseApi? get database => _database; - bool enableE2eeRecovery; - @deprecated MatrixApi get api => this; @@ -120,7 +118,6 @@ class Client extends MatrixApi { /// [databaseBuilder]: A function that creates the database instance, that will be used. /// [legacyDatabaseBuilder]: Use this for your old database implementation to perform an automatic migration /// [databaseDestroyer]: A function that can be used to destroy a database instance, for example by deleting files from disk. - /// [enableE2eeRecovery]: Enable additional logic to try to recover from bad e2ee sessions /// [verificationMethods]: A set of all the verification methods this client can handle. Includes: /// KeyVerificationMethod.numbers: Compare numbers. Most basic, should be supported /// KeyVerificationMethod.emoji: Compare emojis @@ -157,7 +154,8 @@ class Client extends MatrixApi { this.databaseDestroyer, this.legacyDatabaseBuilder, this.legacyDatabaseDestroyer, - this.enableE2eeRecovery = false, + @Deprecated('This is now always enabled by default.') + bool? enableE2eeRecovery, Set? verificationMethods, http.Client? httpClient, Set? importantStateEvents, @@ -1074,8 +1072,7 @@ class Client extends MatrixApi { // make sure to throw an exception if libolm doesn't exist await olm.init(); olm.get_library_version(); - encryption = - Encryption(client: this, enableE2eeRecovery: enableE2eeRecovery); + encryption = Encryption(client: this); } catch (_) { encryption?.dispose(); encryption = null; From ac06864627dc100f744b73f6bc6415ac67d44a11 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Fri, 26 Nov 2021 08:17:43 +0100 Subject: [PATCH 24/26] feat: Migrate olm sessions on database migration This adds a getAllOlmSessions endpoint to the database API and implements them in both implementations. This also adds it to the database migration. --- lib/src/client.dart | 18 ++++++++++++ lib/src/database/database_api.dart | 2 ++ lib/src/database/fluffybox_database.dart | 4 +++ lib/src/database/hive_database.dart | 15 ++++++++++ test/database_api_test.dart | 36 ++++++++++++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/lib/src/client.dart b/lib/src/client.dart index 4ce9629e..22f922be 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2398,6 +2398,24 @@ class Client extends MatrixApi { ); } } + Logs().d('Migrate OLM sessions...'); + try { + final olmSessions = await legacyDatabase.getAllOlmSessions(); + for (final identityKey in olmSessions.keys) { + final sessions = olmSessions[identityKey]!; + for (final sessionId in sessions.keys) { + final session = sessions[sessionId]!; + await database.storeOlmSession( + identityKey, + session['session_id'] as String, + session['pickle'] as String, + session['last_received'] as int, + ); + } + } + } catch (e, s) { + Logs().e('Unable to migrate OLM sessions!', e, s); + } Logs().d('Migrate Device Keys...'); final userDeviceKeys = await legacyDatabase.getUserDeviceKeys(this); for (final userId in userDeviceKeys.keys) { diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 7bdc6872..2e3b754e 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -270,6 +270,8 @@ abstract class DatabaseApi { String userId, ); + Future> getAllOlmSessions(); + Future> getOlmSessionsForDevices( List identityKeys, String userId, diff --git a/lib/src/database/fluffybox_database.dart b/lib/src/database/fluffybox_database.dart index acfa3489..05c4eb5f 100644 --- a/lib/src/database/fluffybox_database.dart +++ b/lib/src/database/fluffybox_database.dart @@ -439,6 +439,10 @@ class FluffyBoxDatabase extends DatabaseApi { .toList(); } + @override + Future> getAllOlmSessions() => + _olmSessionsBox.getAllValues(); + @override Future> getOlmSessionsForDevices( List identityKey, String userId) async { diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 884b2c53..8cdb0acf 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -485,6 +485,21 @@ class FamedlySdkHiveDatabase extends DatabaseApi { .toList(); } + @override + Future> getAllOlmSessions() async { + final backup = Map.fromEntries( + await Future.wait( + _olmSessionsBox.keys.map( + (key) async => MapEntry( + key, + await _olmSessionsBox.get(key), + ), + ), + ), + ); + return backup.cast(); + } + @override Future> getOlmSessionsForDevices( List identityKey, String userId) async { diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 0517bcc2..0d809a65 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -313,6 +313,42 @@ void testDatabase( ); expect(olm.isEmpty, true); }); + test('getAllOlmSessions', () async { + var sessions = await database.getAllOlmSessions(); + expect(sessions.isEmpty, true); + await database.storeOlmSession( + 'identityKey', + 'sessionId', + 'pickle', + 0, + ); + await database.storeOlmSession( + 'identityKey', + 'sessionId2', + 'pickle', + 0, + ); + sessions = await database.getAllOlmSessions(); + expect( + sessions, + { + 'identityKey': { + 'sessionId': { + 'identity_key': 'identityKey', + 'pickle': 'pickle', + 'session_id': 'sessionId', + 'last_received': 0 + }, + 'sessionId2': { + 'identity_key': 'identityKey', + 'pickle': 'pickle', + 'session_id': 'sessionId2', + 'last_received': 0 + } + } + }, + ); + }); test('getOlmSessionsForDevices', () async { final olm = await database.getOlmSessionsForDevices( ['identityKeys'], From 0c4fcd2d4e96e5aca32e00d13e21c1ec04764274 Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Fri, 26 Nov 2021 13:15:05 +0000 Subject: [PATCH 25/26] chore: Bump version --- CHANGELOG.md | 4 ++++ pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dba897..9db07699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [0.7.0-nullsafety.10] - 26nd Nov 2021 +- feat: Migrate olm sessions on database migration +- chore: Enable E2EE recovery by default + ## [0.7.0-nullsafety.9] - 25nd Nov 2021 - fix: Limited timeline clean up on web - fix: Remove account avatar diff --git a/pubspec.yaml b/pubspec.yaml index 78b72543..8a682729 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.7.0-nullsafety.9 +version: 0.7.0-nullsafety.10 homepage: https://famedly.com environment: From fe2d184fafd3ac485ce1bb6d3f7e5da0728216ca Mon Sep 17 00:00:00 2001 From: Krille Fear Date: Sun, 28 Nov 2021 10:37:56 +0100 Subject: [PATCH 26/26] fix: userOwnsEncryptionKeys always returns true Actually the homeserver sends an empty object in the deviceKeys map so we need to check if this object is there but is empty. --- lib/src/client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index bbf881a6..0aa483f5 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -650,7 +650,7 @@ class Client extends MatrixApi { return true; } final keys = await queryKeys({userId: []}); - return keys.deviceKeys?.isNotEmpty ?? false; + return keys.deviceKeys?[userId]?.isNotEmpty ?? false; } /// Creates a new space and returns the Room ID. The parameters are mostly