diff --git a/lib/src/client.dart b/lib/src/client.dart index ced85487..e3312a7b 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -32,6 +32,7 @@ import 'package:matrix/src/utils/run_in_root.dart'; import 'package:matrix/src/utils/sync_update_item_count.dart'; import '../encryption.dart'; import '../matrix.dart'; +import 'models/timeline_chunk.dart'; import 'utils/multilock.dart'; import 'utils/run_benchmarked.dart'; @@ -287,7 +288,7 @@ class Client extends MatrixApi { /// found. If you have loaded the [loadArchive()] before, it can also return /// archived rooms. Room? getRoomById(String id) { - for (final room in [...rooms, ..._archivedRooms]) { + for (final room in [...rooms, ..._archivedRooms.map((e) => e.room)]) { if (room.id == id) return room; } @@ -781,9 +782,35 @@ class Client extends MatrixApi { avatarUrl: profile.avatarUrl); } - final List _archivedRooms = []; + final List _archivedRooms = []; + /// Return an archive room containing the room and the timeline for a specific archived room. + ArchivedRoom? getArchiveRoomFromCache(String roomId) { + for (var i = 0; i < _archivedRooms.length; i++) { + final archive = _archivedRooms[i]; + if (archive.room.id == roomId) return archive; + } + return null; + } + + /// Remove all the archives stored in cache. + void clearArchivesFromCache() { + _archivedRooms.clear(); + } + + @Deprecated('Use [loadArchive()] instead.') + Future> get archive => loadArchive(); + + /// Fetch all the archived rooms from the server and return the list of the + /// room. If you want to have the Timelines bundled with it, use + /// loadArchiveWithTimeline instead. Future> loadArchive() async { + return (await loadArchiveWithTimeline()).map((e) => e.room).toList(); + } + + /// Fetch the archived rooms from the server and return them as a list of + /// [ArchivedRoom] objects containing the [Room] and the associated [Timeline]. + Future> loadArchiveWithTimeline() async { _archivedRooms.clear(); final syncResp = await sync( filter: '{"room":{"include_leave":true,"timeline":{"limit":10}}}', @@ -804,12 +831,32 @@ class Client extends MatrixApi { {}, ); + final timeline = Timeline( + room: leftRoom, + chunk: TimelineChunk( + events: room.timeline?.events?.reversed + .toList() // we display the event in the other sence + .map((e) => Event.fromMatrixEvent(e, leftRoom)) + .toList() ?? + [])); + + for (var i = 0; i < timeline.events.length; i++) { + // Try to decrypt encrypted events but don't update the database. + if (leftRoom.encrypted && leftRoom.client.encryptionEnabled) { + if (timeline.events[i].type == EventTypes.Encrypted) { + timeline.events[i] = await leftRoom.client.encryption! + .decryptRoomEvent(leftRoom.id, timeline.events[i]); + } + } + } + room.timeline?.events?.forEach((event) { leftRoom.setState(Event.fromMatrixEvent( event, leftRoom, )); }); + leftRoom.prev_batch = room.timeline?.prevBatch; room.state?.forEach((event) { leftRoom.setState(Event.fromMatrixEvent( @@ -817,7 +864,8 @@ class Client extends MatrixApi { leftRoom, )); }); - _archivedRooms.add(leftRoom); + + _archivedRooms.add(ArchivedRoom(room: leftRoom, timeline: timeline)); } } return _archivedRooms; @@ -1658,6 +1706,12 @@ class Client extends MatrixApi { await database?.storeRoomUpdate(id, syncRoomUpdate, this); final room = _updateRoomsByRoomUpdate(id, syncRoomUpdate); + final timelineUpdateType = direction != null + ? (direction == Direction.b + ? EventUpdateType.history + : EventUpdateType.timeline) + : EventUpdateType.timeline; + /// Handle now all room events and save them in the database if (syncRoomUpdate is JoinedRoomUpdate) { final state = syncRoomUpdate.state; @@ -1673,14 +1727,7 @@ class Client extends MatrixApi { final timelineEvents = syncRoomUpdate.timeline?.events; if (timelineEvents != null && timelineEvents.isNotEmpty) { - await _handleRoomEvents( - room, - timelineEvents, - direction != null - ? (direction == Direction.b - ? EventUpdateType.history - : EventUpdateType.timeline) - : EventUpdateType.timeline); + await _handleRoomEvents(room, timelineEvents, timelineUpdateType); } final ephemeral = syncRoomUpdate.ephemeral; @@ -1705,23 +1752,19 @@ class Client extends MatrixApi { if (syncRoomUpdate is LeftRoomUpdate) { final timelineEvents = syncRoomUpdate.timeline?.events; if (timelineEvents != null && timelineEvents.isNotEmpty) { - await _handleRoomEvents( - room, - timelineEvents, - EventUpdateType.timeline, - ); + await _handleRoomEvents(room, timelineEvents, timelineUpdateType, + store: false); } final accountData = syncRoomUpdate.accountData; if (accountData != null && accountData.isNotEmpty) { await _handleRoomEvents( - room, - accountData, - EventUpdateType.accountData, - ); + room, accountData, EventUpdateType.accountData, + store: false); } final state = syncRoomUpdate.state; if (state != null && state.isNotEmpty) { - await _handleRoomEvents(room, state, EventUpdateType.state); + await _handleRoomEvents(room, state, EventUpdateType.state, + store: false); } } @@ -1795,10 +1838,8 @@ class Client extends MatrixApi { } Future _handleRoomEvents( - Room room, - List events, - EventUpdateType type, - ) async { + Room room, List events, EventUpdateType type, + {bool store = true}) async { // Calling events can be omitted if they are outdated from the same sync. So // we collect them first before we handle them. final callEvents = {}; @@ -1833,7 +1874,7 @@ class Client extends MatrixApi { } } _updateRoomsByEventUpdate(room, update); - if (type != EventUpdateType.ephemeral) { + if (type != EventUpdateType.ephemeral && store) { await database?.storeEventUpdate(update, this); } if (encryptionEnabled) { @@ -1941,6 +1982,10 @@ class Client extends MatrixApi { // Does the chat already exist in the list rooms? if (!found && membership != Membership.leave) { + // Check if the room is not in the rooms in the invited list + if (_archivedRooms.isNotEmpty) { + _archivedRooms.removeWhere((archive) => archive.room.id == roomId); + } final position = membership == Membership.invite ? 0 : rooms.length; // Add the new chat to the list rooms.insert(position, room); @@ -2857,3 +2902,9 @@ class HomeserverSummary { required this.loginFlows, }); } + +class ArchivedRoom { + final Room room; + final Timeline timeline; + ArchivedRoom({required this.room, required this.timeline}); +} diff --git a/lib/src/room.dart b/lib/src/room.dart index ad17445d..47df513a 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -1136,6 +1136,9 @@ class Room { void Function()? onHistoryReceived, direction = Direction.b}) async { final prev_batch = this.prev_batch; + + final storeInDatabase = !isArchived; + if (prev_batch == null) { throw 'Tried to request history without a prev_batch token'; } @@ -1179,7 +1182,9 @@ class Room { state: resp.state, timeline: TimelineUpdate( limited: false, - events: resp.chunk, + events: direction == Direction.b + ? resp.chunk + : resp.chunk.reversed.toList(), prevBatch: direction == Direction.b ? resp.end : resp.start, @@ -1193,7 +1198,9 @@ class Room { if (client.database != null) { await client.database?.transaction(() async { - await client.database?.setRoomPrevBatch(resp.end!, id, client); + if (storeInDatabase) { + await client.database?.setRoomPrevBatch(resp.end!, id, client); + } await loadFn(); }); } else { @@ -1304,6 +1311,9 @@ class Room { return; } + /// Is the room archived + bool get isArchived => membership == Membership.leave; + /// Creates a timeline from the store. Returns a [Timeline] object. If you /// just want to update the whole timeline on every change, use the [onUpdate] /// callback. For updating only the parts that have changed, use the @@ -1319,35 +1329,41 @@ class Room { String? eventContextId}) async { await postLoad(); - final _events = await client.database?.getEventList( - this, - limit: defaultHistoryCount, - ); + var events; - var chunk = TimelineChunk(events: _events ?? []); + if (!isArchived) { + events = await client.database?.getEventList( + this, + limit: defaultHistoryCount, + ) ?? + []; + } else { + final archive = client.getArchiveRoomFromCache(id); + events = archive?.timeline.events.toList() ?? []; + } - if (_events != null) { - if (eventContextId != null) { - if (_events - .firstWhereOrNull((event) => event.eventId == eventContextId) != - null) { - chunk = TimelineChunk(events: _events); - } else { - chunk = await getEventContext(eventContextId) ?? - TimelineChunk(events: []); - } - } - - // Fetch all users from database we have got here. - if (eventContextId == null) { - for (final event in _events) { - if (getState(EventTypes.RoomMember, event.senderId) != null) continue; - final dbUser = await client.database?.getUser(event.senderId, this); - if (dbUser != null) setState(dbUser); - } + var chunk = TimelineChunk(events: events); + // Load the timeline arround eventContextId if set + if (eventContextId != null) { + if (events.firstWhereOrNull((event) => event.eventId == eventContextId) != + null) { + chunk = TimelineChunk(events: events); + } else { + chunk = + await getEventContext(eventContextId) ?? TimelineChunk(events: []); } } + // Fetch all users from database we have got here. + if (eventContextId == null) { + for (final event in events) { + if (getState(EventTypes.RoomMember, event.senderId) != null) continue; + final dbUser = await client.database?.getUser(event.senderId, this); + if (dbUser != null) setState(dbUser); + } + } + + // Try again to decrypt encrypted events and update the database. if (encrypted && client.encryptionEnabled) { // decrypt messages for (var i = 0; i < chunk.events.length; i++) { @@ -1364,8 +1380,9 @@ class Room { await client.database?.transaction(() async { for (var i = 0; i < chunk.events.length; i++) { if (chunk.events[i].content['can_request_session'] == true) { - chunk.events[i] = await client.encryption! - .decryptRoomEvent(id, chunk.events[i], store: true); + chunk.events[i] = await client.encryption!.decryptRoomEvent( + id, chunk.events[i], + store: !isArchived); } } }); diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index c85d206e..581bfdde 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -224,9 +224,7 @@ class Timeline { } // Try to decrypt encrypted events but don't update the database. - if (room.encrypted && - room.client.database != null && - room.client.encryptionEnabled) { + if (room.encrypted && room.client.encryptionEnabled) { for (var i = 0; i < newEvents.length; i++) { if (newEvents[i].type == EventTypes.Encrypted) { newEvents[i] = await room.client.encryption! diff --git a/test/client_test.dart b/test/client_test.dart index 3c32031a..fd39c64d 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -338,21 +338,6 @@ void main() { ); }); - test('get archive', () async { - await matrix.abortSync(); - final archive = await matrix.loadArchive(); - - expect(archive.length, 2); - expect(archive[0].id, '!5345234234:example.com'); - expect(archive[0].membership, Membership.leave); - expect(archive[0].name, 'The room name'); - expect(archive[0].lastEvent?.body, 'This is an example text message'); - expect(archive[0].roomAccountData.length, 1); - expect(archive[1].id, '!5345234235:example.com'); - expect(archive[1].membership, Membership.leave); - expect(archive[1].name, 'The room name 2'); - }); - test('sync state event in-memory handling', () async { final roomId = '!726s6s6q:example.com'; final room = matrix.getRoomById(roomId)!; diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index ac706306..5eea00d8 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -361,6 +361,65 @@ class FakeMatrixApi extends BaseClient { 'state': [], }; + static Map archivesMessageResponse = { + 'start': 't47429-4392820_219380_26003_2265', + 'end': 't47409-4357353_219380_26003_2265', + 'chunk': [ + { + 'content': { + 'body': 'This is an example text message', + 'msgtype': 'm.text', + 'format': 'org.matrix.custom.html', + 'formatted_body': 'This is an example text message' + }, + 'type': 'm.room.message', + 'event_id': '3143273582443PhrSn:example.org', + 'room_id': '!5345234234:example.com', + 'sender': '@example:example.org', + 'origin_server_ts': 1432735824653, + 'unsigned': {'age': 1234} + }, + { + 'content': {'name': 'The room name'}, + 'type': 'm.room.name', + 'event_id': '2143273582443PhrSn:example.org', + 'room_id': '!5345234234:example.com', + 'sender': '@example:example.org', + 'origin_server_ts': 1432735824653, + 'unsigned': {'age': 1234}, + 'state_key': '' + }, + { + 'content': { + 'body': 'Gangnam Style', + 'url': 'mxc://example.org/a526eYUSFFxlgbQYZmo442', + 'info': { + 'thumbnail_url': 'mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe', + 'thumbnail_info': { + 'mimetype': 'image/jpeg', + 'size': 46144, + 'w': 300, + 'h': 300 + }, + 'w': 480, + 'h': 320, + 'duration': 2140786, + 'size': 1563685, + 'mimetype': 'video/mp4' + }, + 'msgtype': 'm.video' + }, + 'type': 'm.room.message', + 'event_id': '1143273582466PhrSn:example.org', + 'room_id': '!5345234234:example.com', + 'sender': '@example:example.org', + 'origin_server_ts': 1432735824654, + 'unsigned': {'age': 1234} + } + ], + 'state': [], + }; + static Map syncResponse = { 'next_batch': Random().nextDouble().toString(), 'rooms': { @@ -888,19 +947,36 @@ class FakeMatrixApi extends BaseClient { 'events': [ { 'content': { - 'body': 'This is an example text message', + 'body': 'This is a second text example message', 'msgtype': 'm.text', 'format': 'org.matrix.custom.html', - 'formatted_body': 'This is an example text message' + 'formatted_body': + 'This is a second text example message' }, 'type': 'm.room.message', - 'event_id': '143273582443PhrSn:example.org', + 'event_id': '143274597446PhrSn:example.org', + 'room_id': '!5345234234:example.com', + 'sender': '@example:example.org', + 'origin_server_ts': 1432735824654, + 'unsigned': {'age': 1234} + }, + { + 'content': { + 'body': 'This is a first text example message', + 'msgtype': 'm.text', + 'format': 'org.matrix.custom.html', + 'formatted_body': + 'This is a first text example message' + }, + 'type': 'm.room.message', + 'event_id': '143274597443PhrSn:example.org', 'room_id': '!5345234234:example.com', 'sender': '@example:example.org', 'origin_server_ts': 1432735824653, 'unsigned': {'age': 1234} - }, - ] + } + ], + 'prev_batch': 't_1234a' }, 'state': { 'events': [ @@ -940,7 +1016,8 @@ class FakeMatrixApi extends BaseClient { 'state_key': '' }, ] - } + }, + 'prev_batch': 't_1234b' }, }, } @@ -1518,6 +1595,8 @@ class FakeMatrixApi extends BaseClient { (var req) => messagesResponseFutureEnd, '/client/v3/rooms/!1234%3Aexample.com/messages?from=t789&dir=f&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D': (var req) => messagesResponseFutureEnd, + '/client/v3/rooms/!5345234234%3Aexample.com/messages?from=t_1234a&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D': + (var req) => archivesMessageResponse, '/client/versions': (var req) => { 'versions': [ 'v1.1', diff --git a/test/room_archived_test.dart b/test/room_archived_test.dart new file mode 100644 index 00000000..c99af5ab --- /dev/null +++ b/test/room_archived_test.dart @@ -0,0 +1,138 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2022 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 'package:olm/olm.dart' as olm; +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'fake_client.dart'; + +void main() { + group('Timeline', () { + Logs().level = Level.error; + var olmEnabled = true; + + final insertList = []; + + late Client client; + + setUp(() async { + try { + await olm.init(); + olm.get_library_version(); + } catch (e) { + olmEnabled = false; + Logs().w('[LibOlm] Failed to load LibOlm', e); + } + Logs().i('[LibOlm] Enabled: $olmEnabled'); + + client = await getClient(); + client.sendMessageTimeoutSeconds = 5; + + await client.abortSync(); + insertList.clear(); + }); + + tearDown(() => client.dispose().onError((e, s) {})); + + test('archive room not loaded', () async { + final archiveRoom = + client.getArchiveRoomFromCache('!5345234234:example.com'); + expect(archiveRoom, null); + }); + + test('get archive', () async { + final archive = await client.loadArchiveWithTimeline(); + + expect(archive.length, 2); + expect(client.rooms.length, 2); + expect(archive[0].room.id, '!5345234234:example.com'); + expect(archive[0].room.membership, Membership.leave); + expect(archive[0].room.name, 'The room name'); + expect(archive[0].room.lastEvent?.body, + 'This is a second text example message'); + expect(archive[0].room.roomAccountData.length, 1); + expect(archive[1].room.id, '!5345234235:example.com'); + expect(archive[1].room.membership, Membership.leave); + expect(archive[1].room.name, 'The room name 2'); + + final archiveRoom = + client.getArchiveRoomFromCache('!5345234234:example.com'); + expect(archiveRoom != null, true); + expect(archiveRoom!.timeline.events.length, 2); + }); + + test('request history', () async { + await client.loadArchiveWithTimeline(); + final archiveRoom = client.getRoomById('!5345234234:example.com'); + expect(archiveRoom != null, true); + + final timeline = await archiveRoom!.getTimeline(onInsert: insertList.add); + + expect(timeline.events.length, 2); + expect(timeline.events[0].eventId, '143274597443PhrSn:example.org'); + expect(timeline.events[1].eventId, '143274597446PhrSn:example.org'); + + await timeline.requestHistory(); + + expect(timeline.events.length, 5); + expect(timeline.events[0].eventId, '143274597443PhrSn:example.org'); + expect(timeline.events[1].eventId, '143274597446PhrSn:example.org'); + expect(timeline.events[2].eventId, '3143273582443PhrSn:example.org'); + expect(timeline.events[3].eventId, '2143273582443PhrSn:example.org'); + expect(timeline.events[4].eventId, '1143273582466PhrSn:example.org'); + expect(insertList.length, 3); + }); + + test('expect database to be empty', () async { + await client.loadArchiveWithTimeline(); + final archiveRoom = client.getRoomById('!5345234234:example.com'); + expect(archiveRoom != null, true); + + final eventsFromStore = await client.database?.getEventList( + archiveRoom!, + start: 0, + limit: Room.defaultHistoryCount, + ); + expect(eventsFromStore?.isEmpty, true); + }); + + test('discard room from archives when membership change', () async { + await client.loadArchiveWithTimeline(); + expect(client.getArchiveRoomFromCache('!5345234235:example.com') != null, + true); + await client.handleSync(SyncUpdate( + nextBatch: 't_456', + rooms: RoomsUpdate( + invite: {'!5345234235:example.com': InvitedRoomUpdate()}))); + expect(client.getArchiveRoomFromCache('!5345234235:example.com'), null); + }); + + test('clear archive', () async { + await client.loadArchiveWithTimeline(); + client.clearArchivesFromCache(); + expect(client.getArchiveRoomFromCache('!5345234234:example.com'), null); + }); + + test('logout', () async { + await client.logout(); + }); + }); +}