From 9b0e9b7c5669ff455bba633db9fbba029e0e41f0 Mon Sep 17 00:00:00 2001 From: td Date: Tue, 16 Sep 2025 10:52:22 +0200 Subject: [PATCH 01/15] fix: (BREAKING CHANGE) remove only your device call membership if room is not msc3757 --- lib/src/voip/group_call_session.dart | 1 - .../voip/utils/famedly_call_extension.dart | 52 ++++++++++++++++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/src/voip/group_call_session.dart b/lib/src/voip/group_call_session.dart index 545ba8e7..48e13bfd 100644 --- a/lib/src/voip/group_call_session.dart +++ b/lib/src/voip/group_call_session.dart @@ -197,7 +197,6 @@ class GroupCallSession { } return room.removeFamedlyCallMemberEvent( groupCallId, - client.deviceID!, voip, application: application, scope: scope, diff --git a/lib/src/voip/utils/famedly_call_extension.dart b/lib/src/voip/utils/famedly_call_extension.dart index 354d691f..4e4c9838 100644 --- a/lib/src/voip/utils/famedly_call_extension.dart +++ b/lib/src/voip/utils/famedly_call_extension.dart @@ -121,12 +121,14 @@ extension FamedlyCallMemberEventsExtension on Room { await setFamedlyCallMemberEvent( newContent, callMembership.voip, + callMembership.callId, + application: callMembership.application, + scope: callMembership.scope, ); } Future removeFamedlyCallMemberEvent( String groupCallId, - String deviceId, VoIP voip, { String? application = 'm.call', String? scope = 'm.room', @@ -140,7 +142,7 @@ extension FamedlyCallMemberEventsExtension on Room { ownMemberships.removeWhere( (mem) => mem.callId == groupCallId && - mem.deviceId == deviceId && + mem.deviceId == client.deviceID! && mem.application == application && mem.scope == scope, ); @@ -148,7 +150,13 @@ extension FamedlyCallMemberEventsExtension on Room { final newContent = { 'memberships': List.from(ownMemberships.map((e) => e.toJson())), }; - await setFamedlyCallMemberEvent(newContent, voip); + await setFamedlyCallMemberEvent( + newContent, + voip, + groupCallId, + application: application, + scope: scope, + ); _restartDelayedLeaveEventTimer?.cancel(); if (_delayedLeaveEventId != null) { @@ -163,7 +171,10 @@ extension FamedlyCallMemberEventsExtension on Room { Future setFamedlyCallMemberEvent( Map newContent, VoIP voip, - ) async { + String groupCallId, { + String? application = 'm.call', + String? scope = 'm.room', + }) async { if (canJoinGroupCall) { final stateKey = (roomVersion?.contains('msc3757') ?? false) ? '${client.userID!}_${client.deviceID!}' @@ -175,7 +186,7 @@ extension FamedlyCallMemberEventsExtension on Room { /// can use delayed events and haven't used it yet if (useDelayedEvents && _delayedLeaveEventId == null) { - // get existing ones + // get existing ones and cancel them final List alreadyScheduledEvents = []; String? nextBatch; final sEvents = await client.getScheduledDelayedEvents(); @@ -200,14 +211,39 @@ extension FamedlyCallMemberEventsExtension on Room { ); } + Map newContent; + if (roomVersion?.contains('msc3757') ?? false) { + // scoped to deviceIds so clear the whole mems list + newContent = { + 'memberships': [], + }; + } else { + // only clear our own deviceId + final ownMemberships = getCallMembershipsForUser( + client.userID!, + client.deviceID!, + voip, + ); + + ownMemberships.removeWhere( + (mem) => + mem.callId == groupCallId && + mem.deviceId == client.deviceID! && + mem.application == application && + mem.scope == scope, + ); + + newContent = { + 'memberships': List.from(ownMemberships.map((e) => e.toJson())), + }; + } + _delayedLeaveEventId = await client.setRoomStateWithKeyWithDelay( id, EventTypes.GroupCallMember, stateKey, voip.timeouts!.delayedEventApplyLeave.inMilliseconds, - { - 'memberships': [], - }, + newContent, ); _restartDelayedLeaveEventTimer = Timer.periodic( From 42196795f165e3acbfe0ee3a6de1f7b298d55862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 17 Sep 2025 10:16:58 +0200 Subject: [PATCH 02/15] feat: Add deleteDeviceDisplayName() method to matrix API --- lib/matrix_api_lite/matrix_api.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/matrix_api_lite/matrix_api.dart b/lib/matrix_api_lite/matrix_api.dart index 81023e13..028a88be 100644 --- a/lib/matrix_api_lite/matrix_api.dart +++ b/lib/matrix_api_lite/matrix_api.dart @@ -184,6 +184,17 @@ class MatrixApi extends Api { return; } + /// Variant of updateDevice operation that deletes the device displayname by + /// setting `display_name: null`. + Future deleteDeviceDisplayName(String deviceId) async { + await request( + RequestType.PUT, + '/client/v3/devices/${Uri.encodeComponent(deviceId)}', + data: {'display_name': null}, + ); + return; + } + /// This API provides credentials for the client to use when initiating /// calls. @override From 8a4eda5201a969da6d261cc028bc2110bfba4695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 10 Sep 2025 13:55:21 +0200 Subject: [PATCH 03/15] feat: Auto refresh last event after limited timeline This adds a new behavior on sync that the app automatically fetches the last event from server after we receive a limited timeline and the lastEvent has not changed. --- lib/fake_matrix_api.dart | 22 ++++++++++++++++++ lib/src/client.dart | 17 +++++++------- lib/src/room.dart | 49 ++++++++++++++++++++++++++++++++++++++++ test/room_test.dart | 23 +++++++++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index f3281824..a37b70a6 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -1740,6 +1740,28 @@ class FakeMatrixApi extends BaseClient { 'origin_server_ts': 1432735824653, 'unsigned': {'age': 1234}, }, + '/client/v3/rooms/!localpart%3Aserver.abc/messages?dir=b&limit=1&filter=%7B%22types%22%3A%5B%22m.room.message%22%2C%22m.room.encrypted%22%2C%22m.sticker%22%2C%22m.call.invite%22%2C%22m.call.answer%22%2C%22m.call.reject%22%2C%22m.call.hangup%22%2C%22com.famedly.call.member%22%5D%7D': + (var req) => { + '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': '!1234:example.com', + 'sender': '@example:example.org', + 'origin_server_ts': 1432735824653, + 'unsigned': {'age': 1234}, + }, + ], + }, '/client/v3/rooms/new_room_id/messages?from=emptyHistoryResponse&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D': (var req) => emptyHistoryResponse, '/client/v3/rooms/new_room_id/messages?from=1&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D': diff --git a/lib/src/client.dart b/lib/src/client.dart index 22a7b922..a2e493e0 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2632,13 +2632,15 @@ class Client extends MatrixApi { final id = entry.key; final syncRoomUpdate = entry.value; + final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate); + // Is the timeline limited? Then all previous messages should be // removed from the database! if (syncRoomUpdate is JoinedRoomUpdate && syncRoomUpdate.timeline?.limited == true) { await database.deleteTimelineForRoom(id); + room.lastEvent = null; } - final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate); final timelineUpdateType = direction != null ? (direction == Direction.b @@ -2739,6 +2741,12 @@ class Client extends MatrixApi { continue; } await database.storeRoomUpdate(id, syncRoomUpdate, room.lastEvent, this); + + if (syncRoomUpdate is JoinedRoomUpdate && + syncRoomUpdate.timeline?.limited == true && + room.lastEvent == null) { + runInRoot(room.refreshLastEvent); + } } } @@ -3035,13 +3043,6 @@ class Client extends MatrixApi { } if (type != EventUpdateType.timeline) break; - // If last event is null or not a valid room preview event anyway, - // just use this: - if (room.lastEvent == null) { - room.lastEvent = event; - break; - } - // Is this event redacting the last event? if (event.type == EventTypes.Redaction && ({ diff --git a/lib/src/room.dart b/lib/src/room.dart index 86ec752d..91cd80e7 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -377,6 +377,55 @@ class Room { Event? lastEvent; + /// Fetches the most recent event in the timeline from the server to have + /// a valid preview after receiving a limited timeline from the sync. Will + /// be triggered by the sync loop on demand. + Future refreshLastEvent() async { + if (membership != Membership.join) return null; + + final filter = StateFilter(types: client.roomPreviewLastEvents.toList()); + final result = await client.getRoomEvents( + id, + Direction.b, + limit: 1, + filter: jsonEncode(filter.toJson()), + ); + final matrixEvent = result.chunk.firstOrNull; + if (matrixEvent == null) { + Logs().d('No last event found for room', id); + return null; + } + var event = Event.fromMatrixEvent( + matrixEvent, + this, + status: EventStatus.synced, + ); + if (event.type == EventTypes.Encrypted) { + final encryption = client.encryption; + if (encryption != null) { + event = await encryption.decryptRoomEvent(event); + } + } + lastEvent = event; + + Logs().d('Refreshed last event for room', id); + + // Trigger sync handling so that lastEvent gets stored and room list gets + // updated. + await _handleFakeSync( + SyncUpdate( + nextBatch: '', + rooms: RoomsUpdate( + join: { + id: JoinedRoomUpdate(timeline: TimelineUpdate(limited: false)), + }, + ), + ), + ); + + return event; + } + void setEphemeral(BasicEvent ephemeral) { ephemerals[ephemeral.type] = ephemeral; if (ephemeral.type == 'm.typing') { diff --git a/test/room_test.dart b/test/room_test.dart index e6963a4c..01fe4d20 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -883,6 +883,29 @@ void main() { expect(timeline.events.length, 17); }); + test('Refresh last event', () async { + expect(room.lastEvent?.eventId, '12'); + final lastEventUpdate = + room.client.onSync.stream.firstWhere((u) => u.nextBatch.isEmpty); + await room.client.handleSync( + SyncUpdate( + nextBatch: 'abcd', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [], + limited: true, + ), + ), + }, + ), + ), + ); + await lastEventUpdate; + expect(room.lastEvent?.eventId, '3143273582443PhrSn:example.org'); + }); + test('isFederated', () { expect(room.isFederated, true); room.setState( From bc8164a48752c6d3e9a6f14ce30c9560a7cb07e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Fri, 12 Sep 2025 16:01:05 +0200 Subject: [PATCH 04/15] refactor: Return a better default for lastEventReceivedTime --- lib/matrix_api_lite/model/event_types.dart | 4 ++ lib/src/client.dart | 18 ++++++-- lib/src/room.dart | 46 +++++++++++++++---- lib/src/utils/event_localizations.dart | 1 + .../utils/matrix_default_localizations.dart | 3 ++ lib/src/utils/matrix_localizations.dart | 2 + test/client_test.dart | 20 ++++---- 7 files changed, 71 insertions(+), 23 deletions(-) diff --git a/lib/matrix_api_lite/model/event_types.dart b/lib/matrix_api_lite/model/event_types.dart index 0e39bb5d..b99282ea 100644 --- a/lib/matrix_api_lite/model/event_types.dart +++ b/lib/matrix_api_lite/model/event_types.dart @@ -61,6 +61,10 @@ abstract class EventTypes { 'org.matrix.call.asserted_identity'; static const String Unknown = 'm.unknown'; + /// An internal event type indicating that the last event in the room for + /// a room list preview is currently being refreshed. + static const String refreshingLastEvent = 'com.famedly.refreshing_last_event'; + // To device event types static const String RoomKey = 'm.room_key'; static const String ForwardedRoomKey = 'm.forwarded_room_key'; diff --git a/lib/src/client.dart b/lib/src/client.dart index a2e493e0..5d0c0453 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2740,13 +2740,25 @@ class Client extends MatrixApi { Logs().d('Skip store LeftRoomUpdate for unknown room', id); continue; } - await database.storeRoomUpdate(id, syncRoomUpdate, room.lastEvent, this); if (syncRoomUpdate is JoinedRoomUpdate && - syncRoomUpdate.timeline?.limited == true && - room.lastEvent == null) { + (room.lastEvent?.type == EventTypes.refreshingLastEvent || + (syncRoomUpdate.timeline?.limited == true && + room.lastEvent == null))) { + room.lastEvent = Event( + originServerTs: + syncRoomUpdate.timeline?.events?.firstOrNull?.originServerTs ?? + DateTime.now(), + type: EventTypes.refreshingLastEvent, + content: {'body': 'Refreshing last event...'}, + room: room, + eventId: generateUniqueTransactionId(), + senderId: userID!, + ); runInRoot(room.refreshLastEvent); } + + await database.storeRoomUpdate(id, syncRoomUpdate, room.lastEvent, this); } } diff --git a/lib/src/room.dart b/lib/src/room.dart index 91cd80e7..dd7c6825 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -379,19 +379,37 @@ class Room { /// Fetches the most recent event in the timeline from the server to have /// a valid preview after receiving a limited timeline from the sync. Will - /// be triggered by the sync loop on demand. - Future refreshLastEvent() async { + /// be triggered by the sync loop on demand. Multiple requests will be + /// combined to the same request. + Future refreshLastEvent({ + timeout = const Duration(seconds: 30), + }) async { + final lastEvent = _refreshingLastEvent ??= _refreshLastEvent(); + _refreshingLastEvent = null; + return lastEvent; + } + + Future? _refreshingLastEvent; + + Future _refreshLastEvent({ + timeout = const Duration(seconds: 30), + }) async { if (membership != Membership.join) return null; final filter = StateFilter(types: client.roomPreviewLastEvents.toList()); - final result = await client.getRoomEvents( - id, - Direction.b, - limit: 1, - filter: jsonEncode(filter.toJson()), - ); + final result = await client + .getRoomEvents( + id, + Direction.b, + limit: 1, + filter: jsonEncode(filter.toJson()), + ) + .timeout(timeout); final matrixEvent = result.chunk.firstOrNull; if (matrixEvent == null) { + if (lastEvent?.type == EventTypes.refreshingLastEvent) { + lastEvent = null; + } Logs().d('No last event found for room', id); return null; } @@ -492,8 +510,16 @@ class Room { String get displayname => getLocalizedDisplayname(); /// When was the last event received. - DateTime get latestEventReceivedTime => - lastEvent?.originServerTs ?? DateTime.now(); + DateTime get latestEventReceivedTime { + final lastEventTime = lastEvent?.originServerTs; + if (lastEventTime != null) return lastEventTime; + + if (membership == Membership.invite) return DateTime.now(); + final createEvent = getState(EventTypes.RoomCreate); + if (createEvent is MatrixEvent) return createEvent.originServerTs; + + return DateTime(0); + } /// Call the Matrix API to change the name of this room. Returns the event ID of the /// new m.room.name event. diff --git a/lib/src/utils/event_localizations.dart b/lib/src/utils/event_localizations.dart index a4fb76d8..4f19972e 100644 --- a/lib/src/utils/event_localizations.dart +++ b/lib/src/utils/event_localizations.dart @@ -299,5 +299,6 @@ abstract class EventLocalizations { ?.tryGet('key') ?? body, ), + EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent, }; } diff --git a/lib/src/utils/matrix_default_localizations.dart b/lib/src/utils/matrix_default_localizations.dart index 9f5efc58..c9076777 100644 --- a/lib/src/utils/matrix_default_localizations.dart +++ b/lib/src/utils/matrix_default_localizations.dart @@ -318,4 +318,7 @@ class MatrixDefaultLocalizations extends MatrixLocalizations { : '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')} '; return '$senderName: ${durationString}Voice message'; } + + @override + String get refreshingLastEvent => 'Refreshing last event...'; } diff --git a/lib/src/utils/matrix_localizations.dart b/lib/src/utils/matrix_localizations.dart index 921747f0..37c9672f 100644 --- a/lib/src/utils/matrix_localizations.dart +++ b/lib/src/utils/matrix_localizations.dart @@ -62,6 +62,8 @@ abstract class MatrixLocalizations { String get cancelledSend; + String get refreshingLastEvent; + String youInvitedBy(String senderName); String invitedBy(String senderName); diff --git a/test/client_test.dart b/test/client_test.dart index e38378a9..19773a1e 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -201,26 +201,26 @@ void main() { matrix.getDirectChatFromUserId('@bob:example.com'), '!726s6s6q:example.com', ); - expect(matrix.rooms[2].directChatMatrixID, '@bob:example.com'); + expect(matrix.rooms[1].directChatMatrixID, '@bob:example.com'); expect(matrix.directChats, matrix.accountData['m.direct']?.content); // ignore: deprecated_member_use_from_same_package expect(matrix.presences.length, 1); - expect(matrix.rooms[2].ephemerals.length, 2); - expect(matrix.rooms[2].typingUsers.length, 1); - expect(matrix.rooms[2].typingUsers[0].id, '@alice:example.com'); - expect(matrix.rooms[2].roomAccountData.length, 3); - expect(matrix.rooms[2].encrypted, true); + expect(matrix.rooms[1].ephemerals.length, 2); + expect(matrix.rooms[1].typingUsers.length, 1); + expect(matrix.rooms[1].typingUsers[0].id, '@alice:example.com'); + expect(matrix.rooms[1].roomAccountData.length, 3); + expect(matrix.rooms[1].encrypted, true); expect( - matrix.rooms[2].encryptionAlgorithm, + matrix.rooms[1].encryptionAlgorithm, Client.supportedGroupEncryptionAlgorithms.first, ); expect( matrix - .rooms[2].receiptState.global.otherUsers['@alice:example.com']?.ts, + .rooms[1].receiptState.global.otherUsers['@alice:example.com']?.ts, 1436451550453, ); expect( - matrix.rooms[2].receiptState.global.otherUsers['@alice:example.com'] + matrix.rooms[1].receiptState.global.otherUsers['@alice:example.com'] ?.eventId, '\$7365636s6r6432:example.com', ); @@ -231,7 +231,7 @@ void main() { expect(inviteRoom.states[EventTypes.RoomMember]?.length, 1); expect(matrix.rooms.length, 3); expect( - matrix.rooms[2].canonicalAlias, + matrix.rooms[1].canonicalAlias, "#famedlyContactDiscovery:${matrix.userID!.split(":")[1]}", ); expect( From 77fb2b29e7ed86ad359576673f20e8833d71fd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Fri, 12 Sep 2025 10:13:57 +0200 Subject: [PATCH 05/15] feat: Implement get mentions from event content --- lib/src/event.dart | 11 +++++++++++ test/event_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/lib/src/event.dart b/lib/src/event.dart index 43f4d27c..3cb2da50 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -1180,6 +1180,17 @@ class Event extends MatrixEvent { (fileSendingStatus) => fileSendingStatus.name == status, ); } + + /// Returns the mentioned userIds and wether the event includes an @room + /// mention. This is only determined by the `m.mention` object in the event + /// content. + ({List userIds, bool room}) get mentions { + final mentionsMap = content.tryGetMap('m.mentions'); + return ( + userIds: mentionsMap?.tryGetList('user_ids') ?? [], + room: mentionsMap?.tryGet('room') ?? false, + ); + } } enum FileSendingStatus { diff --git a/test/event_test.dart b/test/event_test.dart index 7f244095..a5166ce9 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -2951,5 +2951,28 @@ void main() async { Timeline(room: room, chunk: TimelineChunk(events: [targetEvent])); expect(await event.getReplyEvent(timeline), targetEvent); }); + test('getMentions', () { + final event = Event.fromJson( + { + 'content': { + 'msgtype': 'text', + 'body': 'Hello world @alice:matrix.org', + 'm.mentions': { + 'user_ids': ['@alice:matrix.org'], + 'room': false, + } + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': room.id, + 'sender': '@example:example.org', + 'type': 'm.room.message', + 'unsigned': {'age': 1234}, + }, + room, + ); + expect(event.mentions.userIds, ['@alice:matrix.org']); + expect(event.mentions.room, false); + }); }); } From 98dd75d822e6f9c30ab8e2775e0e9651100f66ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Fri, 12 Sep 2025 11:35:32 +0200 Subject: [PATCH 06/15] feat: Set m.mention field when sending text event --- lib/src/room.dart | 36 +++++++++++++++++++++++++++++ test/commands_test.dart | 3 +++ test/event_test.dart | 2 +- test/room_test.dart | 50 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/lib/src/room.dart b/lib/src/room.dart index dd7c6825..e6e92327 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -710,6 +710,7 @@ class Room { String? threadRootEventId, String? threadLastEventId, StringBuffer? commandStdout, + bool addMentions = true, }) { if (parseCommands) { return client.parseAndRunCommand( @@ -727,6 +728,41 @@ class Room { 'msgtype': msgtype, 'body': message, }; + + if (addMentions) { + var potentialMentions = message + .split('@') + .map( + (text) => text.startsWith('[') + ? '@${text.split(']').first}]' + : '@${text.split(RegExp(r'\s+')).first}', + ) + .toList() + ..removeAt(0); + + final hasRoomMention = potentialMentions.remove('@room'); + + potentialMentions = potentialMentions + .map( + (mention) => + mention.isValidMatrixId ? mention : getMention(mention), + ) + .nonNulls + .toSet() // Deduplicate + .toList() + ..remove(client.userID); // We should never mention ourself. + + // https://spec.matrix.org/v1.7/client-server-api/#mentioning-the-replied-to-user + if (inReplyTo != null) potentialMentions.add(inReplyTo.senderId); + + if (hasRoomMention || potentialMentions.isNotEmpty) { + event['m.mentions'] = { + if (hasRoomMention) 'room': true, + if (potentialMentions.isNotEmpty) 'user_ids': potentialMentions, + }; + } + } + if (parseMarkdown) { final html = markdown( event['body'], diff --git a/test/commands_test.dart b/test/commands_test.dart index 238c00cb..6efc34e6 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -243,6 +243,9 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': '> <@test:fakeServer.notExisting> reply\n\nreply', + 'm.mentions': { + 'user_ids': ['@test:fakeServer.notExisting'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @test:fakeServer.notExisting
reply
reply', diff --git a/test/event_test.dart b/test/event_test.dart index a5166ce9..e3edae90 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -2960,7 +2960,7 @@ void main() async { 'm.mentions': { 'user_ids': ['@alice:matrix.org'], 'room': false, - } + }, }, 'event_id': '\$143273582443PhrSn:example.org', 'origin_server_ts': 1432735824653, diff --git a/test/room_test.dart b/test/room_test.dart index 01fe4d20..175b3459 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -1038,6 +1038,36 @@ void main() { }); }); + test('sendEvent with room mention', () async { + FakeMatrixApi.calledEndpoints.clear(); + final resp = await room.sendTextEvent( + 'Hello world @room', + txid: 'testtxid', + addMentions: true, + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], {'room': true}); + }); + + test('sendEvent with user mention', () async { + FakeMatrixApi.calledEndpoints.clear(); + final resp = await room.sendTextEvent( + 'Hello world @[Alice Margatroid]', + addMentions: true, + txid: 'testtxid', + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], { + 'user_ids': ['@alice:matrix.org'], + }); + }); + test('send edit', () async { FakeMatrixApi.calledEndpoints.clear(); final dynamic resp = await room.sendTextEvent( @@ -1089,6 +1119,9 @@ void main() { expect(content, { 'body': '> <@alice:example.org> Blah\n\nHello world', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
Blah
Hello world', @@ -1125,6 +1158,9 @@ void main() { 'body': '> <@alice:example.org> Blah\n> beep\n\nHello world\nfox', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
<b>Blah</b>
beep
Hello world
fox', @@ -1162,6 +1198,9 @@ void main() { expect(content, { 'body': '> <@alice:example.org> plaintext meow\n\nHello world', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
meow
Hello world', @@ -1197,6 +1236,9 @@ void main() { expect(content, { 'body': '> <@alice:example.org> Hey @\u{200b}room\n\nHello world', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
Hey @room
Hello world', @@ -1214,6 +1256,9 @@ void main() { 'content': { 'body': '> <@alice:example.org> Hey\n\nHello world', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
Hey
Hello world', @@ -1238,6 +1283,9 @@ void main() { expect(content, { 'body': '> <@alice:example.org> Hello world\n\nFox', 'msgtype': 'm.text', + 'm.mentions': { + 'user_ids': ['@alice:example.org'], + }, 'format': 'org.matrix.custom.html', 'formatted_body': '
In reply to @alice:example.org
Hello world
Fox', @@ -1296,7 +1344,7 @@ void main() { test('sendFileEvent', () async { final testFile = MatrixFile(bytes: Uint8List(0), name: 'file.jpeg'); final resp = await room.sendFileEvent(testFile, txid: 'testtxid'); - expect(resp.toString(), '\$event10'); + expect(resp.toString(), '\$event12'); }); test('pushRuleState', () async { From 6b73fc635480d778f2db93e34f3f97bbbbc87c28 Mon Sep 17 00:00:00 2001 From: Karthikeyan S Date: Fri, 26 Sep 2025 14:08:01 +0200 Subject: [PATCH 07/15] refactor: migrate to web and js_interop pkgs --- lib/encryption/utils/key_verification.dart | 7 +- lib/matrix.dart | 4 +- lib/matrix_api_lite/utils/logs.dart | 2 +- lib/matrix_api_lite/utils/print_logs_web.dart | 16 +- lib/src/database/indexeddb_box.dart | 319 ++++++++++++++---- lib/src/database/matrix_sdk_database.dart | 8 +- lib/src/utils/crypto/crypto.dart | 2 +- .../native_implementations_web_worker.dart | 23 +- lib/src/utils/web_worker/web_worker.dart | 137 ++++---- lib/src/voip/utils/wrapped_media_stream.dart | 2 +- pubspec.yaml | 2 + test/box_test.dart | 6 +- 12 files changed, 364 insertions(+), 164 deletions(-) diff --git a/lib/encryption/utils/key_verification.dart b/lib/encryption/utils/key_verification.dart index 35cc121f..33963621 100644 --- a/lib/encryption/utils/key_verification.dart +++ b/lib/encryption/utils/key_verification.dart @@ -748,9 +748,10 @@ class KeyVerification { // no need to request cache, we already have it return; } - // ignore: unawaited_futures - encryption.ssss - .maybeRequestAll(_verifiedDevices.whereType().toList()); + unawaited( + encryption.ssss + .maybeRequestAll(_verifiedDevices.whereType().toList()), + ); if (requestInterval.length <= i) { return; } diff --git a/lib/matrix.dart b/lib/matrix.dart index da5aadeb..8ad93add 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -84,7 +84,7 @@ export 'msc_extensions/extension_timeline_export/timeline_export.dart'; export 'msc_extensions/msc_4140_delayed_events/api.dart'; export 'src/utils/web_worker/web_worker_stub.dart' - if (dart.library.html) 'src/utils/web_worker/web_worker.dart'; + if (dart.library.js_interop) 'src/utils/web_worker/web_worker.dart'; export 'src/utils/web_worker/native_implementations_web_worker_stub.dart' - if (dart.library.html) 'src/utils/web_worker/native_implementations_web_worker.dart'; + if (dart.library.js_interop) 'src/utils/web_worker/native_implementations_web_worker.dart'; diff --git a/lib/matrix_api_lite/utils/logs.dart b/lib/matrix_api_lite/utils/logs.dart index 495194cd..ff266686 100644 --- a/lib/matrix_api_lite/utils/logs.dart +++ b/lib/matrix_api_lite/utils/logs.dart @@ -22,7 +22,7 @@ */ import 'package:matrix/matrix_api_lite/utils/print_logs_native.dart' - if (dart.library.html) 'print_logs_web.dart'; + if (dart.library.js_interop) 'print_logs_web.dart'; enum Level { wtf, diff --git a/lib/matrix_api_lite/utils/print_logs_web.dart b/lib/matrix_api_lite/utils/print_logs_web.dart index b24ffd4b..c1e83379 100644 --- a/lib/matrix_api_lite/utils/print_logs_web.dart +++ b/lib/matrix_api_lite/utils/print_logs_web.dart @@ -1,4 +1,6 @@ -import 'dart:html'; +import 'dart:js_interop'; + +import 'package:web/web.dart'; import 'package:matrix/matrix_api_lite.dart'; @@ -13,22 +15,22 @@ extension PrintLogs on LogEvent { } switch (level) { case Level.wtf: - window.console.error('!!!CRITICAL!!! $logsStr'); + console.error('!!!CRITICAL!!! $logsStr'.toJS); break; case Level.error: - window.console.error(logsStr); + console.error(logsStr.toJS); break; case Level.warning: - window.console.warn(logsStr); + console.warn(logsStr.toJS); break; case Level.info: - window.console.info(logsStr); + console.info(logsStr.toJS); break; case Level.debug: - window.console.debug(logsStr); + console.debug(logsStr.toJS); break; case Level.verbose: - window.console.log(logsStr); + console.log(logsStr.toJS); break; } } diff --git a/lib/src/database/indexeddb_box.dart b/lib/src/database/indexeddb_box.dart index 080b1ad5..5726cb0e 100644 --- a/lib/src/database/indexeddb_box.dart +++ b/lib/src/database/indexeddb_box.dart @@ -1,13 +1,15 @@ import 'dart:async'; -import 'dart:html'; -import 'dart:indexed_db'; +import 'dart:js_interop'; +import 'package:web/web.dart'; + +import 'package:matrix/matrix_api_lite/utils/logs.dart'; import 'package:matrix/src/database/zone_transaction_mixin.dart'; /// Key-Value store abstraction over IndexedDB so that the sdk database can use /// a single interface for all platforms. API is inspired by Hive. class BoxCollection with ZoneTransactionMixin { - final Database _db; + final IDBDatabase _db; final Set boxNames; final String name; @@ -18,23 +20,45 @@ class BoxCollection with ZoneTransactionMixin { Set boxNames, { Object? sqfliteDatabase, Object? sqfliteFactory, - IdbFactory? idbFactory, + IDBFactory? idbFactory, int version = 1, }) async { - idbFactory ??= window.indexedDB!; - final db = await idbFactory.open( - name, - version: version, - onUpgradeNeeded: (VersionChangeEvent event) { - final db = event.target.result; - for (final name in boxNames) { - if (db.objectStoreNames.contains(name)) continue; + idbFactory ??= window.indexedDB; + final dbOpenCompleter = Completer(); + final request = idbFactory.open(name, version); - db.createObjectStore(name, autoIncrement: true); - } - }, - ); - return BoxCollection(db, boxNames, name); + request.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] Error loading database - ${request.error?.toString()}', + ); + dbOpenCompleter.completeError( + 'Error loading database - ${request.error?.toString()}', + ); + }.toJS; + + request.onupgradeneeded = (IDBVersionChangeEvent event) { + final db = (event.target! as IDBOpenDBRequest).result as IDBDatabase; + + db.onerror = (Event event) { + Logs().e('[IndexedDBBox] [onupgradeneeded] Error loading database'); + dbOpenCompleter + .completeError('Error loading database onupgradeneeded.'); + }.toJS; + + for (final name in boxNames) { + if (db.objectStoreNames.contains(name)) continue; + db.createObjectStore( + name, + IDBObjectStoreParameters(autoIncrement: true), + ); + } + }.toJS; + + request.onsuccess = (Event event) { + final db = request.result as IDBDatabase; + dbOpenCompleter.complete(BoxCollection(db, boxNames, name)); + }.toJS; + return dbOpenCompleter.future; } Box openBox(String name) { @@ -44,7 +68,7 @@ class BoxCollection with ZoneTransactionMixin { return Box(name, this); } - List Function(Transaction txn)>? _txnCache; + List Function(IDBTransaction txn)>? _txnCache; Future transaction( Future Function() action, { @@ -52,15 +76,18 @@ class BoxCollection with ZoneTransactionMixin { bool readOnly = false, }) => zoneTransaction(() async { - boxNames ??= _db.objectStoreNames!.toList(); final txnCache = _txnCache = []; await action(); final cache = - List Function(Transaction txn)>.from(txnCache); + List Function(IDBTransaction txn)>.from(txnCache); _txnCache = null; if (cache.isEmpty) return; - final txn = - _db.transaction(boxNames, readOnly ? 'readonly' : 'readwrite'); + + final transactionCompleter = Completer(); + final txn = _db.transaction( + boxNames?.jsify() ?? _db.objectStoreNames, + readOnly ? 'readonly' : 'readwrite', + ); for (final fun in cache) { // The IDB methods return a Future in Dart but must not be awaited in // order to have an actual transaction. They must only be performed and @@ -69,16 +96,54 @@ class BoxCollection with ZoneTransactionMixin { // https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction unawaited(fun(txn)); } - await txn.completed; - return; + + txn.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [transaction] Error - ${txn.error?.toString()}', + ); + transactionCompleter.completeError( + 'Transaction not completed due to an error - ${txn.error?.toString()}' + .toJS, + ); + }.toJS; + + txn.oncomplete = (Event event) { + transactionCompleter.complete(); + }.toJS; + return transactionCompleter.future; }); Future clear() async { - final txn = _db.transaction(boxNames.toList(), 'readwrite'); + final transactionCompleter = Completer(); + final txn = _db.transaction(boxNames.toList().jsify()!, 'readwrite'); for (final name in boxNames) { - unawaited(txn.objectStore(name).clear()); + final objStoreClearCompleter = Completer(); + final request = txn.objectStore(name).clear(); + request.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [clear] Object store clear error - ${request.error?.toString()}', + ); + objStoreClearCompleter.completeError( + 'Object store clear not completed due to an error - ${request.error?.toString()}' + .toJS, + ); + }.toJS; + request.onsuccess = (Event event) { + objStoreClearCompleter.complete(); + }.toJS; + unawaited(objStoreClearCompleter.future); } - await txn.completed; + txn.onerror = (Event event) { + Logs().e('[IndexedDBBox] [clear] Error - ${txn.error?.toString()}'); + transactionCompleter.completeError( + 'DB clear transaction not completed due to an error - ${txn.error?.toString()}' + .toJS, + ); + }.toJS; + txn.oncomplete = (Event event) { + transactionCompleter.complete(); + }.toJS; + return transactionCompleter.future; } Future close() async { @@ -87,13 +152,24 @@ class BoxCollection with ZoneTransactionMixin { return zoneTransaction(() async => _db.close()); } - @Deprecated('use collection.deleteDatabase now') - static Future delete(String name, [dynamic factory]) => - (factory ?? window.indexedDB!).deleteDatabase(name); - Future deleteDatabase(String name, [dynamic factory]) async { await close(); - await (factory ?? window.indexedDB).deleteDatabase(name); + final deleteDatabaseCompleter = Completer(); + final request = + ((factory ?? window.indexedDB) as IDBFactory).deleteDatabase(name); + request.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [deleteDatabase] Error - ${request.error?.toString()}', + ); + deleteDatabaseCompleter.completeError( + 'Error deleting database - ${request.error?.toString()}'.toJS, + ); + }.toJS; + request.onsuccess = (Event event) { + Logs().i('[IndexedDBBox] [deleteDatabase] Database deleted.'); + deleteDatabaseCompleter.complete(); + }.toJS; + return deleteDatabaseCompleter.future; } } @@ -111,44 +187,109 @@ class Box { Box(this.name, this.boxCollection); - Future> getAllKeys([Transaction? txn]) async { + Future> getAllKeys([IDBTransaction? txn]) async { if (_quickAccessCachedKeys != null) return _quickAccessCachedKeys!.toList(); - txn ??= boxCollection._db.transaction(name, 'readonly'); + txn ??= boxCollection._db.transaction(name.toJS, 'readonly'); final store = txn.objectStore(name); - final request = store.getAllKeys(null); - await request.onSuccess.first; - final keys = request.result.cast(); + final getAllKeysCompleter = Completer(); + final request = store.getAllKeys(); + request.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [getAllKeys] Error - ${request.error?.toString()}', + ); + getAllKeysCompleter.completeError( + '[IndexedDBBox] [getAllKeys] Error - ${request.error?.toString()}'.toJS, + ); + }.toJS; + request.onsuccess = (Event event) { + getAllKeysCompleter.complete(); + }.toJS; + await getAllKeysCompleter.future; + final keys = (request.result?.dartify() as List?)?.cast() ?? []; _quickAccessCachedKeys = keys.toSet(); return keys; } - Future> getAllValues([Transaction? txn]) async { - txn ??= boxCollection._db.transaction(name, 'readonly'); + Future> getAllValues([IDBTransaction? txn]) async { + txn ??= boxCollection._db.transaction(name.toJS, 'readonly'); final store = txn.objectStore(name); final map = {}; - final cursorStream = store.openCursor(autoAdvance: true); - await for (final cursor in cursorStream) { - map[cursor.key as String] = _fromValue(cursor.value) as V; - } + + /// NOTE: This is a workaround to get the keys as [IDBObjectStore.getAll()] + /// only returns the values as a list. + /// And using the [IDBObjectStore.openCursor()] method is not working as expected. + final keys = await getAllKeys(txn); + + final getAllValuesCompleter = Completer(); + final getAllValuesRequest = store.getAll(); + getAllValuesRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [getAllValues] Error - ${getAllValuesRequest.error?.toString()}', + ); + getAllValuesCompleter.completeError( + '[IndexedDBBox] [getAllValues] Error - ${getAllValuesRequest.error?.toString()}' + .toJS, + ); + }.toJS; + getAllValuesRequest.onsuccess = (Event event) { + final values = getAllValuesRequest.result.dartify() as List; + for (int i = 0; i < values.length; i++) { + map[keys[i]] = _fromValue(values[i]) as V; + } + getAllValuesCompleter.complete(); + }.toJS; + await getAllValuesCompleter.future; return map; } - Future get(String key, [Transaction? txn]) async { + Future get(String key, [IDBTransaction? txn]) async { if (_quickAccessCache.containsKey(key)) return _quickAccessCache[key]; - txn ??= boxCollection._db.transaction(name, 'readonly'); + txn ??= boxCollection._db.transaction(name.toJS, 'readonly'); final store = txn.objectStore(name); - _quickAccessCache[key] = await store.getObject(key).then(_fromValue); + final getObjectRequest = store.get(key.toJS); + final getObjectCompleter = Completer(); + getObjectRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [get] Error - ${getObjectRequest.error?.toString()}', + ); + getObjectCompleter.completeError( + '[IndexedDBBox] [get] Error - ${getObjectRequest.error?.toString()}' + .toJS, + ); + }.toJS; + getObjectRequest.onsuccess = (Event event) { + getObjectCompleter.complete(); + }.toJS; + await getObjectCompleter.future; + _quickAccessCache[key] = _fromValue(getObjectRequest.result?.dartify()); return _quickAccessCache[key]; } - Future> getAll(List keys, [Transaction? txn]) async { + Future> getAll(List keys, [IDBTransaction? txn]) async { if (keys.every((key) => _quickAccessCache.containsKey(key))) { return keys.map((key) => _quickAccessCache[key]).toList(); } - txn ??= boxCollection._db.transaction(name, 'readonly'); + txn ??= boxCollection._db.transaction(name.toJS, 'readonly'); final store = txn.objectStore(name); final list = await Future.wait( - keys.map((key) => store.getObject(key).then(_fromValue)), + keys.map((key) async { + final getObjectRequest = store.get(key.toJS); + final getObjectCompleter = Completer(); + getObjectRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [getAll] Error at key $key - ${getObjectRequest.error?.toString()}', + ); + getObjectCompleter.completeError( + '[IndexedDBBox] [getAll] Error at key $key - ${getObjectRequest.error?.toString()}' + .toJS, + ); + }.toJS; + getObjectRequest.onsuccess = (Event event) { + getObjectCompleter.complete(); + }.toJS; + await getObjectCompleter.future; + return _fromValue(getObjectRequest.result?.dartify()); + }), ); for (var i = 0; i < keys.length; i++) { _quickAccessCache[keys[i]] = list[i]; @@ -156,7 +297,7 @@ class Box { return list; } - Future put(String key, V val, [Transaction? txn]) async { + Future put(String key, V val, [IDBTransaction? txn]) async { if (boxCollection._txnCache != null) { boxCollection._txnCache!.add((txn) => put(key, val, txn)); _quickAccessCache[key] = val; @@ -164,15 +305,28 @@ class Box { return; } - txn ??= boxCollection._db.transaction(name, 'readwrite'); + txn ??= boxCollection._db.transaction(name.toJS, 'readwrite'); final store = txn.objectStore(name); - await store.put(val as Object, key); + final putRequest = store.put(val.jsify(), key.toJS); + final putCompleter = Completer(); + putRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [put] Error - ${putRequest.error?.toString()}', + ); + putCompleter.completeError( + '[IndexedDBBox] [put] Error - ${putRequest.error?.toString()}'.toJS, + ); + }.toJS; + putRequest.onsuccess = (Event event) { + putCompleter.complete(); + }.toJS; + await putCompleter.future; _quickAccessCache[key] = val; _quickAccessCachedKeys?.add(key); return; } - Future delete(String key, [Transaction? txn]) async { + Future delete(String key, [IDBTransaction? txn]) async { if (boxCollection._txnCache != null) { boxCollection._txnCache!.add((txn) => delete(key, txn)); _quickAccessCache[key] = null; @@ -180,9 +334,23 @@ class Box { return; } - txn ??= boxCollection._db.transaction(name, 'readwrite'); + txn ??= boxCollection._db.transaction(name.toJS, 'readwrite'); final store = txn.objectStore(name); - await store.delete(key); + final deleteRequest = store.delete(key.toJS); + final deleteCompleter = Completer(); + deleteRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [delete] Error - ${deleteRequest.error?.toString()}', + ); + deleteCompleter.completeError( + '[IndexedDBBox] [delete] Error - ${deleteRequest.error?.toString()}' + .toJS, + ); + }.toJS; + deleteRequest.onsuccess = (Event event) { + deleteCompleter.complete(); + }.toJS; + await deleteCompleter.future; // Set to null instead remove() so that inside of transactions null is // returned. @@ -191,7 +359,7 @@ class Box { return; } - Future deleteAll(List keys, [Transaction? txn]) async { + Future deleteAll(List keys, [IDBTransaction? txn]) async { if (boxCollection._txnCache != null) { boxCollection._txnCache!.add((txn) => deleteAll(keys, txn)); for (final key in keys) { @@ -201,10 +369,24 @@ class Box { return; } - txn ??= boxCollection._db.transaction(name, 'readwrite'); + txn ??= boxCollection._db.transaction(name.toJS, 'readwrite'); final store = txn.objectStore(name); for (final key in keys) { - await store.delete(key); + final deleteRequest = store.delete(key.toJS); + final deleteCompleter = Completer(); + deleteRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [deleteAll] Error at key $key - ${deleteRequest.error?.toString()}', + ); + deleteCompleter.completeError( + '[IndexedDBBox] [deleteAll] Error at key $key - ${deleteRequest.error?.toString()}' + .toJS, + ); + }.toJS; + deleteRequest.onsuccess = (Event event) { + deleteCompleter.complete(); + }.toJS; + await deleteCompleter.future; _quickAccessCache[key] = null; _quickAccessCachedKeys?.remove(key); } @@ -216,15 +398,28 @@ class Box { _quickAccessCachedKeys = null; } - Future clear([Transaction? txn]) async { + Future clear([IDBTransaction? txn]) async { if (boxCollection._txnCache != null) { boxCollection._txnCache!.add((txn) => clear(txn)); } else { - txn ??= boxCollection._db.transaction(name, 'readwrite'); + txn ??= boxCollection._db.transaction(name.toJS, 'readwrite'); final store = txn.objectStore(name); - await store.clear(); + final clearRequest = store.clear(); + final clearCompleter = Completer(); + clearRequest.onerror = (Event event) { + Logs().e( + '[IndexedDBBox] [clear] Error - ${clearRequest.error?.toString()}', + ); + clearCompleter.completeError( + '[IndexedDBBox] [clear] Error - ${clearRequest.error?.toString()}' + .toJS, + ); + }.toJS; + clearRequest.onsuccess = (Event event) { + clearCompleter.complete(); + }.toJS; + await clearCompleter.future; } - clearQuickAccessCache(); } diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart index 942418b8..fc2ddeaf 100644 --- a/lib/src/database/matrix_sdk_database.dart +++ b/lib/src/database/matrix_sdk_database.dart @@ -31,8 +31,8 @@ import 'package:matrix/src/utils/copy_map.dart'; import 'package:matrix/src/utils/queued_to_device_event.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; -import 'package:matrix/src/database/indexeddb_box.dart' - if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; +import 'package:matrix/src/database/sqflite_box.dart' + if (dart.library.js_interop) 'package:matrix/src/database/indexeddb_box.dart'; import 'package:matrix/src/database/database_file_storage_stub.dart' if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart'; @@ -167,8 +167,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { Database? database; - /// Custom IdbFactory used to create the indexedDB. On IO platforms it would - /// lead to an error to import "dart:indexed_db" so this is dynamically + /// Custom [IDBFactory] used to create the indexedDB. On IO platforms it would + /// lead to an error to import "package:web/web.dart" so this is dynamically /// typed. final dynamic idbFactory; diff --git a/lib/src/utils/crypto/crypto.dart b/lib/src/utils/crypto/crypto.dart index a97417ee..c8137934 100644 --- a/lib/src/utils/crypto/crypto.dart +++ b/lib/src/utils/crypto/crypto.dart @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -export 'native.dart' if (dart.library.js) 'js.dart'; +export 'native.dart' if (dart.library.js_interop) 'js.dart'; import 'dart:math'; import 'dart:typed_data'; diff --git a/lib/src/utils/web_worker/native_implementations_web_worker.dart b/lib/src/utils/web_worker/native_implementations_web_worker.dart index 6cd71a69..260eb45a 100644 --- a/lib/src/utils/web_worker/native_implementations_web_worker.dart +++ b/lib/src/utils/web_worker/native_implementations_web_worker.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:html'; +import 'dart:js_interop'; import 'dart:math'; import 'dart:typed_data'; +import 'package:web/web.dart'; + import 'package:matrix/matrix.dart'; class NativeImplementationsWebWorker extends NativeImplementations { @@ -23,8 +25,8 @@ class NativeImplementationsWebWorker extends NativeImplementations { Uri href, { this.timeout = const Duration(seconds: 30), this.onStackTrace = defaultStackTraceHandler, - }) : worker = Worker(href.toString()) { - worker.onMessage.listen(_handleIncomingMessage); + }) : worker = Worker(href.toString().toJS) { + worker.onmessage = _handleIncomingMessage.toJS; } Future operation(WebWorkerOperations name, U argument) async { @@ -32,27 +34,26 @@ class NativeImplementationsWebWorker extends NativeImplementations { final completer = Completer(); _completers[label] = completer; final message = WebWorkerData(label, name, argument); - worker.postMessage(message.toJson()); + worker.postMessage(message.toJson().jsify()); return completer.future.timeout(timeout); } - Future _handleIncomingMessage(MessageEvent event) async { - final data = event.data; + void _handleIncomingMessage(MessageEvent event) async { + final data = event.data.dartify() as LinkedHashMap; // don't forget handling errors of our second thread... if (data['label'] == 'stacktrace') { - final origin = event.data['origin']; + final origin = data['origin']; final completer = _completers[origin]; - final error = event.data['error']!; + final error = data['error']!; - final stackTrace = - await onStackTrace.call(event.data['stacktrace'] as String); + final stackTrace = await onStackTrace.call(data['stacktrace'] as String); completer?.completeError( WebWorkerError(error: error, stackTrace: stackTrace), ); } else { - final response = WebWorkerData.fromJson(event.data); + final response = WebWorkerData.fromJson(data); _completers[response.label]!.complete(response.data); } } diff --git a/lib/src/utils/web_worker/web_worker.dart b/lib/src/utils/web_worker/web_worker.dart index 89180856..64f7aca6 100644 --- a/lib/src/utils/web_worker/web_worker.dart +++ b/lib/src/utils/web_worker/web_worker.dart @@ -1,13 +1,11 @@ // ignore_for_file: avoid_print import 'dart:async'; -import 'dart:html'; -import 'dart:indexed_db'; -import 'dart:js'; +import 'dart:collection'; +import 'dart:js_interop'; import 'dart:typed_data'; -import 'package:js/js.dart'; -import 'package:js/js_util.dart'; +import 'package:web/web.dart'; import 'package:matrix/matrix.dart' hide Event; import 'package:matrix/src/utils/web_worker/native_implementations_web_worker.dart'; @@ -32,63 +30,73 @@ import 'package:matrix/src/utils/web_worker/native_implementations_web_worker.da /// the web worker in your CI pipeline. /// +DedicatedWorkerGlobalScope get _workerScope => + (globalContext as DedicatedWorkerGlobalScope).self + as DedicatedWorkerGlobalScope; + @pragma('dart2js:tryInline') Future startWebWorker() async { - print('[native implementations worker]: Starting...'); - setProperty( - context['self'] as Object, - 'onmessage', - allowInterop( - (MessageEvent event) async { - final data = event.data; - try { - final operation = WebWorkerData.fromJson(data); - switch (operation.name) { - case WebWorkerOperations.shrinkImage: - final result = MatrixImageFile.resizeImplementation( - MatrixImageFileResizeArguments.fromJson( - Map.from(operation.data as Map), - ), - ); - sendResponse(operation.label as double, result?.toJson()); - break; - case WebWorkerOperations.calcImageMetadata: - final result = MatrixImageFile.calcMetadataImplementation( - Uint8List.fromList( - (operation.data as JsArray).whereType().toList(), - ), - ); - sendResponse(operation.label as double, result?.toJson()); - break; - default: - throw TypeError(); - } - } on Event catch (e, s) { - allowInterop(_replyError) - .call((e.target as Request).error, s, data['label'] as double); - } catch (e, s) { - allowInterop(_replyError).call(e, s, data['label'] as double); - } - }, - ), - ); + Logs().i('[native implementations worker]: Starting...'); + _workerScope.onmessage = (MessageEvent event) { + final data = event.data.dartify() as LinkedHashMap; + try { + final operation = WebWorkerData.fromJson(data); + switch (operation.name) { + case WebWorkerOperations.shrinkImage: + final result = MatrixImageFile.resizeImplementation( + MatrixImageFileResizeArguments.fromJson( + Map.from(operation.data as Map), + ), + ); + _sendResponse( + operation.label as double, + result?.toJson(), + ); + break; + case WebWorkerOperations.calcImageMetadata: + final result = MatrixImageFile.calcMetadataImplementation( + Uint8List.fromList( + (operation.data as List).whereType().toList(), + ), + ); + _sendResponse( + operation.label as double, + result?.toJson(), + ); + break; + default: + throw TypeError(); + } + } catch (e, s) { + _replyError(e, s, data['label'] as double); + } + }.toJS; } -void sendResponse(double label, dynamic response) { +void _sendResponse( + double label, + dynamic response, +) { try { - self.postMessage({ - 'label': label, - 'data': response, - }); + _workerScope.postMessage( + { + 'label': label, + 'data': response, + }.jsify(), + ); } catch (e, s) { - print('[native implementations worker] Error responding: $e, $s'); + Logs().e('[native implementations worker] Error responding: $e, $s'); } } -void _replyError(Object? error, StackTrace stackTrace, double origin) { +void _replyError( + Object? error, + StackTrace stackTrace, + double origin, +) { if (error != null) { try { - final jsError = jsify(error); + final jsError = error.jsify(); if (jsError != null) { error = jsError; } @@ -97,24 +105,15 @@ void _replyError(Object? error, StackTrace stackTrace, double origin) { } } try { - self.postMessage({ - 'label': 'stacktrace', - 'origin': origin, - 'error': error, - 'stacktrace': stackTrace.toString(), - }); + _workerScope.postMessage( + { + 'label': 'stacktrace', + 'origin': origin, + 'error': error, + 'stacktrace': stackTrace.toString(), + }.jsify(), + ); } catch (e, s) { - print('[native implementations worker] Error responding: $e, $s'); - } -} - -/// represents the [WorkerGlobalScope] the worker currently runs in. -@JS('self') -external WorkerGlobalScope get self; - -/// adding all missing WebWorker-only properties to the [WorkerGlobalScope] -extension on WorkerGlobalScope { - void postMessage(Object data) { - callMethod(self, 'postMessage', [jsify(data)]); + Logs().e('[native implementations worker] Error responding: $e, $s'); } } diff --git a/lib/src/voip/utils/wrapped_media_stream.dart b/lib/src/voip/utils/wrapped_media_stream.dart index e8eb0f3a..836cb690 100644 --- a/lib/src/voip/utils/wrapped_media_stream.dart +++ b/lib/src/voip/utils/wrapped_media_stream.dart @@ -47,7 +47,7 @@ class WrappedMediaStream { Future dispose() async { // AOT it - const isWeb = bool.fromEnvironment('dart.library.js_util'); + const isWeb = bool.fromEnvironment('dart.library.js_interop'); // libwebrtc does not provide a way to clone MediaStreams. So stopping the // local stream here would break calls with all other participants if anyone diff --git a/pubspec.yaml b/pubspec.yaml index 58922dc9..af0b6f27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: http: ">=0.13.0 <2.0.0" image: ^4.0.15 js: ^0.6.3 + js_interop: ^0.0.1 markdown: ^7.1.1 mime: ">=1.0.0 <3.0.0" path: ^1.9.1 @@ -32,6 +33,7 @@ dependencies: sqlite3: ^2.1.0 typed_data: ^1.3.2 vodozemac: ^0.2.0 + web: ^1.1.1 webrtc_interface: ^1.2.0 dev_dependencies: diff --git a/test/box_test.dart b/test/box_test.dart index 135b2526..e2e733a5 100644 --- a/test/box_test.dart +++ b/test/box_test.dart @@ -1,8 +1,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:test/test.dart'; -import 'package:matrix/src/database/indexeddb_box.dart' - if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; +import 'package:matrix/src/database/sqflite_box.dart' + if (dart.library.js_interop) 'package:matrix/src/database/indexeddb_box.dart'; void main() { group('Box tests', () { @@ -11,7 +11,7 @@ void main() { const data = {'name': 'Fluffy', 'age': 2}; const data2 = {'name': 'Loki', 'age': 4}; Database? db; - const isWeb = bool.fromEnvironment('dart.library.js_util'); + const isWeb = bool.fromEnvironment('dart.library.js_interop'); setUp(() async { if (!isWeb) { db = await databaseFactoryFfi.openDatabase(':memory:'); From 68f2fb9ddba986bc43a3b552474c92cfd25d0392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Fri, 19 Sep 2025 10:01:52 +0200 Subject: [PATCH 08/15] feat: Leave DM rooms and invite when ignoring a user --- lib/src/client.dart | 22 +++++++++++++++++++++- lib/src/event.dart | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 5d0c0453..cea838e5 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -3771,10 +3771,30 @@ class Client extends MatrixApi { /// Ignore another user. This will clear the local cached messages to /// hide all previous messages from this user. - Future ignoreUser(String userId) async { + Future ignoreUser( + String userId, { + /// Whether to also decline all invites and leave DM rooms with this user. + bool leaveRooms = true, + }) async { if (!userId.isValidMatrixId) { throw Exception('$userId is not a valid mxid!'); } + + if (leaveRooms) { + for (final room in rooms) { + final isInviteFromUser = room.membership == Membership.invite && + room.getState(EventTypes.RoomMember, userID!)?.senderId == userId; + + if (room.directChatMatrixID == userId || isInviteFromUser) { + try { + await room.leave(); + } catch (e, s) { + Logs().w('Unable to leave room with blocked user $userId', e, s); + } + } + } + } + await setAccountData(userID!, 'm.ignored_user_list', { 'ignored_users': Map.fromEntries( (ignoredUsers..add(userId)).map((key) => MapEntry(key, {})), diff --git a/lib/src/event.dart b/lib/src/event.dart index 3cb2da50..14b49e9a 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -1181,7 +1181,7 @@ class Event extends MatrixEvent { ); } - /// Returns the mentioned userIds and wether the event includes an @room + /// Returns the mentioned userIds and whether the event includes an @room /// mention. This is only determined by the `m.mention` object in the event /// content. ({List userIds, bool room}) get mentions { From 8735ecd378113bc444debe88a1064fb0d266ef0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Thu, 18 Sep 2025 15:30:38 +0200 Subject: [PATCH 09/15] feat: Add onProgress for upload and download methods --- lib/src/client.dart | 38 ++++++++++++- lib/src/event.dart | 20 +++++-- lib/src/room.dart | 18 +++++- lib/src/utils/multipart_request_progress.dart | 57 +++++++++++++++++++ test/client_test.dart | 9 ++- test/event_test.dart | 24 ++++++++ 6 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 lib/src/utils/multipart_request_progress.dart diff --git a/lib/src/client.dart b/lib/src/client.dart index cea838e5..c79a3a50 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -37,6 +37,7 @@ import 'package:matrix/src/models/timeline_chunk.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/client_init_exception.dart'; import 'package:matrix/src/utils/multilock.dart'; +import 'package:matrix/src/utils/multipart_request_progress.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_in_root.dart'; import 'package:matrix/src/utils/sync_update_item_count.dart'; @@ -1523,6 +1524,10 @@ class Client extends MatrixApi { Uint8List file, { String? filename, String? contentType, + + /// Callback which gets triggered on progress containing the amount of + /// uploaded bytes. + void Function(int)? onProgress, }) async { final mediaConfig = await getConfig(); final maxMediaSize = mediaConfig.mUploadSize; @@ -1531,8 +1536,31 @@ class Client extends MatrixApi { } contentType ??= lookupMimeType(filename ?? '', headerBytes: file); - final mxc = await super - .uploadContent(file, filename: filename, contentType: contentType); + + final requestUri = Uri( + path: '_matrix/media/v3/upload', + queryParameters: { + if (filename != null) 'filename': filename, + }, + ); + final request = MultipartRequest( + 'POST', + baseUri!.resolveUri(requestUri), + onProgress: onProgress, + ); + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + if (contentType != null) request.headers['content-type'] = contentType; + request.files.add( + http.MultipartFile.fromBytes('file', file, filename: filename), + ); + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + final mxc = ((json['content_uri'] as String).startsWith('mxc://') + ? Uri.parse(json['content_uri'] as String) + : throw Exception('Uri not an mxc URI')); final database = this.database; if (file.length <= database.maxFileSize) { @@ -1606,7 +1634,10 @@ 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 { + Future setAvatar( + MatrixFile? file, { + void Function(int)? onUploadProgress, + }) async { if (file == null) { // We send an empty String to remove the avatar. Sending Null **should** // work but it doesn't with Synapse. See: @@ -1617,6 +1648,7 @@ class Client extends MatrixApi { file.bytes, filename: file.name, contentType: file.mimeType, + onProgress: onUploadProgress, ); await setAvatarUrl(userID!, uploadResp); return; diff --git a/lib/src/event.dart b/lib/src/event.dart index 14b49e9a..60c39583 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -16,17 +16,20 @@ * along with this program. If not, see . */ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:html/parser.dart'; +import 'package:http/http.dart' as http; import 'package:mime/mime.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/file_send_request_credentials.dart'; import 'package:matrix/src/utils/html_to_text.dart'; import 'package:matrix/src/utils/markdown.dart'; +import 'package:matrix/src/utils/multipart_request_progress.dart'; abstract class RelationshipTypes { static const String reply = 'm.in_reply_to'; @@ -747,6 +750,10 @@ class Event extends MatrixEvent { bool getThumbnail = false, Future Function(Uri)? downloadCallback, bool fromLocalStoreOnly = false, + + /// Callback which gets triggered on progress containing the amount of + /// downloaded bytes. + void Function(int)? onDownloadProgress, }) async { if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { throw ("This event has the type '$type' and so it can't contain an attachment."); @@ -781,11 +788,14 @@ class Event extends MatrixEvent { final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly; if (canDownloadFileFromServer) { final httpClient = room.client.httpClient; - downloadCallback ??= (Uri url) async => (await httpClient.get( - url, - headers: {'authorization': 'Bearer ${room.client.accessToken}'}, - )) - .bodyBytes; + downloadCallback ??= (Uri url) async { + final request = http.Request('GET', url); + request.headers['authorization'] = 'Bearer ${room.client.accessToken}'; + + final response = await httpClient.send(request); + + return await response.stream.toBytesWithProgress(onDownloadProgress); + }; uint8list = await downloadCallback(await mxcUrl.getDownloadUri(room.client)); storeable = storeable && uint8list.lengthInBytes < database.maxFileSize; diff --git a/lib/src/room.dart b/lib/src/room.dart index e6e92327..0238db48 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -838,6 +838,11 @@ class Room { Map? extraContent, String? threadRootEventId, String? threadLastEventId, + + /// Callback which gets triggered on progress containing the amount of + /// uploaded bytes. + void Function(int)? onUploadProgress, + void Function(int)? onThumbnailUploadProgress, }) async { txid ??= client.generateUniqueTransactionId(); sendingFilePlaceholders[txid] = file; @@ -955,12 +960,14 @@ class Room { uploadFile.bytes, filename: uploadFile.name, contentType: uploadFile.mimeType, + onProgress: onUploadProgress, ); thumbnailUploadResp = uploadThumbnail != null ? await client.uploadContent( uploadThumbnail.bytes, filename: uploadThumbnail.name, contentType: uploadThumbnail.mimeType, + onProgress: onThumbnailUploadProgress, ) : null; } on MatrixException catch (_) { @@ -2104,10 +2111,17 @@ class Room { /// Uploads a new avatar for this room. Returns the event ID of the new /// m.room.avatar event. Insert null to remove the current avatar. - Future setAvatar(MatrixFile? file) async { + Future setAvatar( + MatrixFile? file, { + void Function(int)? onUploadProgress, + }) async { final uploadResp = file == null ? null - : await client.uploadContent(file.bytes, filename: file.name); + : await client.uploadContent( + file.bytes, + filename: file.name, + onProgress: onUploadProgress, + ); return await client.setRoomStateWithKey( id, EventTypes.RoomAvatar, diff --git a/lib/src/utils/multipart_request_progress.dart b/lib/src/utils/multipart_request_progress.dart new file mode 100644 index 00000000..7fb9335d --- /dev/null +++ b/lib/src/utils/multipart_request_progress.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +class MultipartRequest extends http.MultipartRequest { + MultipartRequest( + super.method, + super.url, { + this.onProgress, + }); + + final void Function(int bytes)? onProgress; + + @override + http.ByteStream finalize() { + final byteStream = super.finalize(); + if (onProgress == null) return byteStream; + + final total = contentLength; + int bytes = 0; + + final t = StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress?.call(bytes); + if (total >= bytes) { + sink.add(data); + } + }, + ); + final stream = byteStream.transform(t); + return http.ByteStream(stream); + } +} + +extension ToBytesWithProgress on http.ByteStream { + /// Collects the data of this stream in a [Uint8List]. + Future toBytesWithProgress(void Function(int)? onProgress) { + var length = 0; + final completer = Completer(); + final sink = ByteConversionSink.withCallback( + (bytes) => completer.complete(Uint8List.fromList(bytes)), + ); + listen( + (bytes) { + sink.add(bytes); + onProgress?.call(length += bytes.length); + }, + onError: completer.completeError, + onDone: sink.close, + cancelOnError: true, + ); + return completer.future; + } +} diff --git a/test/client_test.dart b/test/client_test.dart index 19773a1e..7bc747ff 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1488,13 +1488,18 @@ void main() { }); test('upload', () async { final client = await getClient(); - final response = - await client.uploadContent(Uint8List(0), filename: 'file.jpeg'); + final onProgressMap = []; + final response = await client.uploadContent( + Uint8List(0), + filename: 'file.jpeg', + onProgress: onProgressMap.add, + ); expect(response.toString(), 'mxc://example.com/AQwafuaFswefuhsfAFAgsw'); expect( await client.database.getFile(response) != null, client.database.supportsFileStoring, ); + expect(onProgressMap, [74, 183, 183, 185, 261]); await client.dispose(closeDatabase: true); }); diff --git a/test/event_test.dart b/test/event_test.dart index e3edae90..0bfb297f 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -2485,6 +2485,30 @@ void main() async { await room.client.dispose(closeDatabase: true); }, ); + + test('downloadAndDecryptAttachment from server', () async { + final client = await getClient(); + final event = Event( + room: client.rooms.first, + eventId: 'test', + originServerTs: DateTime.now(), + senderId: client.userID!, + content: { + 'body': 'ascii.txt', + 'filename': 'ascii.txt', + 'info': {'mimetype': 'application/msword', 'size': 6}, + 'msgtype': 'm.file', + 'url': 'mxc://example.org/abcd1234ascii', + }, + type: EventTypes.Message, + ); + final progressList = []; + await event.downloadAndDecryptAttachment( + onDownloadProgress: progressList.add, + ); + await client.dispose(); + expect(progressList, [112]); + }); test('downloadAndDecryptAttachment store', tags: 'olm', () async { final FILE_BUFF = Uint8List.fromList([0]); var serverHits = 0; From 9549270423473fcfb6f22add0c3837b338bd5e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Mon, 29 Sep 2025 14:58:40 +0200 Subject: [PATCH 10/15] chore: Revert on upload progress This is not working as expected. As far as I can see there is no way to do this with the http package for now. Only way would be to switch to dio. Not sure if we want to do this. --- lib/src/client.dart | 38 ++----------------- lib/src/room.dart | 3 -- lib/src/utils/multipart_request_progress.dart | 31 --------------- test/client_test.dart | 3 -- 4 files changed, 3 insertions(+), 72 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index c79a3a50..cea838e5 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -37,7 +37,6 @@ import 'package:matrix/src/models/timeline_chunk.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/client_init_exception.dart'; import 'package:matrix/src/utils/multilock.dart'; -import 'package:matrix/src/utils/multipart_request_progress.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_in_root.dart'; import 'package:matrix/src/utils/sync_update_item_count.dart'; @@ -1524,10 +1523,6 @@ class Client extends MatrixApi { Uint8List file, { String? filename, String? contentType, - - /// Callback which gets triggered on progress containing the amount of - /// uploaded bytes. - void Function(int)? onProgress, }) async { final mediaConfig = await getConfig(); final maxMediaSize = mediaConfig.mUploadSize; @@ -1536,31 +1531,8 @@ class Client extends MatrixApi { } contentType ??= lookupMimeType(filename ?? '', headerBytes: file); - - final requestUri = Uri( - path: '_matrix/media/v3/upload', - queryParameters: { - if (filename != null) 'filename': filename, - }, - ); - final request = MultipartRequest( - 'POST', - baseUri!.resolveUri(requestUri), - onProgress: onProgress, - ); - request.headers['authorization'] = 'Bearer ${bearerToken!}'; - if (contentType != null) request.headers['content-type'] = contentType; - request.files.add( - http.MultipartFile.fromBytes('file', file, filename: filename), - ); - final response = await httpClient.send(request); - final responseBody = await response.stream.toBytes(); - if (response.statusCode != 200) unexpectedResponse(response, responseBody); - final responseString = utf8.decode(responseBody); - final json = jsonDecode(responseString); - final mxc = ((json['content_uri'] as String).startsWith('mxc://') - ? Uri.parse(json['content_uri'] as String) - : throw Exception('Uri not an mxc URI')); + final mxc = await super + .uploadContent(file, filename: filename, contentType: contentType); final database = this.database; if (file.length <= database.maxFileSize) { @@ -1634,10 +1606,7 @@ class Client extends MatrixApi { /// Uploads a new user avatar for this user. Leave file null to remove the /// current avatar. - Future setAvatar( - MatrixFile? file, { - void Function(int)? onUploadProgress, - }) async { + Future setAvatar(MatrixFile? file) async { if (file == null) { // We send an empty String to remove the avatar. Sending Null **should** // work but it doesn't with Synapse. See: @@ -1648,7 +1617,6 @@ class Client extends MatrixApi { file.bytes, filename: file.name, contentType: file.mimeType, - onProgress: onUploadProgress, ); await setAvatarUrl(userID!, uploadResp); return; diff --git a/lib/src/room.dart b/lib/src/room.dart index 0238db48..0c61e8f0 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -960,14 +960,12 @@ class Room { uploadFile.bytes, filename: uploadFile.name, contentType: uploadFile.mimeType, - onProgress: onUploadProgress, ); thumbnailUploadResp = uploadThumbnail != null ? await client.uploadContent( uploadThumbnail.bytes, filename: uploadThumbnail.name, contentType: uploadThumbnail.mimeType, - onProgress: onThumbnailUploadProgress, ) : null; } on MatrixException catch (_) { @@ -2120,7 +2118,6 @@ class Room { : await client.uploadContent( file.bytes, filename: file.name, - onProgress: onUploadProgress, ); return await client.setRoomStateWithKey( id, diff --git a/lib/src/utils/multipart_request_progress.dart b/lib/src/utils/multipart_request_progress.dart index 7fb9335d..0b05a230 100644 --- a/lib/src/utils/multipart_request_progress.dart +++ b/lib/src/utils/multipart_request_progress.dart @@ -4,37 +4,6 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; -class MultipartRequest extends http.MultipartRequest { - MultipartRequest( - super.method, - super.url, { - this.onProgress, - }); - - final void Function(int bytes)? onProgress; - - @override - http.ByteStream finalize() { - final byteStream = super.finalize(); - if (onProgress == null) return byteStream; - - final total = contentLength; - int bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress?.call(bytes); - if (total >= bytes) { - sink.add(data); - } - }, - ); - final stream = byteStream.transform(t); - return http.ByteStream(stream); - } -} - extension ToBytesWithProgress on http.ByteStream { /// Collects the data of this stream in a [Uint8List]. Future toBytesWithProgress(void Function(int)? onProgress) { diff --git a/test/client_test.dart b/test/client_test.dart index 7bc747ff..2db2b1d9 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1488,18 +1488,15 @@ void main() { }); test('upload', () async { final client = await getClient(); - final onProgressMap = []; final response = await client.uploadContent( Uint8List(0), filename: 'file.jpeg', - onProgress: onProgressMap.add, ); expect(response.toString(), 'mxc://example.com/AQwafuaFswefuhsfAFAgsw'); expect( await client.database.getFile(response) != null, client.database.supportsFileStoring, ); - expect(onProgressMap, [74, 183, 183, 185, 261]); await client.dispose(closeDatabase: true); }); From fe94df97db24e8b7135850fad46a1020ba458306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Tue, 30 Sep 2025 10:07:19 +0200 Subject: [PATCH 11/15] refactor: Upgrade to vodozemac cryptoutils --- .github/workflows/versions.env | 4 +- README.md | 5 +- doc/end-to-end-encryption.md | 6 - doc/get-started.md | 1 - lib/encryption/ssss.dart | 44 +- lib/encryption/utils/key_verification.dart | 5 +- lib/src/utils/crypto/crypto.dart | 2 - lib/src/utils/crypto/encrypted_file.dart | 10 +- lib/src/utils/crypto/ffi.dart | 164 ---- lib/src/utils/crypto/js.dart | 77 -- lib/src/utils/crypto/native.dart | 120 --- lib/src/utils/crypto/subtle.dart | 119 --- lib/src/utils/native_implementations.dart | 10 +- pubspec.yaml | 8 +- scripts/prepare_vodozemac.sh | 4 +- test/encryption/ssss_test.dart | 844 +++++++++++---------- test/matrix_file_test.dart | 4 + 17 files changed, 481 insertions(+), 946 deletions(-) delete mode 100644 lib/src/utils/crypto/ffi.dart delete mode 100644 lib/src/utils/crypto/js.dart delete mode 100644 lib/src/utils/crypto/native.dart delete mode 100644 lib/src/utils/crypto/subtle.dart diff --git a/.github/workflows/versions.env b/.github/workflows/versions.env index 3fb81f74..0af8eef5 100644 --- a/.github/workflows/versions.env +++ b/.github/workflows/versions.env @@ -1,2 +1,2 @@ -flutter_version=3.27.4 -dart_version=3.6.2 \ No newline at end of file +flutter_version=3.35.4 +dart_version=3.9.2 \ No newline at end of file diff --git a/README.md b/README.md index 59b84879..0f951101 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,13 @@ Matrix (matrix.org) SDK written in dart. For E2EE, vodozemac must be provided. -Additionally, OpenSSL (libcrypto) must be provided on native platforms for E2EE. - -For flutter apps you can easily import it with the [flutter_vodozemac](https://pub.dev/packages/flutter_vodozemac) and the [flutter_openssl_crypto](https://pub.dev/packages/flutter_openssl_crypto) packages. +For flutter apps you can easily import it with the [flutter_vodozemac](https://pub.dev/packages/flutter_vodozemac) package. ```sh flutter pub add matrix # Optional: For end to end encryption: flutter pub add flutter_vodozemac -flutter pub add flutter_openssl_crypto ``` ## Get started diff --git a/doc/end-to-end-encryption.md b/doc/end-to-end-encryption.md index 8879535d..0f434ecc 100644 --- a/doc/end-to-end-encryption.md +++ b/doc/end-to-end-encryption.md @@ -6,12 +6,6 @@ For Flutter you can use [flutter_vodozemac](https://pub.dev/packages/flutter_vod flutter pub add flutter_vodozemac ``` -You also need [flutter_openssl_crypto](https://pub.dev/packages/flutter_openssl_crypto). - -```sh -flutter pub add flutter_openssl_crypto -``` - Now before you create your `Client`, init vodozemac: ```dart diff --git a/doc/get-started.md b/doc/get-started.md index f1113728..38ffc581 100644 --- a/doc/get-started.md +++ b/doc/get-started.md @@ -12,7 +12,6 @@ In your `pubspec.yaml` file add the following dependencies: # (Optional) For end to end encryption, please head on the # encryption guide and add these dependencies: flutter_vodozemac: - flutter_openssl_crypto: ``` ## Step 2: Create the client diff --git a/lib/encryption/ssss.dart b/lib/encryption/ssss.dart index 6e67abbc..efd50dbf 100644 --- a/lib/encryption/ssss.dart +++ b/lib/encryption/ssss.dart @@ -23,7 +23,7 @@ import 'dart:typed_data'; import 'package:base58check/base58.dart'; import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; +import 'package:vodozemac/vodozemac.dart'; import 'package:matrix/encryption/encryption.dart'; import 'package:matrix/encryption/utils/base64_unpadded.dart'; @@ -74,16 +74,18 @@ class SSSS { static DerivedKeys deriveKeys(Uint8List key, String name) { final zerosalt = Uint8List(8); - final prk = Hmac(sha256, zerosalt).convert(key); + final prk = CryptoUtils.hmac(key: zerosalt, input: key); final b = Uint8List(1); b[0] = 1; - final aesKey = Hmac(sha256, prk.bytes).convert(utf8.encode(name) + b); + final aesKey = CryptoUtils.hmac(key: prk, input: utf8.encode(name) + b); b[0] = 2; - final hmacKey = - Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b); + final hmacKey = CryptoUtils.hmac( + key: prk, + input: aesKey + utf8.encode(name) + b, + ); return DerivedKeys( - aesKey: Uint8List.fromList(aesKey.bytes), - hmacKey: Uint8List.fromList(hmacKey.bytes), + aesKey: Uint8List.fromList(aesKey), + hmacKey: Uint8List.fromList(hmacKey), ); } @@ -105,14 +107,15 @@ class SSSS { final keys = deriveKeys(key, name); final plain = Uint8List.fromList(utf8.encode(data)); - final ciphertext = await uc.aesCtr.encrypt(plain, keys.aesKey, iv); + final ciphertext = + CryptoUtils.aesCtr(input: plain, key: keys.aesKey, iv: iv); - final hmac = Hmac(sha256, keys.hmacKey).convert(ciphertext); + final hmac = CryptoUtils.hmac(key: keys.hmacKey, input: ciphertext); return EncryptedContent( iv: base64.encode(iv), ciphertext: base64.encode(ciphertext), - mac: base64.encode(hmac.bytes), + mac: base64.encode(hmac), ); } @@ -124,13 +127,16 @@ class SSSS { final keys = deriveKeys(key, name); final cipher = base64decodeUnpadded(data.ciphertext); final hmac = base64 - .encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes) + .encode(CryptoUtils.hmac(key: keys.hmacKey, input: cipher)) .replaceAll(RegExp(r'=+$'), ''); if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) { throw Exception('Bad MAC'); } - final decipher = await uc.aesCtr - .encrypt(cipher, keys.aesKey, base64decodeUnpadded(data.iv)); + final decipher = CryptoUtils.aesCtr( + input: cipher, + key: keys.aesKey, + iv: base64decodeUnpadded(data.iv), + ); return String.fromCharCodes(decipher); } @@ -184,12 +190,10 @@ class SSSS { if (info.salt == null) { throw InvalidPassphraseException('Passphrase info without salt'); } - return await uc.pbkdf2( - Uint8List.fromList(utf8.encode(passphrase)), - Uint8List.fromList(utf8.encode(info.salt!)), - uc.sha512, - info.iterations!, - info.bits ?? 256, + return CryptoUtils.pbkdf2( + passphrase: Uint8List.fromList(utf8.encode(passphrase)), + salt: Uint8List.fromList(utf8.encode(info.salt!)), + iterations: info.iterations!, ); } @@ -742,7 +746,7 @@ class OpenSSSS { info: keyData.passphrase!, ), ), - ).timeout(Duration(seconds: 10)); + ).timeout(Duration(minutes: 2)); } else if (recoveryKey != null) { privateKey = SSSS.decodeRecoveryKey(recoveryKey); } else { diff --git a/lib/encryption/utils/key_verification.dart b/lib/encryption/utils/key_verification.dart index 33963621..068137bc 100644 --- a/lib/encryption/utils/key_verification.dart +++ b/lib/encryption/utils/key_verification.dart @@ -21,7 +21,6 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:canonical_json/canonical_json.dart'; -import 'package:crypto/crypto.dart' as crypto; import 'package:typed_data/typed_data.dart'; import 'package:vodozemac/vodozemac.dart' as vod; @@ -1559,8 +1558,8 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { Future _makeCommitment(String pubKey, String canonicalJson) async { if (hash == 'sha256') { final bytes = utf8.encoder.convert(pubKey + canonicalJson); - final digest = crypto.sha256.convert(bytes); - return encodeBase64Unpadded(digest.bytes); + final digest = vod.CryptoUtils.sha256(input: bytes); + return encodeBase64Unpadded(digest); } throw Exception('Unknown hash method'); } diff --git a/lib/src/utils/crypto/crypto.dart b/lib/src/utils/crypto/crypto.dart index c8137934..9f400c23 100644 --- a/lib/src/utils/crypto/crypto.dart +++ b/lib/src/utils/crypto/crypto.dart @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -export 'native.dart' if (dart.library.js_interop) 'js.dart'; - import 'dart:math'; import 'dart:typed_data'; diff --git a/lib/src/utils/crypto/encrypted_file.dart b/lib/src/utils/crypto/encrypted_file.dart index c5b35488..1f639541 100644 --- a/lib/src/utils/crypto/encrypted_file.dart +++ b/lib/src/utils/crypto/encrypted_file.dart @@ -19,6 +19,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:vodozemac/vodozemac.dart'; + import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/src/utils/crypto/crypto.dart'; @@ -38,8 +40,8 @@ class EncryptedFile { Future encryptFile(Uint8List input) async { final key = secureRandomBytes(32); final iv = secureRandomBytes(16); - final data = await aesCtr.encrypt(input, key, iv); - final hash = await sha256(data); + final data = CryptoUtils.aesCtr(input: input, key: key, iv: iv); + final hash = CryptoUtils.sha256(input: data); return EncryptedFile( data: data, k: base64Url.encode(key).replaceAll('=', ''), @@ -51,12 +53,12 @@ Future encryptFile(Uint8List input) async { /// you would likely want to use [NativeImplementations] and /// [Client.nativeImplementations] instead Future decryptFileImplementation(EncryptedFile input) async { - if (base64.encode(await sha256(input.data)) != + if (base64.encode(CryptoUtils.sha256(input: input.data)) != base64.normalize(input.sha256)) { return null; } final key = base64decodeUnpadded(base64.normalize(input.k)); final iv = base64decodeUnpadded(base64.normalize(input.iv)); - return await aesCtr.encrypt(input.data, key, iv); + return CryptoUtils.aesCtr(input: input.data, key: key, iv: iv); } diff --git a/lib/src/utils/crypto/ffi.dart b/lib/src/utils/crypto/ffi.dart deleted file mode 100644 index 8cba110f..00000000 --- a/lib/src/utils/crypto/ffi.dart +++ /dev/null @@ -1,164 +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:ffi'; -import 'dart:io'; - -final libcrypto = () { - if (Platform.isIOS) { - return DynamicLibrary.process(); - } else if (Platform.isAndroid) { - return DynamicLibrary.open('libcrypto.so'); - } else if (Platform.isWindows) { - return DynamicLibrary.open('libcrypto.dll'); - } else if (Platform.isMacOS) { - try { - return DynamicLibrary.open('libcrypto.3.dylib'); - } catch (_) { - return DynamicLibrary.open('libcrypto.1.1.dylib'); - } - } else { - try { - return DynamicLibrary.open('libcrypto.so.3'); - } catch (_) { - return DynamicLibrary.open('libcrypto.so.1.1'); - } - } -}(); - -final PKCS5_PBKDF2_HMAC = libcrypto.lookupFunction< - IntPtr Function( - Pointer pass, - IntPtr passlen, - Pointer salt, - IntPtr saltlen, - IntPtr iter, - Pointer digest, - IntPtr keylen, - Pointer out, - ), - int Function( - Pointer pass, - int passlen, - Pointer salt, - int saltlen, - int iter, - Pointer digest, - int keylen, - Pointer out, - )>('PKCS5_PBKDF2_HMAC'); - -final EVP_sha1 = libcrypto.lookupFunction Function(), - Pointer Function()>('EVP_sha1'); - -final EVP_sha256 = libcrypto.lookupFunction Function(), - Pointer Function()>('EVP_sha256'); - -final EVP_sha512 = libcrypto.lookupFunction Function(), - Pointer Function()>('EVP_sha512'); - -final EVP_aes_128_ctr = libcrypto.lookupFunction Function(), - Pointer Function()>('EVP_aes_128_ctr'); - -final EVP_aes_256_ctr = libcrypto.lookupFunction Function(), - Pointer Function()>('EVP_aes_256_ctr'); - -final EVP_CIPHER_CTX_new = libcrypto.lookupFunction< - Pointer Function(), - Pointer Function()>('EVP_CIPHER_CTX_new'); - -final EVP_EncryptInit_ex = libcrypto.lookupFunction< - Pointer Function( - Pointer ctx, - Pointer alg, - Pointer some, - Pointer key, - Pointer iv, - ), - Pointer Function( - Pointer ctx, - Pointer alg, - Pointer some, - Pointer key, - Pointer iv, - )>('EVP_EncryptInit_ex'); - -final EVP_EncryptUpdate = libcrypto.lookupFunction< - Pointer Function( - Pointer ctx, - Pointer output, - Pointer outputLen, - Pointer input, - IntPtr inputLen, - ), - Pointer Function( - Pointer ctx, - Pointer output, - Pointer outputLen, - Pointer input, - int inputLen, - )>('EVP_EncryptUpdate'); - -final EVP_EncryptFinal_ex = libcrypto.lookupFunction< - Pointer Function( - Pointer ctx, - Pointer data, - Pointer len, - ), - Pointer Function( - Pointer ctx, - Pointer data, - Pointer len, - )>('EVP_EncryptFinal_ex'); - -final EVP_CIPHER_CTX_free = libcrypto.lookupFunction< - Pointer Function(Pointer ctx), - Pointer Function( - Pointer ctx, - )>('EVP_CIPHER_CTX_free'); - -final EVP_Digest = libcrypto.lookupFunction< - IntPtr Function( - Pointer data, - IntPtr len, - Pointer hash, - Pointer hsize, - Pointer alg, - Pointer engine, - ), - int Function( - Pointer data, - int len, - Pointer hash, - Pointer hsize, - Pointer alg, - Pointer engine, - )>('EVP_Digest'); - -final EVP_MD_size = () { - // EVP_MD_size was renamed to EVP_MD_get_size in Openssl3.0. - // There is an alias macro, but those don't exist in libraries. - // Try loading the new name first, then fall back to the old one if not found. - try { - return libcrypto.lookupFunction ctx), - int Function(Pointer ctx)>('EVP_MD_get_size'); - } catch (e) { - return libcrypto.lookupFunction ctx), - int Function(Pointer ctx)>('EVP_MD_size'); - } -}(); diff --git a/lib/src/utils/crypto/js.dart b/lib/src/utils/crypto/js.dart deleted file mode 100644 index aa2dd2c8..00000000 --- a/lib/src/utils/crypto/js.dart +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2020 Famedly GmbH -// SPDX-License-Identifier: AGPL-3.0-or-later - -import 'dart:typed_data'; - -import 'package:matrix/src/utils/crypto/subtle.dart' as subtle; -import 'package:matrix/src/utils/crypto/subtle.dart'; - -abstract class Hash { - Hash._(this.name); - String name; - - Future call(Uint8List input) async => - Uint8List.view(await digest(name, input)); -} - -final Hash sha1 = _Sha1(); -final Hash sha256 = _Sha256(); -final Hash sha512 = _Sha512(); - -class _Sha1 extends Hash { - _Sha1() : super._('SHA-1'); -} - -class _Sha256 extends Hash { - _Sha256() : super._('SHA-256'); -} - -class _Sha512 extends Hash { - _Sha512() : super._('SHA-512'); -} - -abstract class Cipher { - Cipher._(this.name); - String name; - Object params(Uint8List iv); - Future encrypt( - Uint8List input, - Uint8List key, - Uint8List iv, - ) async { - final subtleKey = await importKey('raw', key, name, false, ['encrypt']); - return (await subtle.encrypt(params(iv), subtleKey, input)).asUint8List(); - } -} - -final Cipher aesCtr = _AesCtr(); - -class _AesCtr extends Cipher { - _AesCtr() : super._('AES-CTR'); - - @override - Object params(Uint8List iv) => - AesCtrParams(name: name, counter: iv, length: 64); -} - -Future pbkdf2( - Uint8List passphrase, - Uint8List salt, - Hash hash, - int iterations, - int bits, -) async { - final raw = - await importKey('raw', passphrase, 'PBKDF2', false, ['deriveBits']); - final res = await deriveBits( - Pbkdf2Params( - name: 'PBKDF2', - hash: hash.name, - salt: salt, - iterations: iterations, - ), - raw, - bits, - ); - return Uint8List.view(res); -} diff --git a/lib/src/utils/crypto/native.dart b/lib/src/utils/crypto/native.dart deleted file mode 100644 index c70cded9..00000000 --- a/lib/src/utils/crypto/native.dart +++ /dev/null @@ -1,120 +0,0 @@ -// ignore_for_file: deprecated_member_use -// ignoring the elementAt deprecation because this would make the SDK -// incompatible with older flutter versions than 3.19.0 or dart 3.3.0 - -import 'dart:async'; -import 'dart:ffi'; -import 'dart:typed_data'; - -import 'package:ffi/ffi.dart'; - -import 'package:matrix/src/utils/crypto/ffi.dart'; - -abstract class Hash { - Hash._(this.ptr); - Pointer ptr; - - FutureOr call(Uint8List data) { - final outSize = EVP_MD_size(ptr); - final mem = malloc.call(outSize + data.length); - final dataMem = mem.elementAt(outSize); - try { - dataMem.asTypedList(data.length).setAll(0, data); - EVP_Digest(dataMem, data.length, mem, nullptr, ptr, nullptr); - return Uint8List.fromList(mem.asTypedList(outSize)); - } finally { - malloc.free(mem); - } - } -} - -final Hash sha1 = _Sha1(); -final Hash sha256 = _Sha256(); -final Hash sha512 = _Sha512(); - -class _Sha1 extends Hash { - _Sha1() : super._(EVP_sha1()); -} - -class _Sha256 extends Hash { - _Sha256() : super._(EVP_sha256()); -} - -class _Sha512 extends Hash { - _Sha512() : super._(EVP_sha512()); -} - -abstract class Cipher { - Cipher._(); - Pointer getAlg(int keysize); - FutureOr encrypt(Uint8List input, Uint8List key, Uint8List iv) { - final alg = getAlg(key.length * 8); - final mem = malloc - .call(sizeOf() + key.length + iv.length + input.length); - final lenMem = mem.cast(); - final keyMem = mem.elementAt(sizeOf()); - final ivMem = keyMem.elementAt(key.length); - final dataMem = ivMem.elementAt(iv.length); - try { - keyMem.asTypedList(key.length).setAll(0, key); - ivMem.asTypedList(iv.length).setAll(0, iv); - dataMem.asTypedList(input.length).setAll(0, input); - final ctx = EVP_CIPHER_CTX_new(); - EVP_EncryptInit_ex(ctx, alg, nullptr, keyMem, ivMem); - EVP_EncryptUpdate(ctx, dataMem, lenMem, dataMem, input.length); - EVP_EncryptFinal_ex(ctx, dataMem.elementAt(lenMem.value), lenMem); - EVP_CIPHER_CTX_free(ctx); - return Uint8List.fromList(dataMem.asTypedList(input.length)); - } finally { - malloc.free(mem); - } - } -} - -final Cipher aesCtr = _AesCtr(); - -class _AesCtr extends Cipher { - _AesCtr() : super._(); - - @override - Pointer getAlg(int keysize) { - switch (keysize) { - case 128: - return EVP_aes_128_ctr(); - case 256: - return EVP_aes_256_ctr(); - default: - throw ArgumentError('invalid key size'); - } - } -} - -FutureOr pbkdf2( - Uint8List passphrase, - Uint8List salt, - Hash hash, - int iterations, - int bits, -) { - final outLen = bits ~/ 8; - final mem = malloc.call(passphrase.length + salt.length + outLen); - final saltMem = mem.elementAt(passphrase.length); - final outMem = saltMem.elementAt(salt.length); - try { - mem.asTypedList(passphrase.length).setAll(0, passphrase); - saltMem.asTypedList(salt.length).setAll(0, salt); - PKCS5_PBKDF2_HMAC( - mem, - passphrase.length, - saltMem, - salt.length, - iterations, - hash.ptr, - outLen, - outMem, - ); - return Uint8List.fromList(outMem.asTypedList(outLen)); - } finally { - malloc.free(mem); - } -} diff --git a/lib/src/utils/crypto/subtle.dart b/lib/src/utils/crypto/subtle.dart deleted file mode 100644 index d1cb7c13..00000000 --- a/lib/src/utils/crypto/subtle.dart +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) 2020 Famedly GmbH -// SPDX-License-Identifier: AGPL-3.0-or-later - -import 'dart:async'; -import 'dart:js_util'; -import 'dart:typed_data'; - -import 'package:js/js.dart'; - -@JS() -@anonymous -class Pbkdf2Params { - external factory Pbkdf2Params({ - String name, - String hash, - Uint8List salt, - int iterations, - }); - String? name; - String? hash; - Uint8List? salt; - int? iterations; -} - -@JS() -@anonymous -class AesCtrParams { - external factory AesCtrParams({ - String name, - Uint8List counter, - int length, - }); - String? name; - Uint8List? counter; - int? length; -} - -@JS('crypto.subtle.encrypt') -external dynamic _encrypt(dynamic algorithm, dynamic key, Uint8List data); - -Future encrypt(dynamic algorithm, dynamic key, Uint8List data) { - return promiseToFuture(_encrypt(algorithm, key, data)); -} - -@JS('crypto.subtle.decrypt') -external dynamic _decrypt(dynamic algorithm, dynamic key, Uint8List data); - -Future decrypt(dynamic algorithm, dynamic key, Uint8List data) { - return promiseToFuture(_decrypt(algorithm, key, data)); -} - -@JS('crypto.subtle.importKey') -external dynamic _importKey( - String format, - dynamic keyData, - dynamic algorithm, - bool extractable, - List keyUsages, -); - -Future importKey( - String format, - dynamic keyData, - dynamic algorithm, - bool extractable, - List keyUsages, -) { - return promiseToFuture( - _importKey(format, keyData, algorithm, extractable, keyUsages), - ); -} - -@JS('crypto.subtle.exportKey') -external dynamic _exportKey(String algorithm, dynamic key); - -Future exportKey(String algorithm, dynamic key) { - return promiseToFuture(_exportKey(algorithm, key)); -} - -@JS('crypto.subtle.deriveKey') -external dynamic _deriveKey( - dynamic algorithm, - dynamic baseKey, - dynamic derivedKeyAlgorithm, - bool extractable, - List keyUsages, -); - -Future deriveKey( - dynamic algorithm, - dynamic baseKey, - dynamic derivedKeyAlgorithm, - bool extractable, - List keyUsages, -) { - return promiseToFuture( - _deriveKey( - algorithm, - baseKey, - derivedKeyAlgorithm, - extractable, - keyUsages, - ), - ); -} - -@JS('crypto.subtle.deriveBits') -external dynamic _deriveBits(dynamic algorithm, dynamic baseKey, int length); - -Future deriveBits(dynamic algorithm, dynamic baseKey, int length) { - return promiseToFuture(_deriveBits(algorithm, baseKey, length)); -} - -@JS('crypto.subtle.digest') -external dynamic _digest(String algorithm, Uint8List data); - -Future digest(String algorithm, Uint8List data) { - return promiseToFuture(_digest(algorithm, data)); -} diff --git a/lib/src/utils/native_implementations.dart b/lib/src/utils/native_implementations.dart index 367909d3..ba0163e1 100644 --- a/lib/src/utils/native_implementations.dart +++ b/lib/src/utils/native_implementations.dart @@ -155,7 +155,10 @@ class NativeImplementationsIsolate extends NativeImplementations { bool retryInDummy = true, }) { return runInBackground( - NativeImplementations.dummy.decryptFile, + (EncryptedFile args) async { + await vodozemacInit?.call(); + return NativeImplementations.dummy.decryptFile(args); + }, file, ); } @@ -180,7 +183,10 @@ class NativeImplementationsIsolate extends NativeImplementations { bool retryInDummy = true, }) { return runInBackground( - NativeImplementations.dummy.keyFromPassphrase, + (KeyFromPassphraseArgs args) async { + await vodozemacInit?.call(); + return NativeImplementations.dummy.keyFromPassphrase(args); + }, args, ); } diff --git a/pubspec.yaml b/pubspec.yaml index af0b6f27..33377ab7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,14 +14,10 @@ dependencies: blurhash_dart: ^1.1.0 canonical_json: ^1.1.0 collection: ^1.15.0 - crypto: ^3.0.0 - enhanced_enum: ^0.2.4 - ffi: ^2.0.0 html: ^0.15.0 html_unescape: ^2.0.0 http: ">=0.13.0 <2.0.0" image: ^4.0.15 - js: ^0.6.3 js_interop: ^0.0.1 markdown: ^7.1.1 mime: ">=1.0.0 <3.0.0" @@ -32,7 +28,7 @@ dependencies: sqflite_common: ^2.4.5 sqlite3: ^2.1.0 typed_data: ^1.3.2 - vodozemac: ^0.2.0 + vodozemac: ^0.3.0 web: ^1.1.1 webrtc_interface: ^1.2.0 @@ -43,4 +39,4 @@ dev_dependencies: import_sorter: ^4.6.0 lints: ^5.0.0 sqflite_common_ffi: ^2.3.4+4 # sqflite_common_ffi aggressively requires newer dart versions - test: ^1.25.13 + test: ^1.25.13 \ No newline at end of file diff --git a/scripts/prepare_vodozemac.sh b/scripts/prepare_vodozemac.sh index 47f65044..00bc4df0 100755 --- a/scripts/prepare_vodozemac.sh +++ b/scripts/prepare_vodozemac.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash rm -rf rust -git clone https://github.com/famedly/dart-vodozemac.git +version=$(yq ".dependencies.vodozemac" < pubspec.yaml) +version=$(expr "$version" : '\^*\(.*\)') +git clone https://github.com/famedly/dart-vodozemac.git -b ${version} mv ./dart-vodozemac/rust ./ rm -rf dart-vodozemac cd ./rust diff --git a/test/encryption/ssss_test.dart b/test/encryption/ssss_test.dart index 96726d76..25a3d074 100644 --- a/test/encryption/ssss_test.dart +++ b/test/encryption/ssss_test.dart @@ -48,462 +48,476 @@ class MockSSSS extends SSSS { } void main() { - group('SSSS', tags: 'olm', () { - Logs().level = Level.error; + group( + 'SSSS', + tags: 'olm', + () { + Logs().level = Level.error; - late Client client; + late Client client; - setUpAll(() async { - await vod.init( - wasmPath: './pkg/', - libraryPath: './rust/target/debug/', - ); + setUpAll(() async { + await vod.init( + wasmPath: './pkg/', + libraryPath: './rust/target/debug/', + ); - client = await getClient(); - }); - - test('basic things', () async { - expect( - client.encryption!.ssss.defaultKeyId, - '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3', - ); - }); - - test('encrypt / decrypt', () async { - final key = Uint8List.fromList(secureRandomBytes(32)); - - final enc = await SSSS.encryptAes('secret foxies', key, 'name'); - final dec = await SSSS.decryptAes(enc, key, 'name'); - expect(dec, 'secret foxies'); - }); - - test('store', () async { - final handle = client.encryption!.ssss.open(); - var failed = false; - try { - await handle.unlock(passphrase: 'invalid'); - } catch (_) { - failed = true; - } - expect(failed, true); - expect(handle.isUnlocked, false); - failed = false; - try { - await handle.unlock(recoveryKey: 'invalid'); - } catch (_) { - failed = true; - } - expect(failed, true); - expect(handle.isUnlocked, false); - await handle.unlock(passphrase: ssssPassphrase); - await handle.unlock(recoveryKey: ssssKey); - expect(handle.isUnlocked, true); - FakeMatrixApi.calledEndpoints.clear(); - - // OpenSSSS store waits for accountdata to be updated before returning - // but we can't update that before the below endpoint is not hit. - await handle.ssss - .store('best animal', 'foxies', handle.keyId, handle.privateKey!); - - final content = FakeMatrixApi - .calledEndpoints[ - '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/best%20animal']! - .first; - client.accountData['best animal'] = BasicEvent.fromJson({ - 'type': 'best animal', - 'content': json.decode(content), + client = await getClient(); }); - expect(await handle.getStored('best animal'), 'foxies'); - }); - test('encode / decode recovery key', () async { - final key = Uint8List.fromList(secureRandomBytes(32)); - final encoded = SSSS.encodeRecoveryKey(key); - var decoded = SSSS.decodeRecoveryKey(encoded); - expect(key, decoded); + test('basic things', () async { + expect( + client.encryption!.ssss.defaultKeyId, + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3', + ); + }); - decoded = SSSS.decodeRecoveryKey('$encoded \n\t'); - expect(key, decoded); + test('encrypt / decrypt', () async { + final key = Uint8List.fromList(secureRandomBytes(32)); - final handle = client.encryption!.ssss.open(); - await handle.unlock(recoveryKey: ssssKey); - expect(handle.recoveryKey, ssssKey); - }); + final enc = await SSSS.encryptAes('secret foxies', key, 'name'); + final dec = await SSSS.decryptAes(enc, key, 'name'); + expect(dec, 'secret foxies'); + }); - test('cache', () async { - await client.encryption!.ssss.clearCache(); - final handle = - client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); - await handle.unlock(recoveryKey: ssssKey, postUnlock: false); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningSelfSigning)) != - null, - false, - ); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningUserSigning)) != - null, - false, - ); - await handle.getStored(EventTypes.CrossSigningSelfSigning); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningSelfSigning)) != - null, - true, - ); - await handle.maybeCacheAll(); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningUserSigning)) != - null, - true, - ); - expect( - (await client.encryption!.ssss.getCached(EventTypes.MegolmBackup)) != - null, - true, - ); - }); + test('store', () async { + final handle = client.encryption!.ssss.open(); + var failed = false; + try { + await handle.unlock(passphrase: 'invalid'); + } catch (_) { + failed = true; + } + expect(failed, true); + expect(handle.isUnlocked, false); + failed = false; + try { + await handle.unlock(recoveryKey: 'invalid'); + } catch (_) { + failed = true; + } + expect(failed, true); + expect(handle.isUnlocked, false); + await handle.unlock(passphrase: ssssPassphrase); + await handle.unlock(recoveryKey: ssssKey); + expect(handle.isUnlocked, true); + FakeMatrixApi.calledEndpoints.clear(); - test('postUnlock', () async { - await client.encryption!.ssss.clearCache(); - client.userDeviceKeys[client.userID!]!.masterKey! - .setDirectVerified(false); - final handle = - client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); - await handle.unlock(recoveryKey: ssssKey); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningSelfSigning)) != - null, - true, - ); - expect( - (await client.encryption!.ssss - .getCached(EventTypes.CrossSigningUserSigning)) != - null, - true, - ); - expect( - (await client.encryption!.ssss.getCached(EventTypes.MegolmBackup)) != - null, - true, - ); - expect( - client.userDeviceKeys[client.userID!]!.masterKey!.directVerified, - true, - ); - }); + // OpenSSSS store waits for accountdata to be updated before returning + // but we can't update that before the below endpoint is not hit. + await handle.ssss + .store('best animal', 'foxies', handle.keyId, handle.privateKey!); - test('make share requests', () async { - final key = - client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; - key.setDirectVerified(true); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.request('some.type', [key]); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - true, - ); - }); + final content = FakeMatrixApi + .calledEndpoints[ + '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/best%20animal']! + .first; + client.accountData['best animal'] = BasicEvent.fromJson({ + 'type': 'best animal', + 'content': json.decode(content), + }); + expect(await handle.getStored('best animal'), 'foxies'); + }); - test('answer to share requests', () async { - var event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.request', - content: { - 'action': 'request', - 'requesting_device_id': 'OTHERDEVICE', - 'name': EventTypes.CrossSigningSelfSigning, - 'request_id': '1', - }, - ); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - true, - ); + test('encode / decode recovery key', () async { + final key = Uint8List.fromList(secureRandomBytes(32)); + final encoded = SSSS.encodeRecoveryKey(key); + var decoded = SSSS.decodeRecoveryKey(encoded); + expect(key, decoded); - // now test some fail scenarios + decoded = SSSS.decodeRecoveryKey('$encoded \n\t'); + expect(key, decoded); - // not by us - event = ToDeviceEvent( - sender: '@someotheruser:example.org', - type: 'm.secret.request', - content: { - 'action': 'request', - 'requesting_device_id': 'OTHERDEVICE', - 'name': EventTypes.CrossSigningSelfSigning, - 'request_id': '1', - }, - ); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - false, - ); + final handle = client.encryption!.ssss.open(); + await handle.unlock(recoveryKey: ssssKey); + expect(handle.recoveryKey, ssssKey); + }); - // secret not cached - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.request', - content: { - 'action': 'request', - 'requesting_device_id': 'OTHERDEVICE', - 'name': 'm.unknown.secret', - 'request_id': '1', - }, - ); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - false, - ); + test('cache', () async { + await client.encryption!.ssss.clearCache(); + final handle = + client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); + await handle.unlock(recoveryKey: ssssKey, postUnlock: false); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningSelfSigning)) != + null, + false, + ); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningUserSigning)) != + null, + false, + ); + await handle.getStored(EventTypes.CrossSigningSelfSigning); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningSelfSigning)) != + null, + true, + ); + await handle.maybeCacheAll(); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningUserSigning)) != + null, + true, + ); + expect( + (await client.encryption!.ssss.getCached(EventTypes.MegolmBackup)) != + null, + true, + ); + }); - // is a cancelation - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.request', - content: { - 'action': 'request_cancellation', - 'requesting_device_id': 'OTHERDEVICE', - 'name': EventTypes.CrossSigningSelfSigning, - 'request_id': '1', - }, - ); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - false, - ); + test('postUnlock', () async { + await client.encryption!.ssss.clearCache(); + client.userDeviceKeys[client.userID!]!.masterKey! + .setDirectVerified(false); + final handle = + client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); + await handle.unlock(recoveryKey: ssssKey); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningSelfSigning)) != + null, + true, + ); + expect( + (await client.encryption!.ssss + .getCached(EventTypes.CrossSigningUserSigning)) != + null, + true, + ); + expect( + (await client.encryption!.ssss.getCached(EventTypes.MegolmBackup)) != + null, + true, + ); + expect( + client.userDeviceKeys[client.userID!]!.masterKey!.directVerified, + true, + ); + }); - // device not verified - final key = - client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; - key.setDirectVerified(false); - client.userDeviceKeys[client.userID!]!.masterKey! - .setDirectVerified(false); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.request', - content: { - 'action': 'request', - 'requesting_device_id': 'OTHERDEVICE', - 'name': EventTypes.CrossSigningSelfSigning, - 'request_id': '1', - }, - ); - FakeMatrixApi.calledEndpoints.clear(); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - FakeMatrixApi.calledEndpoints.keys.any( - (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), - ), - false, - ); - key.setDirectVerified(true); - }); + test('make share requests', () async { + final key = + client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; + key.setDirectVerified(true); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.request('some.type', [key]); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + true, + ); + }); - test('receive share requests', () async { - final key = - client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; - key.setDirectVerified(true); - final handle = - client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); - await handle.unlock(recoveryKey: ssssKey); + test('answer to share requests', () async { + var event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': EventTypes.CrossSigningSelfSigning, + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + true, + ); - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request('best animal', [key]); - var event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': 'foxies!', - }, - encryptedContent: { - 'sender_key': key.curve25519Key, - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached('best animal'), 'foxies!'); + // now test some fail scenarios + + // not by us + event = ToDeviceEvent( + sender: '@someotheruser:example.org', + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': EventTypes.CrossSigningSelfSigning, + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + false, + ); + + // secret not cached + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.unknown.secret', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + false, + ); + + // is a cancelation + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.request', + content: { + 'action': 'request_cancellation', + 'requesting_device_id': 'OTHERDEVICE', + 'name': EventTypes.CrossSigningSelfSigning, + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + false, + ); + + // device not verified + final key = + client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; + key.setDirectVerified(false); + client.userDeviceKeys[client.userID!]!.masterKey! + .setDirectVerified(false); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': EventTypes.CrossSigningSelfSigning, + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), + ), + false, + ); + key.setDirectVerified(true); + }); + + test('receive share requests', () async { + final key = + client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; + key.setDirectVerified(true); + final handle = + client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); + await handle.unlock(recoveryKey: ssssKey); - // test the different validators - for (final type in [ - EventTypes.CrossSigningSelfSigning, - EventTypes.CrossSigningUserSigning, - EventTypes.MegolmBackup, - ]) { - final secret = await handle.getStored(type); await client.encryption!.ssss.clearCache(); client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request(type, [key]); - event = ToDeviceEvent( + await client.encryption!.ssss.request('best animal', [key]); + var event = ToDeviceEvent( sender: client.userID!, type: 'm.secret.send', content: { 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': secret, + 'secret': 'foxies!', }, encryptedContent: { 'sender_key': key.curve25519Key, }, ); await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached(type), secret); - } + expect( + await client.encryption!.ssss.getCached('best animal'), + 'foxies!', + ); - // test different fail scenarios + // test the different validators + for (final type in [ + EventTypes.CrossSigningSelfSigning, + EventTypes.CrossSigningUserSigning, + EventTypes.MegolmBackup, + ]) { + final secret = await handle.getStored(type); + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request(type, [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption!.ssss.pendingShareRequests.keys.first, + 'secret': secret, + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect(await client.encryption!.ssss.getCached(type), secret); + } - // not encrypted - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request('best animal', [key]); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': 'foxies!', - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached('best animal'), null); + // test different fail scenarios - // unknown request id - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request('best animal', [key]); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': 'invalid', - 'secret': 'foxies!', - }, - encryptedContent: { - 'sender_key': key.curve25519Key, - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached('best animal'), null); + // not encrypted + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption!.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect(await client.encryption!.ssss.getCached('best animal'), null); - // not from a device we sent the request to - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request('best animal', [key]); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': 'foxies!', - }, - encryptedContent: { - 'sender_key': 'invalid', - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached('best animal'), null); + // unknown request id + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': 'invalid', + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect(await client.encryption!.ssss.getCached('best animal'), null); - // secret not a string - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request('best animal', [key]); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': 42, - }, - encryptedContent: { - 'sender_key': key.curve25519Key, - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect(await client.encryption!.ssss.getCached('best animal'), null); + // not from a device we sent the request to + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption!.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': 'invalid', + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect(await client.encryption!.ssss.getCached('best animal'), null); - // validator doesn't check out - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.request(EventTypes.MegolmBackup, [key]); - event = ToDeviceEvent( - sender: client.userID!, - type: 'm.secret.send', - content: { - 'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, - 'secret': 'foxies!', - }, - encryptedContent: { - 'sender_key': key.curve25519Key, - }, - ); - await client.encryption!.ssss.handleToDeviceEvent(event); - expect( - await client.encryption!.ssss.getCached(EventTypes.MegolmBackup), - null, - ); - }); + // secret not a string + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption!.ssss.pendingShareRequests.keys.first, + 'secret': 42, + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect(await client.encryption!.ssss.getCached('best animal'), null); - test('request all', () async { - final key = - client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; - key.setDirectVerified(true); - await client.encryption!.ssss.clearCache(); - client.encryption!.ssss.pendingShareRequests.clear(); - await client.encryption!.ssss.maybeRequestAll([key]); - expect(client.encryption!.ssss.pendingShareRequests.length, 3); - }); + // validator doesn't check out + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.request(EventTypes.MegolmBackup, [key]); + event = ToDeviceEvent( + sender: client.userID!, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption!.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption!.ssss.handleToDeviceEvent(event); + expect( + await client.encryption!.ssss.getCached(EventTypes.MegolmBackup), + null, + ); + }); - test('periodicallyRequestMissingCache', () async { - client.userDeviceKeys[client.userID!]!.masterKey!.setDirectVerified(true); - client.encryption!.ssss = MockSSSS(client.encryption!); - (client.encryption!.ssss as MockSSSS).requestedSecrets = false; - await client.encryption!.ssss.periodicallyRequestMissingCache(); - expect((client.encryption!.ssss as MockSSSS).requestedSecrets, true); - // it should only retry once every 15 min - (client.encryption!.ssss as MockSSSS).requestedSecrets = false; - await client.encryption!.ssss.periodicallyRequestMissingCache(); - expect((client.encryption!.ssss as MockSSSS).requestedSecrets, false); - }); + test('request all', () async { + final key = + client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; + key.setDirectVerified(true); + await client.encryption!.ssss.clearCache(); + client.encryption!.ssss.pendingShareRequests.clear(); + await client.encryption!.ssss.maybeRequestAll([key]); + expect(client.encryption!.ssss.pendingShareRequests.length, 3); + }); - test('createKey', () async { - // with passphrase - var newKey = await client.encryption!.ssss.createKey('test'); - expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); - var testKey = client.encryption!.ssss.open(newKey.keyId); - await testKey.unlock(passphrase: 'test'); - await testKey.setPrivateKey(newKey.privateKey!); + test('periodicallyRequestMissingCache', () async { + client.userDeviceKeys[client.userID!]!.masterKey! + .setDirectVerified(true); + client.encryption!.ssss = MockSSSS(client.encryption!); + (client.encryption!.ssss as MockSSSS).requestedSecrets = false; + await client.encryption!.ssss.periodicallyRequestMissingCache(); + expect((client.encryption!.ssss as MockSSSS).requestedSecrets, true); + // it should only retry once every 15 min + (client.encryption!.ssss as MockSSSS).requestedSecrets = false; + await client.encryption!.ssss.periodicallyRequestMissingCache(); + expect((client.encryption!.ssss as MockSSSS).requestedSecrets, false); + }); - // without passphrase - newKey = await client.encryption!.ssss.createKey(); - expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); - testKey = client.encryption!.ssss.open(newKey.keyId); - await testKey.setPrivateKey(newKey.privateKey!); - }); + test('createKey', () async { + // with passphrase + var newKey = await client.encryption!.ssss.createKey('test'); + expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); + var testKey = client.encryption!.ssss.open(newKey.keyId); + await testKey.unlock(passphrase: 'test'); + await testKey.setPrivateKey(newKey.privateKey!); - test('dispose client', () async { - await client.dispose(closeDatabase: true); - }); - }); + // without passphrase + newKey = await client.encryption!.ssss.createKey(); + expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); + testKey = client.encryption!.ssss.open(newKey.keyId); + await testKey.setPrivateKey(newKey.privateKey!); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }, + timeout: Timeout(const Duration(minutes: 2)), + ); } diff --git a/test/matrix_file_test.dart b/test/matrix_file_test.dart index 4dc11668..3c19f175 100644 --- a/test/matrix_file_test.dart +++ b/test/matrix_file_test.dart @@ -22,10 +22,14 @@ import 'package:http/http.dart' as http; import 'package:test/test.dart'; import 'package:matrix/matrix.dart'; +import 'fake_client.dart'; void main() { /// All Tests related to device keys group('Matrix File', tags: 'olm', () { + setUpAll(() async { + await getClient(); // To trigger vodozemac init + }); Logs().level = Level.error; test('Decrypt', () async { final text = 'hello world'; From 014fc7395723347a3e96828cd0f3c777407db472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 24 Sep 2025 11:54:26 +0200 Subject: [PATCH 12/15] refactor: Support matrix spec 1.16 --- lib/matrix_api_lite/generated/api.dart | 454 +++++--- lib/matrix_api_lite/generated/model.dart | 1242 +++++++++++++++++++--- lib/src/client.dart | 13 +- 3 files changed, 1420 insertions(+), 289 deletions(-) diff --git a/lib/matrix_api_lite/generated/api.dart b/lib/matrix_api_lite/generated/api.dart index c571cf34..f01ad0e6 100644 --- a/lib/matrix_api_lite/generated/api.dart +++ b/lib/matrix_api_lite/generated/api.dart @@ -34,6 +34,12 @@ class Api { /// suitably namespaced for each application and reduces the risk of /// clashes. /// + /// **NOTE:** + /// This endpoint should be accessed with the hostname of the homeserver's + /// [server name](https://spec.matrix.org/unstable/appendices/#server-name) by making a + /// GET request to `https://hostname/.well-known/matrix/client`. + /// + /// /// Note that this endpoint is not necessarily handled by the homeserver, /// but by another webserver, to be used for discovering the homeserver URL. Future getWellknown() async { @@ -49,10 +55,13 @@ class Api { /// Gets server admin contact and support page of the domain. /// - /// Like the [well-known discovery URI](https://spec.matrix.org/unstable/client-server-api/#well-known-uri), - /// this should be accessed with the hostname of the homeserver by making a + /// **NOTE:** + /// Like the [well-known discovery URI](https://spec.matrix.org/unstable/client-server-api/#well-known-uris), + /// this endpoint should be accessed with the hostname of the homeserver's + /// [server name](https://spec.matrix.org/unstable/appendices/#server-name) by making a /// GET request to `https://hostname/.well-known/matrix/support`. /// + /// /// Note that this endpoint is not necessarily handled by the homeserver. /// It may be served by another webserver, used for discovering support /// information for the homeserver. @@ -112,6 +121,36 @@ class Api { return json['duration_ms'] as int; } + /// Gets the OAuth 2.0 authorization server metadata, as defined in + /// [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414), including the + /// endpoint URLs and the supported parameters that can be used by the + /// clients. + /// + /// This endpoint definition includes only the fields that are meaningful in + /// the context of the Matrix specification. The full list of possible + /// fields is available in the [OAuth Authorization Server Metadata + /// registry](https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#authorization-server-metadata), + /// and normative definitions of them are available in their respective + /// RFCs. + /// + /// **NOTE:** + /// The authorization server metadata is relatively large and may change + /// over time. Clients should: + /// + /// - Cache the metadata appropriately based on HTTP caching headers + /// - Refetch the metadata if it is stale + /// + Future getAuthMetadata() async { + final requestUri = Uri(path: '_matrix/client/v1/auth_metadata'); + final request = Request('GET', baseUri!.resolveUri(requestUri)); + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return GetAuthMetadataResponse.fromJson(json as Map); + } + /// Optional endpoint - the server is not required to implement this endpoint if it does not /// intend to use or support this functionality. /// @@ -169,12 +208,12 @@ class Api { /// All values are intentionally left optional. Clients SHOULD follow /// the advice given in the field description when the field is not available. /// - /// {{% boxes/note %}} + /// **NOTE:** /// Both clients and server administrators should be aware that proxies /// between the client and the server may affect the apparent behaviour of content /// repository APIs, for example, proxies may enforce a lower upload size limit /// than is advertised by the server on this endpoint. - /// {{% /boxes/note %}} + /// Future getConfigAuthed() async { final requestUri = Uri(path: '_matrix/client/v1/media/config'); final request = Request('GET', baseUri!.resolveUri(requestUri)); @@ -187,11 +226,11 @@ class Api { return MediaConfig.fromJson(json as Map); } - /// {{% boxes/note %}} + /// **NOTE:** /// Clients SHOULD NOT generate or use URLs which supply the access token in /// the query string. These URLs may be copied by users verbatim and provided /// in a chat message to another user, disclosing the sender's access token. - /// {{% /boxes/note %}} + /// /// /// Clients MAY be redirected using the 307/308 responses below to download /// the request object. This is typical when the homeserver uses a Content @@ -236,11 +275,11 @@ class Api { /// the previous endpoint) but replaces the target file name with the one /// provided by the caller. /// - /// {{% boxes/note %}} + /// **NOTE:** /// Clients SHOULD NOT generate or use URLs which supply the access token in /// the query string. These URLs may be copied by users verbatim and provided /// in a chat message to another user, disclosing the sender's access token. - /// {{% /boxes/note %}} + /// /// /// Clients MAY be redirected using the 307/308 responses below to download /// the request object. This is typical when the homeserver uses a Content @@ -287,12 +326,12 @@ class Api { /// Get information about a URL for the client. Typically this is called when a /// client sees a URL in a message and wants to render a preview for the user. /// - /// {{% boxes/note %}} + /// **NOTE:** /// Clients should consider avoiding this endpoint for URLs posted in encrypted /// rooms. Encrypted rooms often contain more sensitive information the users /// do not want to share with the homeserver, and this can mean that the URLs /// being shared should also not be shared with the homeserver. - /// {{% /boxes/note %}} + /// /// /// [url] The URL to get a preview of. /// @@ -320,11 +359,11 @@ class Api { /// Download a thumbnail of content from the content repository. /// See the [Thumbnails](https://spec.matrix.org/unstable/client-server-api/#thumbnails) section for more information. /// - /// {{% boxes/note %}} + /// **NOTE:** /// Clients SHOULD NOT generate or use URLs which supply the access token in /// the query string. These URLs may be copied by users verbatim and provided /// in a chat message to another user, disclosing the sender's access token. - /// {{% /boxes/note %}} + /// /// /// Clients MAY be redirected using the 307/308 responses below to download /// the request object. This is typical when the homeserver uses a Content @@ -427,6 +466,48 @@ class Api { return json['valid'] as bool; } + /// Retrieves a summary for a room. + /// + /// Clients should note that requests for rooms where the user's membership + /// is `invite` or `knock` might yield outdated, partial or even no data + /// since the server may not have access to the current state of the room. + /// + /// Servers MAY allow unauthenticated access to this API if at least one of + /// the following conditions holds true: + /// + /// - The room has a [join rule](#mroomjoin_rules) of `public`, `knock` or + /// `knock_restricted`. + /// - The room has a `world_readable` [history visibility](#room-history-visibility). + /// + /// Servers should consider rate limiting requests that require a federation + /// request more heavily if the client is unauthenticated. + /// + /// [roomIdOrAlias] The room identifier or alias to summarise. + /// + /// [via] The servers to attempt to request the summary from when + /// the local server cannot generate it (for instance, because + /// it has no local user in the room). + Future getRoomSummary( + String roomIdOrAlias, { + List? via, + }) async { + final requestUri = Uri( + path: + '_matrix/client/v1/room_summary/${Uri.encodeComponent(roomIdOrAlias)}', + queryParameters: { + if (via != null) 'via': via, + }, + ); + final request = Request('GET', baseUri!.resolveUri(requestUri)); + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return GetRoomSummaryResponse$3.fromJson(json as Map); + } + /// Paginates over the space tree in a depth-first manner to locate child rooms of a given space. /// /// Where a child room is unknown to the local server, federation is used to fill in the details. @@ -915,6 +996,11 @@ class Api { /// Homeservers should prevent the caller from adding a 3PID to their account if it has /// already been added to another user's account on the homeserver. /// + /// **WARNING:** + /// Since this endpoint uses User-Interactive Authentication, it cannot be used when the access token was obtained + /// via the [OAuth 2.0 API](https://spec.matrix.org/unstable/client-server-api/#oauth-20-api). + /// + /// /// [auth] Additional authentication information for the /// user-interactive authentication API. /// @@ -1588,9 +1674,23 @@ class Api { /// 2. An `m.room.member` event for the creator to join the room. This is /// needed so the remaining events can be sent. /// - /// 3. A default `m.room.power_levels` event, giving the room creator - /// (and not other members) permission to send state events. Overridden - /// by the `power_level_content_override` parameter. + /// 3. A default `m.room.power_levels` event. Overridden by the + /// `power_level_content_override` parameter. + /// + /// In [room versions](https://spec.matrix.org/unstable/rooms) 1 through 11, the room creator (and not + /// other members) will be given permission to send state events. + /// + /// In room versions 12 and later, the room creator is given infinite + /// power level and cannot be specified in the `users` field of + /// `m.room.power_levels`, so is not listed explicitly. + /// + /// **Note**: For `trusted_private_chat`, the users specified in the + /// `invite` parameter SHOULD also be appended to `additional_creators` + /// by the server, per the `creation_content` parameter. + /// + /// If the room's version is 12 or higher, the power level for sending + /// `m.room.tombstone` events MUST explicitly be higher than `state_default`. + /// For example, set to 150 instead of 100. /// /// 4. An `m.room.canonical_alias` event if `room_alias_name` is given. /// @@ -1616,13 +1716,20 @@ class Api { /// /// The server will create a `m.room.create` event in the room with the /// requesting user as the creator, alongside other keys provided in the - /// `creation_content`. + /// `creation_content` or implied by behaviour of `creation_content`. /// /// [creationContent] Extra keys, such as `m.federate`, to be added to the content - /// of the [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate) event. The server will overwrite the following + /// of the [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate) event. + /// + /// The server will overwrite the following /// keys: `creator`, `room_version`. Future versions of the specification /// may allow the server to overwrite other keys. /// + /// When using the `trusted_private_chat` preset, the server SHOULD combine + /// `additional_creators` specified here and the `invite` array into the + /// eventual `m.room.create` event's `additional_creators`, deduplicating + /// between the two parameters. + /// /// [initialState] A list of state events to set in the new room. This allows /// the user to override the default state events set in the new /// room. The expected format of the state events are an object @@ -1641,9 +1748,10 @@ class Api { /// `m.room.member` events sent to the users in `invite` and /// `invite_3pid`. See [Direct Messaging](https://spec.matrix.org/unstable/client-server-api/#direct-messaging) for more information. /// - /// [name] If this is included, an `m.room.name` event will be sent - /// into the room to indicate the name of the room. See Room - /// Events for more information on `m.room.name`. + /// [name] If this is included, an [`m.room.name`](https://spec.matrix.org/unstable/client-server-api/#mroomname) event + /// will be sent into the room to indicate the name for the room. + /// This overwrites any [`m.room.name`](https://spec.matrix.org/unstable/client-server-api/#mroomname) + /// event in `initial_state`. /// /// [powerLevelContentOverride] The power level content to override in the default power level /// event. This object is applied on top of the generated @@ -1675,16 +1783,14 @@ class Api { /// 400 error with the errcode `M_UNSUPPORTED_ROOM_VERSION` if it does not /// support the room version. /// - /// [topic] If this is included, an `m.room.topic` event will be sent - /// into the room to indicate the topic for the room. See Room - /// Events for more information on `m.room.topic`. + /// [topic] If this is included, an [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic) + /// event with a `text/plain` mimetype will be sent into the room + /// to indicate the topic for the room. This overwrites any + /// [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic) event in `initial_state`. /// - /// [visibility] A `public` visibility indicates that the room will be shown - /// in the published room list. A `private` visibility will hide - /// the room from the published room list. Rooms default to - /// `private` visibility if this key is not included. NB: This - /// should not be confused with `join_rules` which also uses the - /// word `public`. + /// [visibility] The room's visibility in the server's + /// [published room directory](https://spec.matrix.org/unstable/client-server-api#published-room-directory). + /// Defaults to `private`. /// /// returns `room_id`: /// The created room's ID. @@ -1737,6 +1843,11 @@ class Api { /// /// Deletes the given devices, and invalidates any access token associated with them. /// + /// **WARNING:** + /// Since this endpoint uses User-Interactive Authentication, it cannot be used when the access token was obtained + /// via the [OAuth 2.0 API](https://spec.matrix.org/unstable/client-server-api/#oauth-20-api). + /// + /// /// [auth] Additional authentication information for the /// user-interactive authentication API. /// @@ -1787,6 +1898,11 @@ class Api { /// /// Deletes the given device, and invalidates any access token associated with it. /// + /// **WARNING:** + /// Since this endpoint uses User-Interactive Authentication, it cannot be used when the access token was obtained + /// via the [OAuth 2.0 API](https://spec.matrix.org/unstable/client-server-api/#oauth-20-api). + /// + /// /// [deviceId] The device to delete. /// /// [auth] Additional authentication information for the @@ -1851,11 +1967,12 @@ class Api { return ignore(json); } - /// Updates the visibility of a given room on the application service's room - /// directory. + /// Updates the visibility of a given room in the application service's + /// published room directory. /// - /// This API is similar to the room directory visibility API used by clients - /// to update the homeserver's more general room directory. + /// This API is similar to the + /// [visibility API](https://spec.matrix.org/unstable/client-server-api#put_matrixclientv3directorylistroomroomid) + /// used by clients to update the homeserver's more general published room directory. /// /// This API requires the use of an application service access token (`as_token`) /// instead of a typical client's access_token. This API cannot be invoked by @@ -1894,7 +2011,8 @@ class Api { return json as Map; } - /// Gets the visibility of a given room on the server's public room directory. + /// Gets the visibility of a given room in the server's + /// published room directory. /// /// [roomId] The room ID. /// @@ -1916,17 +2034,16 @@ class Api { : null)(json['visibility']); } - /// Sets the visibility of a given room in the server's public room - /// directory. + /// Sets the visibility of a given room in the server's published room directory. /// - /// Servers may choose to implement additional access control checks - /// here, for instance that room visibility can only be changed by - /// the room creator or a server administrator. + /// Servers MAY implement additional access control checks, for instance, + /// to ensure that a room's visibility can only be changed by the room creator + /// or a server administrator. /// /// [roomId] The room ID. /// /// [visibility] The new visibility setting for the room. - /// Defaults to 'public'. + /// Defaults to `public`. Future setRoomVisibilityOnDirectory( String roomId, { Visibility? visibility, @@ -2296,6 +2413,11 @@ class Api { /// makes this endpoint idempotent in the case where the response is lost over the network, /// which would otherwise cause a UIA challenge upon retry. /// + /// **WARNING:** + /// When this endpoint requires User-Interactive Authentication, it cannot be used when the access token was obtained + /// via the [OAuth 2.0 API](https://spec.matrix.org/unstable/client-server-api/#oauth-20-api). + /// + /// /// [auth] Additional authentication information for the /// user-interactive authentication API. /// @@ -2639,7 +2761,7 @@ class Api { /// deleted alongside the device. /// /// This endpoint does not use the [User-Interactive Authentication API](https://spec.matrix.org/unstable/client-server-api/#user-interactive-authentication-api) because - /// User-Interactive Authentication is designed to protect against attacks where the + /// User-Interactive Authentication is designed to protect against attacks where /// someone gets hold of a single access token then takes over the account. This /// endpoint invalidates all access tokens for the user, including the token used in /// the request, and therefore the attacker is unable to take over the account in @@ -2742,9 +2864,7 @@ class Api { return ignore(json); } - /// Get the combined profile information for this user. This API may be used - /// to fetch the user's own profile information or other users; either - /// locally or on remote homeservers. + /// Get the complete profile for a user. /// /// [userId] The user whose profile information to get. Future getUserProfile(String userId) async { @@ -2762,18 +2882,41 @@ class Api { return ProfileInformation.fromJson(json as Map); } - /// Get the user's avatar URL. This API may be used to fetch the user's - /// own avatar URL or to query the URL of other users; either locally or - /// on remote homeservers. + /// Remove a specific field from a user's profile. /// - /// [userId] The user whose avatar URL to get. + /// [userId] The user whose profile field should be deleted. /// - /// returns `avatar_url`: - /// The user's avatar URL if they have set one, otherwise not present. - Future getAvatarUrl(String userId) async { + /// [keyName] The name of the profile field to delete. + Future> deleteProfileField( + String userId, + String keyName, + ) async { final requestUri = Uri( path: - '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/avatar_url', + '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/${Uri.encodeComponent(keyName)}', + ); + final request = Request('DELETE', baseUri!.resolveUri(requestUri)); + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return json as Map; + } + + /// Get the value of a profile field for a user. + /// + /// [userId] The user whose profile field should be returned. + /// + /// [keyName] The name of the profile field to retrieve. + Future> getProfileField( + String userId, + String keyName, + ) async { + final requestUri = Uri( + path: + '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/${Uri.encodeComponent(keyName)}', ); final request = Request('GET', baseUri!.resolveUri(requestUri)); if (bearerToken != null) { @@ -2784,90 +2927,44 @@ class Api { if (response.statusCode != 200) unexpectedResponse(response, responseBody); final responseString = utf8.decode(responseBody); final json = jsonDecode(responseString); - return ((v) => - v != null ? Uri.parse(v as String) : null)(json['avatar_url']); + return json as Map; } - /// This API sets the given user's avatar URL. You must have permission to - /// set this user's avatar URL, e.g. you need to have their `access_token`. + /// Set or update a profile field for a user. Must be authenticated with an + /// access token authorised to make changes. Servers MAY impose size limits + /// on individual fields, and the total profile MUST be under 64 KiB. /// - /// [userId] The user whose avatar URL to set. + /// Servers MAY reject `null` values. Servers that accept `null` values SHOULD store + /// them rather than treating `null` as a deletion request. Clients that want to delete a + /// field, including its key and value, SHOULD use the `DELETE` endpoint instead. /// - /// [avatarUrl] The new avatar URL for this user. - Future setAvatarUrl(String userId, Uri? avatarUrl) async { + /// [userId] The user whose profile field should be set. + /// + /// [keyName] The name of the profile field to set. This MUST be either `avatar_url`, `displayname`, `m.tz`, or a custom field following the [Common Namespaced Identifier Grammar](https://spec.matrix.org/unstable/appendices/#common-namespaced-identifier-grammar). + /// + /// [body] A JSON object containing the property whose name matches the `keyName` specified in the URL. See `additionalProperties` for further details. + Future> setProfileField( + String userId, + String keyName, + Map body, + ) async { final requestUri = Uri( path: - '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/avatar_url', + '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/${Uri.encodeComponent(keyName)}', ); final request = Request('PUT', baseUri!.resolveUri(requestUri)); request.headers['authorization'] = 'Bearer ${bearerToken!}'; request.headers['content-type'] = 'application/json'; - request.bodyBytes = utf8.encode( - jsonEncode({ - if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), - }), - ); + request.bodyBytes = utf8.encode(jsonEncode(body)); final response = await httpClient.send(request); final responseBody = await response.stream.toBytes(); if (response.statusCode != 200) unexpectedResponse(response, responseBody); final responseString = utf8.decode(responseBody); final json = jsonDecode(responseString); - return ignore(json); + return json as Map; } - /// Get the user's display name. This API may be used to fetch the user's - /// own displayname or to query the name of other users; either locally or - /// on remote homeservers. - /// - /// [userId] The user whose display name to get. - /// - /// returns `displayname`: - /// The user's display name if they have set one, otherwise not present. - Future getDisplayName(String userId) async { - final requestUri = Uri( - path: - '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/displayname', - ); - final request = Request('GET', baseUri!.resolveUri(requestUri)); - if (bearerToken != null) { - request.headers['authorization'] = 'Bearer ${bearerToken!}'; - } - final response = await httpClient.send(request); - final responseBody = await response.stream.toBytes(); - if (response.statusCode != 200) unexpectedResponse(response, responseBody); - final responseString = utf8.decode(responseBody); - final json = jsonDecode(responseString); - return ((v) => v != null ? v as String : null)(json['displayname']); - } - - /// This API sets the given user's display name. You must have permission to - /// set this user's display name, e.g. you need to have their `access_token`. - /// - /// [userId] The user whose display name to set. - /// - /// [displayname] The new display name for this user. - Future setDisplayName(String userId, String? displayname) async { - final requestUri = Uri( - path: - '_matrix/client/v3/profile/${Uri.encodeComponent(userId)}/displayname', - ); - final request = Request('PUT', baseUri!.resolveUri(requestUri)); - request.headers['authorization'] = 'Bearer ${bearerToken!}'; - request.headers['content-type'] = 'application/json'; - request.bodyBytes = utf8.encode( - jsonEncode({ - if (displayname != null) 'displayname': displayname, - }), - ); - final response = await httpClient.send(request); - final responseBody = await response.stream.toBytes(); - if (response.statusCode != 200) unexpectedResponse(response, responseBody); - final responseString = utf8.decode(responseBody); - final json = jsonDecode(responseString); - return ignore(json); - } - - /// Lists the public rooms on the server. + /// Lists a server's published room directory. /// /// This API returns paginated responses. The rooms are ordered by the number /// of joined members, with the largest rooms first. @@ -2879,8 +2976,8 @@ class Api { /// The direction of pagination is specified solely by which token /// is supplied, rather than via an explicit flag. /// - /// [server] The server to fetch the public room lists from. Defaults to the - /// local server. Case sensitive. + /// [server] The server to fetch the published room directory from. Defaults + /// to the local server. Case sensitive. Future getPublicRooms({ int? limit, String? since, @@ -2903,13 +3000,13 @@ class Api { return GetPublicRoomsResponse.fromJson(json as Map); } - /// Lists the public rooms on the server, with optional filter. + /// Lists a server's published room directory with an optional filter. /// /// This API returns paginated responses. The rooms are ordered by the number /// of joined members, with the largest rooms first. /// - /// [server] The server to fetch the public room lists from. Defaults to the - /// local server. Case sensitive. + /// [server] The server to fetch the published room directory from. Defaults + /// to the local server. Case sensitive. /// /// [filter] Filter to apply to the results. /// @@ -4134,9 +4231,6 @@ class Api { /// /// - The matrix user ID who invited them to the room /// - /// If a token is requested from the identity server, the homeserver will - /// append a `m.room.third_party_invite` event to the room. - /// /// [roomId] The room identifier (not alias) to which to invite the user. /// /// [body] @@ -4739,14 +4833,23 @@ class Api { /// /// [stateKey] The key of the state to look up. Defaults to an empty string. When /// an empty string, the trailing slash on this endpoint is optional. + /// + /// [format] The format to use for the returned data. `content` (the default) will + /// return only the content of the state event. `event` will return the entire + /// event in the usual format suitable for clients, including fields like event + /// ID, sender and timestamp. Future> getRoomStateWithKey( String roomId, String eventType, - String stateKey, - ) async { + String stateKey, { + Format? format, + }) async { final requestUri = Uri( path: '_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/state/${Uri.encodeComponent(eventType)}/${Uri.encodeComponent(stateKey)}', + queryParameters: { + if (format != null) 'format': format.name, + }, ); final request = Request('GET', baseUri!.resolveUri(requestUri)); request.headers['authorization'] = 'Bearer ${bearerToken!}'; @@ -4886,11 +4989,26 @@ class Api { /// /// [roomId] The ID of the room to upgrade. /// + /// [additionalCreators] When upgrading to a [room version](https://spec.matrix.org/unstable/rooms) which supports additional creators, + /// the [user IDs](https://spec.matrix.org/unstable/appendices#user-identifiers) which should be considered room + /// creators in addition to the user performing the upgrade. + /// + /// If the room being upgraded has additional creators, they are *not* automatically + /// copied to the new room. The full set of additional creators needs to be set to + /// retain (or add/remove) more room creators. + /// + /// When upgrading to a room version which doesn't support additional creators, this + /// field is ignored and has no effect during the upgrade process. + /// /// [newVersion] The new version for the room. /// /// returns `replacement_room`: /// The ID of the new room. - Future upgradeRoom(String roomId, String newVersion) async { + Future upgradeRoom( + String roomId, + String newVersion, { + List? additionalCreators, + }) async { final requestUri = Uri( path: '_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/upgrade', ); @@ -4899,6 +5017,8 @@ class Api { request.headers['content-type'] = 'application/json'; request.bodyBytes = utf8.encode( jsonEncode({ + if (additionalCreators != null) + 'additional_creators': additionalCreators.map((v) => v).toList(), 'new_version': newVersion, }), ); @@ -5043,12 +5163,32 @@ class Api { /// /// By default, this is `0`, so the server will return immediately /// even if the response is empty. + /// + /// [useStateAfter] Controls whether to receive state changes between the previous sync + /// and the **start** of the timeline, or between the previous sync and + /// the **end** of the timeline. + /// + /// If this is set to `true`, servers MUST respond with the state + /// between the previous sync and the **end** of the timeline in + /// `state_after` and MUST omit `state`. + /// + /// If `false`, servers MUST respond with the state between the previous + /// sync and the **start** of the timeline in `state` and MUST omit + /// `state_after`. + /// + /// Even if this is set to `true`, clients MUST update their local state + /// with events in `state` and `timeline` if `state_after` is missing in + /// the response, for compatibility with servers that don't support this + /// parameter. + /// + /// By default, this is `false`. Future sync({ String? filter, String? since, bool? fullState, PresenceType? setPresence, int? timeout, + bool? useStateAfter, }) async { final requestUri = Uri( path: '_matrix/client/v3/sync', @@ -5058,6 +5198,7 @@ class Api { if (fullState != null) 'full_state': fullState.toString(), if (setPresence != null) 'set_presence': setPresence.name, if (timeout != null) 'timeout': timeout.toString(), + if (useStateAfter != null) 'use_state_after': useStateAfter.toString(), }, ); final request = Request('GET', baseUri!.resolveUri(requestUri)); @@ -5507,10 +5648,17 @@ class Api { return ignore(json); } - /// Performs a search for users. The homeserver may - /// determine which subset of users are searched, however the homeserver - /// MUST at a minimum consider the users the requesting user shares a - /// room with and those who reside in public rooms (known to the homeserver). + /// Performs a search for users. The homeserver may determine which + /// subset of users are searched. However, the homeserver MUST at a + /// minimum consider users who are visible to the requester based + /// on their membership in rooms known to the homeserver. This means: + /// + /// - users that share a room with the requesting user + /// - users who are joined to rooms known to the homeserver that have a + /// `public` [join rule](#mroomjoin_rules) + /// - users who are joined to rooms known to the homeserver that have a + /// `world_readable` [history visibility](#room-history-visibility) + /// /// The search MUST consider local users to the homeserver, and SHOULD /// query remote users as part of the search. /// @@ -5663,9 +5811,9 @@ class Api { return CreateContentResponse.fromJson(json as Map); } - /// {{% boxes/note %}} + /// **NOTE:** /// Replaced by [`GET /_matrix/client/v1/media/config`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediaconfig). - /// {{% /boxes/note %}} + /// /// /// This endpoint allows clients to retrieve the configuration of the content /// repository, such as upload limitations. @@ -5690,17 +5838,17 @@ class Api { return MediaConfig.fromJson(json as Map); } - /// {{% boxes/note %}} + /// **NOTE:** /// Replaced by [`GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid) /// (requires authentication). - /// {{% /boxes/note %}} /// - /// {{% boxes/warning %}} - /// {{% changed-in v="1.11" %}} This endpoint MAY return `404 M_NOT_FOUND` + /// + /// **WARNING:** + /// **[Changed in `v1.11`]** This endpoint MAY return `404 M_NOT_FOUND` /// for media which exists, but is after the server froze unauthenticated /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more /// information. - /// {{% /boxes/warning %}} + /// /// /// [serverName] The server name from the `mxc://` URI (the authority component). /// @@ -5752,21 +5900,21 @@ class Api { ); } - /// {{% boxes/note %}} + /// **NOTE:** /// Replaced by [`GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediadownloadservernamemediaidfilename) /// (requires authentication). - /// {{% /boxes/note %}} + /// /// /// This will download content from the content repository (same as /// the previous endpoint) but replace the target file name with the one /// provided by the caller. /// - /// {{% boxes/warning %}} - /// {{% changed-in v="1.11" %}} This endpoint MAY return `404 M_NOT_FOUND` + /// **WARNING:** + /// **[Changed in `v1.11`]** This endpoint MAY return `404 M_NOT_FOUND` /// for media which exists, but is after the server froze unauthenticated /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more /// information. - /// {{% /boxes/warning %}} + /// /// /// [serverName] The server name from the `mxc://` URI (the authority component). /// @@ -5821,9 +5969,9 @@ class Api { ); } - /// {{% boxes/note %}} + /// **NOTE:** /// Replaced by [`GET /_matrix/client/v1/media/preview_url`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediapreview_url). - /// {{% /boxes/note %}} + /// /// /// Get information about a URL for the client. Typically this is called when a /// client sees a URL in a message and wants to render a preview for the user. @@ -5858,20 +6006,20 @@ class Api { return PreviewForUrl.fromJson(json as Map); } - /// {{% boxes/note %}} + /// **NOTE:** /// Replaced by [`GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid) /// (requires authentication). - /// {{% /boxes/note %}} + /// /// /// Download a thumbnail of content from the content repository. /// See the [Thumbnails](https://spec.matrix.org/unstable/client-server-api/#thumbnails) section for more information. /// - /// {{% boxes/warning %}} - /// {{% changed-in v="1.11" %}} This endpoint MAY return `404 M_NOT_FOUND` + /// **WARNING:** + /// **[Changed in `v1.11`]** This endpoint MAY return `404 M_NOT_FOUND` /// for media which exists, but is after the server froze unauthenticated /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more /// information. - /// {{% /boxes/warning %}} + /// /// /// [serverName] The server name from the `mxc://` URI (the authority component). /// diff --git a/lib/matrix_api_lite/generated/model.dart b/lib/matrix_api_lite/generated/model.dart index d31e6cba..a3208e0c 100644 --- a/lib/matrix_api_lite/generated/model.dart +++ b/lib/matrix_api_lite/generated/model.dart @@ -246,6 +246,157 @@ class GetWellknownSupportResponse { int get hashCode => Object.hash(contacts, supportPage); } +/// +@_NameSource('generated') +class GetAuthMetadataResponse { + GetAuthMetadataResponse({ + required this.authorizationEndpoint, + required this.codeChallengeMethodsSupported, + required this.grantTypesSupported, + required this.issuer, + this.promptValuesSupported, + required this.registrationEndpoint, + required this.responseModesSupported, + required this.responseTypesSupported, + required this.revocationEndpoint, + required this.tokenEndpoint, + }); + + GetAuthMetadataResponse.fromJson(Map json) + : authorizationEndpoint = + Uri.parse(json['authorization_endpoint'] as String), + codeChallengeMethodsSupported = + (json['code_challenge_methods_supported'] as List) + .map((v) => v as String) + .toList(), + grantTypesSupported = (json['grant_types_supported'] as List) + .map((v) => v as String) + .toList(), + issuer = Uri.parse(json['issuer'] as String), + promptValuesSupported = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['prompt_values_supported']), + registrationEndpoint = + Uri.parse(json['registration_endpoint'] as String), + responseModesSupported = (json['response_modes_supported'] as List) + .map((v) => v as String) + .toList(), + responseTypesSupported = (json['response_types_supported'] as List) + .map((v) => v as String) + .toList(), + revocationEndpoint = Uri.parse(json['revocation_endpoint'] as String), + tokenEndpoint = Uri.parse(json['token_endpoint'] as String); + Map toJson() { + final promptValuesSupported = this.promptValuesSupported; + return { + 'authorization_endpoint': authorizationEndpoint.toString(), + 'code_challenge_methods_supported': + codeChallengeMethodsSupported.map((v) => v).toList(), + 'grant_types_supported': grantTypesSupported.map((v) => v).toList(), + 'issuer': issuer.toString(), + if (promptValuesSupported != null) + 'prompt_values_supported': promptValuesSupported.map((v) => v).toList(), + 'registration_endpoint': registrationEndpoint.toString(), + 'response_modes_supported': responseModesSupported.map((v) => v).toList(), + 'response_types_supported': responseTypesSupported.map((v) => v).toList(), + 'revocation_endpoint': revocationEndpoint.toString(), + 'token_endpoint': tokenEndpoint.toString(), + }; + } + + /// URL of the authorization endpoint, necessary to use the authorization code + /// grant. + Uri authorizationEndpoint; + + /// List of OAuth 2.0 Proof Key for Code Exchange (PKCE) code challenge methods + /// that the server supports at the authorization endpoint. + /// + /// This array MUST contain at least the `S256` value, for improved security in + /// the authorization code grant. + List codeChallengeMethodsSupported; + + /// List of OAuth 2.0 grant type strings that the server supports at the token + /// endpoint. + /// + /// This array MUST contain at least the `authorization_code` and `refresh_token` + /// values, for clients to be able to use the authorization code grant and refresh + /// token grant, respectively. + List grantTypesSupported; + + /// The authorization server's issuer identifier, which is a URL that uses the + /// `https` scheme and has no query or fragment components. + /// + /// This is not used in the context of the Matrix specification, but is required + /// by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). + Uri issuer; + + /// List of OpenID Connect prompt values that the server supports at the + /// authorization endpoint. + /// + /// Only the `create` value defined in [Initiating User Registration via OpenID + /// Connect](https://openid.net/specs/openid-connect-prompt-create-1_0.html) is + /// supported, for a client to signal to the server that the user desires to + /// register a new account. + List? promptValuesSupported; + + /// URL of the client registration endpoint, necessary to perform dynamic + /// registration of a client. + Uri registrationEndpoint; + + /// List of OAuth 2.0 response mode strings that the server supports at the + /// authorization endpoint. + /// + /// This array MUST contain at least the `query` and `fragment` values, for + /// improved security in the authorization code grant. + List responseModesSupported; + + /// List of OAuth 2.0 response type strings that the server supports at the + /// authorization endpoint. + /// + /// This array MUST contain at least the `code` value, for clients to be able to + /// use the authorization code grant. + List responseTypesSupported; + + /// URL of the revocation endpoint, necessary to log out a client by invalidating + /// its access and refresh tokens. + Uri revocationEndpoint; + + /// URL of the token endpoint, necessary to use the authorization code grant and + /// the refresh token grant. + Uri tokenEndpoint; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is GetAuthMetadataResponse && + other.runtimeType == runtimeType && + other.authorizationEndpoint == authorizationEndpoint && + other.codeChallengeMethodsSupported == + codeChallengeMethodsSupported && + other.grantTypesSupported == grantTypesSupported && + other.issuer == issuer && + other.promptValuesSupported == promptValuesSupported && + other.registrationEndpoint == registrationEndpoint && + other.responseModesSupported == responseModesSupported && + other.responseTypesSupported == responseTypesSupported && + other.revocationEndpoint == revocationEndpoint && + other.tokenEndpoint == tokenEndpoint); + + @dart.override + int get hashCode => Object.hash( + authorizationEndpoint, + codeChallengeMethodsSupported, + grantTypesSupported, + issuer, + promptValuesSupported, + registrationEndpoint, + responseModesSupported, + responseTypesSupported, + revocationEndpoint, + tokenEndpoint, + ); +} + /// @_NameSource('generated') class GenerateLoginTokenResponse { @@ -366,8 +517,8 @@ enum Method { /// @_NameSource('spec') -class PublicRoomsChunk { - PublicRoomsChunk({ +class PublishedRoomsChunk { + PublishedRoomsChunk({ this.avatarUrl, this.canonicalAlias, required this.guestCanJoin, @@ -380,7 +531,7 @@ class PublicRoomsChunk { required this.worldReadable, }); - PublicRoomsChunk.fromJson(Map json) + PublishedRoomsChunk.fromJson(Map json) : avatarUrl = ((v) => v != null ? Uri.parse(v as String) : null)(json['avatar_url']), canonicalAlias = @@ -441,16 +592,17 @@ class PublicRoomsChunk { /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. String? roomType; - /// The topic of the room, if any. + /// The plain text topic of the room. Omitted if no `text/plain` mimetype + /// exists in [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic). String? topic; - /// Whether the room may be viewed by guest users without joining. + /// Whether the room may be viewed by users without joining. bool worldReadable; @dart.override bool operator ==(Object other) => identical(this, other) || - (other is PublicRoomsChunk && + (other is PublishedRoomsChunk && other.runtimeType == runtimeType && other.avatarUrl == avatarUrl && other.canonicalAlias == canonicalAlias && @@ -479,51 +631,71 @@ class PublicRoomsChunk { } /// -@_NameSource('spec') -class SpaceHierarchyRoomsChunk { - SpaceHierarchyRoomsChunk({ - required this.childrenState, +@_NameSource('generated') +class GetRoomSummaryResponse$1 { + GetRoomSummaryResponse$1({ + this.allowedRoomIds, + this.encryption, this.roomType, + this.roomVersion, }); - SpaceHierarchyRoomsChunk.fromJson(Map json) - : childrenState = (json['children_state'] as List) - .map((v) => ChildrenState.fromJson(v as Map)) - .toList(), - roomType = ((v) => v != null ? v as String : null)(json['room_type']); + GetRoomSummaryResponse$1.fromJson(Map json) + : allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']); Map toJson() { + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; final roomType = this.roomType; + final roomVersion = this.roomVersion; return { - 'children_state': childrenState.map((v) => v.toJson()).toList(), + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, if (roomType != null) 'room_type': roomType, + if (roomVersion != null) 'room_version': roomVersion, }; } - /// The [`m.space.child`](https://spec.matrix.org/unstable/client-server-api/#mspacechild) events of the space-room, represented - /// as [Stripped State Events](https://spec.matrix.org/unstable/client-server-api/#stripped-state) with an added `origin_server_ts` key. - /// - /// If the room is not a space-room, this should be empty. - List childrenState; + /// If the room is a [restricted room](https://spec.matrix.org/unstable/server-server-api/#restricted-rooms), these are the room IDs which + /// are specified by the join rules. Empty or omitted otherwise. + List? allowedRoomIds; + + /// The encryption algorithm to be used to encrypt messages sent in the + /// room. + String? encryption; /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. String? roomType; + /// The version of the room. + String? roomVersion; + @dart.override bool operator ==(Object other) => identical(this, other) || - (other is SpaceHierarchyRoomsChunk && + (other is GetRoomSummaryResponse$1 && other.runtimeType == runtimeType && - other.childrenState == childrenState && - other.roomType == roomType); + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomType == roomType && + other.roomVersion == roomVersion); @dart.override - int get hashCode => Object.hash(childrenState, roomType); + int get hashCode => + Object.hash(allowedRoomIds, encryption, roomType, roomVersion); } /// -@_NameSource('rule override generated') -class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { - SpaceRoomsChunk({ +@_NameSource('spec') +class RoomSummary$1 implements PublishedRoomsChunk, GetRoomSummaryResponse$1 { + RoomSummary$1({ this.avatarUrl, this.canonicalAlias, required this.guestCanJoin, @@ -534,10 +706,12 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { this.roomType, this.topic, required this.worldReadable, - required this.childrenState, + this.allowedRoomIds, + this.encryption, + this.roomVersion, }); - SpaceRoomsChunk.fromJson(Map json) + RoomSummary$1.fromJson(Map json) : avatarUrl = ((v) => v != null ? Uri.parse(v as String) : null)(json['avatar_url']), canonicalAlias = @@ -550,9 +724,13 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { roomType = ((v) => v != null ? v as String : null)(json['room_type']), topic = ((v) => v != null ? v as String : null)(json['topic']), worldReadable = json['world_readable'] as bool, - childrenState = (json['children_state'] as List) - .map((v) => ChildrenState.fromJson(v as Map)) - .toList(); + allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']); @override Map toJson() { final avatarUrl = this.avatarUrl; @@ -561,6 +739,9 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { final name = this.name; final roomType = this.roomType; final topic = this.topic; + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomVersion = this.roomVersion; return { if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), if (canonicalAlias != null) 'canonical_alias': canonicalAlias, @@ -572,7 +753,10 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { if (roomType != null) 'room_type': roomType, if (topic != null) 'topic': topic, 'world_readable': worldReadable, - 'children_state': childrenState.map((v) => v.toJson()).toList(), + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, + if (roomVersion != null) 'room_version': roomVersion, }; } @@ -611,25 +795,33 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { @override String? roomType; - /// The topic of the room, if any. + /// The plain text topic of the room. Omitted if no `text/plain` mimetype + /// exists in [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic). @override String? topic; - /// Whether the room may be viewed by guest users without joining. + /// Whether the room may be viewed by users without joining. @override bool worldReadable; - /// The [`m.space.child`](https://spec.matrix.org/unstable/client-server-api/#mspacechild) events of the space-room, represented - /// as [Stripped State Events](https://spec.matrix.org/unstable/client-server-api/#stripped-state) with an added `origin_server_ts` key. - /// - /// If the room is not a space-room, this should be empty. + /// If the room is a [restricted room](https://spec.matrix.org/unstable/server-server-api/#restricted-rooms), these are the room IDs which + /// are specified by the join rules. Empty or omitted otherwise. @override - List childrenState; + List? allowedRoomIds; + + /// The encryption algorithm to be used to encrypt messages sent in the + /// room. + @override + String? encryption; + + /// The version of the room. + @override + String? roomVersion; @dart.override bool operator ==(Object other) => identical(this, other) || - (other is SpaceRoomsChunk && + (other is RoomSummary$1 && other.runtimeType == runtimeType && other.avatarUrl == avatarUrl && other.canonicalAlias == canonicalAlias && @@ -641,6 +833,707 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { other.roomType == roomType && other.topic == topic && other.worldReadable == worldReadable && + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomVersion == roomVersion); + + @dart.override + int get hashCode => Object.hash( + avatarUrl, + canonicalAlias, + guestCanJoin, + joinRule, + name, + numJoinedMembers, + roomId, + roomType, + topic, + worldReadable, + allowedRoomIds, + encryption, + roomVersion, + ); +} + +/// +@_NameSource('(generated, rule override generated)') +enum Membership { + ban('ban'), + invite('invite'), + join('join'), + knock('knock'), + leave('leave'); + + final String name; + const Membership(this.name); +} + +/// +@_NameSource('generated') +class GetRoomSummaryResponse$2 { + GetRoomSummaryResponse$2({ + this.membership, + }); + + GetRoomSummaryResponse$2.fromJson(Map json) + : membership = ((v) => v != null + ? Membership.values.fromString(v as String)! + : null)(json['membership']); + Map toJson() { + final membership = this.membership; + return { + if (membership != null) 'membership': membership.name, + }; + } + + /// The membership state of the user if the user is joined to the room. Absent + /// if the API was called unauthenticated. + Membership? membership; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is GetRoomSummaryResponse$2 && + other.runtimeType == runtimeType && + other.membership == membership); + + @dart.override + int get hashCode => membership.hashCode; +} + +/// A summary of the room. +@_NameSource('generated') +class GetRoomSummaryResponse$3 + implements RoomSummary$1, GetRoomSummaryResponse$2 { + GetRoomSummaryResponse$3({ + this.avatarUrl, + this.canonicalAlias, + required this.guestCanJoin, + this.joinRule, + this.name, + required this.numJoinedMembers, + required this.roomId, + this.roomType, + this.topic, + required this.worldReadable, + this.allowedRoomIds, + this.encryption, + this.roomVersion, + this.membership, + }); + + GetRoomSummaryResponse$3.fromJson(Map json) + : avatarUrl = ((v) => + v != null ? Uri.parse(v as String) : null)(json['avatar_url']), + canonicalAlias = + ((v) => v != null ? v as String : null)(json['canonical_alias']), + guestCanJoin = json['guest_can_join'] as bool, + joinRule = ((v) => v != null ? v as String : null)(json['join_rule']), + name = ((v) => v != null ? v as String : null)(json['name']), + numJoinedMembers = json['num_joined_members'] as int, + roomId = json['room_id'] as String, + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + topic = ((v) => v != null ? v as String : null)(json['topic']), + worldReadable = json['world_readable'] as bool, + allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']), + membership = ((v) => v != null + ? Membership.values.fromString(v as String)! + : null)(json['membership']); + @override + Map toJson() { + final avatarUrl = this.avatarUrl; + final canonicalAlias = this.canonicalAlias; + final joinRule = this.joinRule; + final name = this.name; + final roomType = this.roomType; + final topic = this.topic; + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomVersion = this.roomVersion; + final membership = this.membership; + return { + if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), + if (canonicalAlias != null) 'canonical_alias': canonicalAlias, + 'guest_can_join': guestCanJoin, + if (joinRule != null) 'join_rule': joinRule, + if (name != null) 'name': name, + 'num_joined_members': numJoinedMembers, + 'room_id': roomId, + if (roomType != null) 'room_type': roomType, + if (topic != null) 'topic': topic, + 'world_readable': worldReadable, + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, + if (roomVersion != null) 'room_version': roomVersion, + if (membership != null) 'membership': membership.name, + }; + } + + /// The URL for the room's avatar, if one is set. + @override + Uri? avatarUrl; + + /// The canonical alias of the room, if any. + @override + String? canonicalAlias; + + /// Whether guest users may join the room and participate in it. + /// If they can, they will be subject to ordinary power level + /// rules like any other user. + @override + bool guestCanJoin; + + /// The room's join rule. When not present, the room is assumed to + /// be `public`. + @override + String? joinRule; + + /// The name of the room, if any. + @override + String? name; + + /// The number of members joined to the room. + @override + int numJoinedMembers; + + /// The ID of the room. + @override + String roomId; + + /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. + @override + String? roomType; + + /// The plain text topic of the room. Omitted if no `text/plain` mimetype + /// exists in [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic). + @override + String? topic; + + /// Whether the room may be viewed by users without joining. + @override + bool worldReadable; + + /// If the room is a [restricted room](https://spec.matrix.org/unstable/server-server-api/#restricted-rooms), these are the room IDs which + /// are specified by the join rules. Empty or omitted otherwise. + @override + List? allowedRoomIds; + + /// The encryption algorithm to be used to encrypt messages sent in the + /// room. + @override + String? encryption; + + /// The version of the room. + @override + String? roomVersion; + + /// The membership state of the user if the user is joined to the room. Absent + /// if the API was called unauthenticated. + @override + Membership? membership; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is GetRoomSummaryResponse$3 && + other.runtimeType == runtimeType && + other.avatarUrl == avatarUrl && + other.canonicalAlias == canonicalAlias && + other.guestCanJoin == guestCanJoin && + other.joinRule == joinRule && + other.name == name && + other.numJoinedMembers == numJoinedMembers && + other.roomId == roomId && + other.roomType == roomType && + other.topic == topic && + other.worldReadable == worldReadable && + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomVersion == roomVersion && + other.membership == membership); + + @dart.override + int get hashCode => Object.hash( + avatarUrl, + canonicalAlias, + guestCanJoin, + joinRule, + name, + numJoinedMembers, + roomId, + roomType, + topic, + worldReadable, + allowedRoomIds, + encryption, + roomVersion, + membership, + ); +} + +/// +@_NameSource('rule override generated') +class SpaceRoomsChunk$1 { + SpaceRoomsChunk$1({ + this.allowedRoomIds, + this.encryption, + this.roomType, + this.roomVersion, + }); + + SpaceRoomsChunk$1.fromJson(Map json) + : allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']); + Map toJson() { + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomType = this.roomType; + final roomVersion = this.roomVersion; + return { + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, + if (roomType != null) 'room_type': roomType, + if (roomVersion != null) 'room_version': roomVersion, + }; + } + + /// If the room is a [restricted room](https://spec.matrix.org/unstable/server-server-api/#restricted-rooms), these are the room IDs which + /// are specified by the join rules. Empty or omitted otherwise. + List? allowedRoomIds; + + /// The encryption algorithm to be used to encrypt messages sent in the + /// room. + String? encryption; + + /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. + String? roomType; + + /// The version of the room. + String? roomVersion; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is SpaceRoomsChunk$1 && + other.runtimeType == runtimeType && + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomType == roomType && + other.roomVersion == roomVersion); + + @dart.override + int get hashCode => + Object.hash(allowedRoomIds, encryption, roomType, roomVersion); +} + +/// +@_NameSource('spec') +class RoomSummary$2 implements PublishedRoomsChunk, SpaceRoomsChunk$1 { + RoomSummary$2({ + this.avatarUrl, + this.canonicalAlias, + required this.guestCanJoin, + this.joinRule, + this.name, + required this.numJoinedMembers, + required this.roomId, + this.roomType, + this.topic, + required this.worldReadable, + this.allowedRoomIds, + this.encryption, + this.roomVersion, + }); + + RoomSummary$2.fromJson(Map json) + : avatarUrl = ((v) => + v != null ? Uri.parse(v as String) : null)(json['avatar_url']), + canonicalAlias = + ((v) => v != null ? v as String : null)(json['canonical_alias']), + guestCanJoin = json['guest_can_join'] as bool, + joinRule = ((v) => v != null ? v as String : null)(json['join_rule']), + name = ((v) => v != null ? v as String : null)(json['name']), + numJoinedMembers = json['num_joined_members'] as int, + roomId = json['room_id'] as String, + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + topic = ((v) => v != null ? v as String : null)(json['topic']), + worldReadable = json['world_readable'] as bool, + allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']); + @override + Map toJson() { + final avatarUrl = this.avatarUrl; + final canonicalAlias = this.canonicalAlias; + final joinRule = this.joinRule; + final name = this.name; + final roomType = this.roomType; + final topic = this.topic; + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomVersion = this.roomVersion; + return { + if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), + if (canonicalAlias != null) 'canonical_alias': canonicalAlias, + 'guest_can_join': guestCanJoin, + if (joinRule != null) 'join_rule': joinRule, + if (name != null) 'name': name, + 'num_joined_members': numJoinedMembers, + 'room_id': roomId, + if (roomType != null) 'room_type': roomType, + if (topic != null) 'topic': topic, + 'world_readable': worldReadable, + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, + if (roomVersion != null) 'room_version': roomVersion, + }; + } + + /// The URL for the room's avatar, if one is set. + @override + Uri? avatarUrl; + + /// The canonical alias of the room, if any. + @override + String? canonicalAlias; + + /// Whether guest users may join the room and participate in it. + /// If they can, they will be subject to ordinary power level + /// rules like any other user. + @override + bool guestCanJoin; + + /// The room's join rule. When not present, the room is assumed to + /// be `public`. + @override + String? joinRule; + + /// The name of the room, if any. + @override + String? name; + + /// The number of members joined to the room. + @override + int numJoinedMembers; + + /// The ID of the room. + @override + String roomId; + + /// The `type` of room (from [`m.room.create`](https://spec.matrix.org/unstable/client-server-api/#mroomcreate)), if any. + @override + String? roomType; + + /// The plain text topic of the room. Omitted if no `text/plain` mimetype + /// exists in [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic). + @override + String? topic; + + /// Whether the room may be viewed by users without joining. + @override + bool worldReadable; + + /// If the room is a [restricted room](https://spec.matrix.org/unstable/server-server-api/#restricted-rooms), these are the room IDs which + /// are specified by the join rules. Empty or omitted otherwise. + @override + List? allowedRoomIds; + + /// The encryption algorithm to be used to encrypt messages sent in the + /// room. + @override + String? encryption; + + /// The version of the room. + @override + String? roomVersion; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is RoomSummary$2 && + other.runtimeType == runtimeType && + other.avatarUrl == avatarUrl && + other.canonicalAlias == canonicalAlias && + other.guestCanJoin == guestCanJoin && + other.joinRule == joinRule && + other.name == name && + other.numJoinedMembers == numJoinedMembers && + other.roomId == roomId && + other.roomType == roomType && + other.topic == topic && + other.worldReadable == worldReadable && + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomVersion == roomVersion); + + @dart.override + int get hashCode => Object.hash( + avatarUrl, + canonicalAlias, + guestCanJoin, + joinRule, + name, + numJoinedMembers, + roomId, + roomType, + topic, + worldReadable, + allowedRoomIds, + encryption, + roomVersion, + ); +} + +/// +@_NameSource('spec') +class SpaceHierarchyRoomsChunk { + SpaceHierarchyRoomsChunk({ + this.allowedRoomIds, + required this.childrenState, + this.encryption, + this.roomType, + this.roomVersion, + }); + + SpaceHierarchyRoomsChunk.fromJson(Map json) + : allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + childrenState = (json['children_state'] as List) + .map((v) => ChildrenState.fromJson(v as Map)) + .toList(), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']); + Map toJson() { + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomType = this.roomType; + final roomVersion = this.roomVersion; + return { + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + 'children_state': childrenState.map((v) => v.toJson()).toList(), + if (encryption != null) 'encryption': encryption, + if (roomType != null) 'room_type': roomType, + if (roomVersion != null) 'room_version': roomVersion, + }; + } + + /// + List? allowedRoomIds; + + /// The [`m.space.child`](https://spec.matrix.org/unstable/client-server-api/#mspacechild) events of the space-room, represented + /// as [Stripped State Events](https://spec.matrix.org/unstable/client-server-api/#stripped-state) with an added `origin_server_ts` key. + /// + /// If the room is not a space-room, this should be empty. + List childrenState; + + /// + String? encryption; + + /// + String? roomType; + + /// + String? roomVersion; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is SpaceHierarchyRoomsChunk && + other.runtimeType == runtimeType && + other.allowedRoomIds == allowedRoomIds && + other.childrenState == childrenState && + other.encryption == encryption && + other.roomType == roomType && + other.roomVersion == roomVersion); + + @dart.override + int get hashCode => Object.hash( + allowedRoomIds, + childrenState, + encryption, + roomType, + roomVersion, + ); +} + +/// +@_NameSource('rule override generated') +class SpaceRoomsChunk$2 implements RoomSummary$2, SpaceHierarchyRoomsChunk { + SpaceRoomsChunk$2({ + this.avatarUrl, + this.canonicalAlias, + required this.guestCanJoin, + this.joinRule, + this.name, + required this.numJoinedMembers, + required this.roomId, + this.roomType, + this.topic, + required this.worldReadable, + this.allowedRoomIds, + this.encryption, + this.roomVersion, + required this.childrenState, + }); + + SpaceRoomsChunk$2.fromJson(Map json) + : avatarUrl = ((v) => + v != null ? Uri.parse(v as String) : null)(json['avatar_url']), + canonicalAlias = + ((v) => v != null ? v as String : null)(json['canonical_alias']), + guestCanJoin = json['guest_can_join'] as bool, + joinRule = ((v) => v != null ? v as String : null)(json['join_rule']), + name = ((v) => v != null ? v as String : null)(json['name']), + numJoinedMembers = json['num_joined_members'] as int, + roomId = json['room_id'] as String, + roomType = ((v) => v != null ? v as String : null)(json['room_type']), + topic = ((v) => v != null ? v as String : null)(json['topic']), + worldReadable = json['world_readable'] as bool, + allowedRoomIds = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed_room_ids']), + encryption = + ((v) => v != null ? v as String : null)(json['encryption']), + roomVersion = + ((v) => v != null ? v as String : null)(json['room_version']), + childrenState = (json['children_state'] as List) + .map((v) => ChildrenState.fromJson(v as Map)) + .toList(); + @override + Map toJson() { + final avatarUrl = this.avatarUrl; + final canonicalAlias = this.canonicalAlias; + final joinRule = this.joinRule; + final name = this.name; + final roomType = this.roomType; + final topic = this.topic; + final allowedRoomIds = this.allowedRoomIds; + final encryption = this.encryption; + final roomVersion = this.roomVersion; + return { + if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), + if (canonicalAlias != null) 'canonical_alias': canonicalAlias, + 'guest_can_join': guestCanJoin, + if (joinRule != null) 'join_rule': joinRule, + if (name != null) 'name': name, + 'num_joined_members': numJoinedMembers, + 'room_id': roomId, + if (roomType != null) 'room_type': roomType, + if (topic != null) 'topic': topic, + 'world_readable': worldReadable, + if (allowedRoomIds != null) + 'allowed_room_ids': allowedRoomIds.map((v) => v).toList(), + if (encryption != null) 'encryption': encryption, + if (roomVersion != null) 'room_version': roomVersion, + 'children_state': childrenState.map((v) => v.toJson()).toList(), + }; + } + + /// The URL for the room's avatar, if one is set. + @override + Uri? avatarUrl; + + /// The canonical alias of the room, if any. + @override + String? canonicalAlias; + + /// Whether guest users may join the room and participate in it. + /// If they can, they will be subject to ordinary power level + /// rules like any other user. + @override + bool guestCanJoin; + + /// The room's join rule. When not present, the room is assumed to + /// be `public`. + @override + String? joinRule; + + /// The name of the room, if any. + @override + String? name; + + /// The number of members joined to the room. + @override + int numJoinedMembers; + + /// The ID of the room. + @override + String roomId; + + /// + @override + String? roomType; + + /// The plain text topic of the room. Omitted if no `text/plain` mimetype + /// exists in [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic). + @override + String? topic; + + /// Whether the room may be viewed by users without joining. + @override + bool worldReadable; + + /// + @override + List? allowedRoomIds; + + /// + @override + String? encryption; + + /// + @override + String? roomVersion; + + /// The [`m.space.child`](https://spec.matrix.org/unstable/client-server-api/#mspacechild) events of the space-room, represented + /// as [Stripped State Events](https://spec.matrix.org/unstable/client-server-api/#stripped-state) with an added `origin_server_ts` key. + /// + /// If the room is not a space-room, this should be empty. + @override + List childrenState; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is SpaceRoomsChunk$2 && + other.runtimeType == runtimeType && + other.avatarUrl == avatarUrl && + other.canonicalAlias == canonicalAlias && + other.guestCanJoin == guestCanJoin && + other.joinRule == joinRule && + other.name == name && + other.numJoinedMembers == numJoinedMembers && + other.roomId == roomId && + other.roomType == roomType && + other.topic == topic && + other.worldReadable == worldReadable && + other.allowedRoomIds == allowedRoomIds && + other.encryption == encryption && + other.roomVersion == roomVersion && other.childrenState == childrenState); @dart.override @@ -655,6 +1548,9 @@ class SpaceRoomsChunk implements PublicRoomsChunk, SpaceHierarchyRoomsChunk { roomType, topic, worldReadable, + allowedRoomIds, + encryption, + roomVersion, childrenState, ); } @@ -670,7 +1566,7 @@ class GetSpaceHierarchyResponse { GetSpaceHierarchyResponse.fromJson(Map json) : nextBatch = ((v) => v != null ? v as String : null)(json['next_batch']), rooms = (json['rooms'] as List) - .map((v) => SpaceRoomsChunk.fromJson(v as Map)) + .map((v) => SpaceRoomsChunk$2.fromJson(v as Map)) .toList(); Map toJson() { final nextBatch = this.nextBatch; @@ -695,7 +1591,7 @@ class GetSpaceHierarchyResponse { /// * The room's join rules are set to [`restricted`](#restricted-rooms), provided the user meets one of the specified conditions. /// * The room is "knockable" (the room's join rules are set to `knock`, or `knock_restricted`, in a room version that supports those settings). /// * The room's [`m.room.history_visibility`](#room-history-visibility) is set to `world_readable`. - List rooms; + List rooms; @dart.override bool operator ==(Object other) => @@ -1404,6 +2300,65 @@ class BooleanCapability { int get hashCode => enabled.hashCode; } +/// +@_NameSource('spec') +class ProfileFieldsCapability { + ProfileFieldsCapability({ + this.allowed, + this.disallowed, + required this.enabled, + }); + + ProfileFieldsCapability.fromJson(Map json) + : allowed = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['allowed']), + disallowed = ((v) => v != null + ? (v as List).map((v) => v as String).toList() + : null)(json['disallowed']), + enabled = json['enabled'] as bool; + Map toJson() { + final allowed = this.allowed; + final disallowed = this.disallowed; + return { + if (allowed != null) 'allowed': allowed.map((v) => v).toList(), + if (disallowed != null) 'disallowed': disallowed.map((v) => v).toList(), + 'enabled': enabled, + }; + } + + /// If present, a list of profile fields that clients are allowed to create, modify or delete, + /// provided `enabled` is `true`; no other profile fields may be changed. + /// + /// If absent, clients may set all profile fields except those forbidden by the `disallowed` + /// list, where present. + /// + List? allowed; + + /// This property has no meaning if `allowed` is also specified. + /// + /// Otherwise, if present, a list of profile fields that clients are _not_ allowed to create, modify or delete. + /// Provided `enabled` is `true`, clients MAY assume that they can set any profile field which is not + /// included in this list. + /// + List? disallowed; + + /// `true` if the user can create, update or delete any profile fields, `false` otherwise. + bool enabled; + + @dart.override + bool operator ==(Object other) => + identical(this, other) || + (other is ProfileFieldsCapability && + other.runtimeType == runtimeType && + other.allowed == allowed && + other.disallowed == disallowed && + other.enabled == enabled); + + @dart.override + int get hashCode => Object.hash(allowed, disallowed, enabled); +} + /// The stability of the room version. @_NameSource('rule override generated') enum RoomVersionAvailable { @@ -1458,6 +2413,7 @@ class Capabilities { this.m3pidChanges, this.mChangePassword, this.mGetLoginToken, + this.mProfileFields, this.mRoomVersions, this.mSetAvatarUrl, this.mSetDisplayname, @@ -1474,6 +2430,9 @@ class Capabilities { mGetLoginToken = ((v) => v != null ? BooleanCapability.fromJson(v as Map) : null)(json['m.get_login_token']), + mProfileFields = ((v) => v != null + ? ProfileFieldsCapability.fromJson(v as Map) + : null)(json['m.profile_fields']), mRoomVersions = ((v) => v != null ? RoomVersionsCapability.fromJson(v as Map) : null)(json['m.room_versions']), @@ -1490,6 +2449,7 @@ class Capabilities { 'm.3pid_changes', 'm.change_password', 'm.get_login_token', + 'm.profile_fields', 'm.room_versions', 'm.set_avatar_url', 'm.set_displayname', @@ -1501,6 +2461,7 @@ class Capabilities { final m3pidChanges = this.m3pidChanges; final mChangePassword = this.mChangePassword; final mGetLoginToken = this.mGetLoginToken; + final mProfileFields = this.mProfileFields; final mRoomVersions = this.mRoomVersions; final mSetAvatarUrl = this.mSetAvatarUrl; final mSetDisplayname = this.mSetDisplayname; @@ -1510,6 +2471,7 @@ class Capabilities { if (mChangePassword != null) 'm.change_password': mChangePassword.toJson(), if (mGetLoginToken != null) 'm.get_login_token': mGetLoginToken.toJson(), + if (mProfileFields != null) 'm.profile_fields': mProfileFields.toJson(), if (mRoomVersions != null) 'm.room_versions': mRoomVersions.toJson(), if (mSetAvatarUrl != null) 'm.set_avatar_url': mSetAvatarUrl.toJson(), if (mSetDisplayname != null) @@ -1526,13 +2488,28 @@ class Capabilities { /// Capability to indicate if the user can generate tokens to log further clients into their account. BooleanCapability? mGetLoginToken; + /// Capability to indicate if the user can set or modify extended profile fields via [`PUT /_matrix/client/v3/profile/{userId}/{keyName}`](https://spec.matrix.org/unstable/client-server-api/#put_matrixclientv3profileuseridkeyname). If absent, clients SHOULD assume custom profile fields are supported, provided the homeserver advertises a specification version that includes `m.profile_fields` in the [`/versions`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientversions) response. + ProfileFieldsCapability? mProfileFields; + /// The room versions the server supports. RoomVersionsCapability? mRoomVersions; - /// Capability to indicate if the user can change their avatar. + /// **Deprecated:** Capability to indicate if the user can change their avatar. + /// Refer to `m.profile_fields` for extended profile management. + /// + /// For backwards compatibility, servers that directly or indirectly include the + /// `avatar_url` profile field in the `m.profile_fields` capability MUST also + /// set this capability accordingly. + /// BooleanCapability? mSetAvatarUrl; - /// Capability to indicate if the user can change their display name. + /// **Deprecated:** Capability to indicate if the user can change their display name. + /// Refer to `m.profile_fields` for extended profile management. + /// + /// For backwards compatibility, servers that directly or indirectly include the + /// `displayname` profile field in the `m.profile_fields` capability MUST also + /// set this capability accordingly. + /// BooleanCapability? mSetDisplayname; Map additionalProperties; @@ -1545,6 +2522,7 @@ class Capabilities { other.m3pidChanges == m3pidChanges && other.mChangePassword == mChangePassword && other.mGetLoginToken == mGetLoginToken && + other.mProfileFields == mProfileFields && other.mRoomVersions == mRoomVersions && other.mSetAvatarUrl == mSetAvatarUrl && other.mSetDisplayname == mSetDisplayname); @@ -1554,6 +2532,7 @@ class Capabilities { m3pidChanges, mChangePassword, mGetLoginToken, + mProfileFields, mRoomVersions, mSetAvatarUrl, mSetDisplayname, @@ -2516,19 +3495,35 @@ class ProfileInformation { ProfileInformation({ this.avatarUrl, this.displayname, + this.mTz, + this.additionalProperties = const {}, }); ProfileInformation.fromJson(Map json) - : avatarUrl = ((v) => - v != null ? Uri.parse(v as String) : null)(json['avatar_url']), + : avatarUrl = ((v) => v != null + ? ((v as String).startsWith('mxc://') + ? Uri.parse(v) + : throw Exception('Uri not an mxc URI')) + : null)(json['avatar_url']), displayname = - ((v) => v != null ? v as String : null)(json['displayname']); + ((v) => v != null ? v as String : null)(json['displayname']), + mTz = ((v) => v != null ? v as String : null)(json['m.tz']), + additionalProperties = Map.fromEntries( + json.entries + .where( + (e) => !['avatar_url', 'displayname', 'm.tz'].contains(e.key), + ) + .map((e) => MapEntry(e.key, e.value)), + ); Map toJson() { final avatarUrl = this.avatarUrl; final displayname = this.displayname; + final mTz = this.mTz; return { + ...additionalProperties, if (avatarUrl != null) 'avatar_url': avatarUrl.toString(), if (displayname != null) 'displayname': displayname, + if (mTz != null) 'm.tz': mTz, }; } @@ -2538,19 +3533,25 @@ class ProfileInformation { /// The user's display name if they have set one, otherwise not present. String? displayname; + /// The user's time zone. + String? mTz; + + Map additionalProperties; + @dart.override bool operator ==(Object other) => identical(this, other) || (other is ProfileInformation && other.runtimeType == runtimeType && other.avatarUrl == avatarUrl && - other.displayname == displayname); + other.displayname == displayname && + other.mTz == mTz); @dart.override - int get hashCode => Object.hash(avatarUrl, displayname); + int get hashCode => Object.hash(avatarUrl, displayname, mTz); } -/// A list of the rooms on the server. +/// A list of the published rooms on the server. @_NameSource('generated') class GetPublicRoomsResponse { GetPublicRoomsResponse({ @@ -2562,7 +3563,7 @@ class GetPublicRoomsResponse { GetPublicRoomsResponse.fromJson(Map json) : chunk = (json['chunk'] as List) - .map((v) => PublicRoomsChunk.fromJson(v as Map)) + .map((v) => PublishedRoomsChunk.fromJson(v as Map)) .toList(), nextBatch = ((v) => v != null ? v as String : null)(json['next_batch']), prevBatch = ((v) => v != null ? v as String : null)(json['prev_batch']), @@ -2581,8 +3582,8 @@ class GetPublicRoomsResponse { }; } - /// A paginated chunk of public rooms. - List chunk; + /// A paginated chunk of published rooms. + List chunk; /// A pagination token for the response. The absence of this token /// means there are no more results to fetch and the client should @@ -2594,7 +3595,7 @@ class GetPublicRoomsResponse { /// batch, i.e. this is the first batch. String? prevBatch; - /// An estimate on the total number of public rooms, if the + /// An estimate on the total number of published rooms, if the /// server has an estimate. int? totalRoomCountEstimate; @@ -2660,7 +3661,7 @@ class PublicRoomQueryFilter { int get hashCode => Object.hash(genericSearchTerm, roomTypes); } -/// A list of the rooms on the server. +/// A list of the published rooms on the server. @_NameSource('generated') class QueryPublicRoomsResponse { QueryPublicRoomsResponse({ @@ -2672,7 +3673,7 @@ class QueryPublicRoomsResponse { QueryPublicRoomsResponse.fromJson(Map json) : chunk = (json['chunk'] as List) - .map((v) => PublicRoomsChunk.fromJson(v as Map)) + .map((v) => PublishedRoomsChunk.fromJson(v as Map)) .toList(), nextBatch = ((v) => v != null ? v as String : null)(json['next_batch']), prevBatch = ((v) => v != null ? v as String : null)(json['prev_batch']), @@ -2691,8 +3692,8 @@ class QueryPublicRoomsResponse { }; } - /// A paginated chunk of public rooms. - List chunk; + /// A paginated chunk of published rooms. + List chunk; /// A pagination token for the response. The absence of this token /// means there are no more results to fetch and the client should @@ -2704,7 +3705,7 @@ class QueryPublicRoomsResponse { /// batch, i.e. this is the first batch. String? prevBatch; - /// An estimate on the total number of public rooms, if the + /// An estimate on the total number of published rooms, if the /// server has an estimate. int? totalRoomCountEstimate; @@ -3813,19 +4814,6 @@ class RoomMember { int get hashCode => Object.hash(avatarUrl, displayName); } -/// -@_NameSource('(generated, rule override generated)') -enum Membership { - ban('ban'), - invite('invite'), - join('join'), - knock('knock'), - leave('leave'); - - final String name; - const Membership(this.name); -} - /// A list of messages with a new token to request more. @_NameSource('generated') class GetRoomEventsResponse { @@ -3916,6 +4904,16 @@ enum ReceiptType { const ReceiptType(this.name); } +/// +@_NameSource('generated') +enum Format { + content('content'), + event('event'); + + final String name; + const Format(this.name); +} + /// @_NameSource('spec') class IncludeEventContext { @@ -5238,22 +6236,16 @@ class Instances$2 implements ProtocolInstance, Instances$1 { @_NameSource('generated') class GetProtocolMetadataResponse$1 { GetProtocolMetadataResponse$1({ - this.instances, + required this.instances, }); GetProtocolMetadataResponse$1.fromJson(Map json) - : instances = ((v) => v != null - ? (v as List) - .map((v) => Instances$2.fromJson(v as Map)) - .toList() - : null)(json['instances']); - Map toJson() { - final instances = this.instances; - return { - if (instances != null) + : instances = (json['instances'] as List) + .map((v) => Instances$2.fromJson(v as Map)) + .toList(); + Map toJson() => { 'instances': instances.map((v) => v.toJson()).toList(), - }; - } + }; /// A list of objects representing independent instances of configuration. /// For example, multiple networks on IRC if multiple are provided by the @@ -5263,7 +6255,7 @@ class GetProtocolMetadataResponse$1 { /// [`GET /_matrix/app/v1/thirdparty/protocol/{protocol}`](https://spec.matrix.org/unstable/application-service-api/#get_matrixappv1thirdpartyprotocolprotocol) /// to include an `instance_id` to serve as a unique identifier for each /// instance on the homeserver. - List? instances; + List instances; @dart.override bool operator ==(Object other) => @@ -5285,7 +6277,7 @@ class GetProtocolMetadataResponse$2 required this.icon, required this.locationFields, required this.userFields, - this.instances, + required this.instances, }); GetProtocolMetadataResponse$2.fromJson(Map json) @@ -5297,23 +6289,17 @@ class GetProtocolMetadataResponse$2 (json['location_fields'] as List).map((v) => v as String).toList(), userFields = (json['user_fields'] as List).map((v) => v as String).toList(), - instances = ((v) => v != null - ? (v as List) - .map((v) => Instances$2.fromJson(v as Map)) - .toList() - : null)(json['instances']); + instances = (json['instances'] as List) + .map((v) => Instances$2.fromJson(v as Map)) + .toList(); @override - Map toJson() { - final instances = this.instances; - return { - 'field_types': fieldTypes.map((k, v) => MapEntry(k, v.toJson())), - 'icon': icon, - 'location_fields': locationFields.map((v) => v).toList(), - 'user_fields': userFields.map((v) => v).toList(), - if (instances != null) + Map toJson() => { + 'field_types': fieldTypes.map((k, v) => MapEntry(k, v.toJson())), + 'icon': icon, + 'location_fields': locationFields.map((v) => v).toList(), + 'user_fields': userFields.map((v) => v).toList(), 'instances': instances.map((v) => v.toJson()).toList(), - }; - } + }; /// The type definitions for the fields defined in `user_fields` and /// `location_fields`. Each entry in those arrays MUST have an entry here. @@ -5350,7 +6336,7 @@ class GetProtocolMetadataResponse$2 /// to include an `instance_id` to serve as a unique identifier for each /// instance on the homeserver. @override - List? instances; + List instances; @dart.override bool operator ==(Object other) => @@ -5372,22 +6358,16 @@ class GetProtocolMetadataResponse$2 @_NameSource('generated') class GetProtocolsResponse$1 { GetProtocolsResponse$1({ - this.instances, + required this.instances, }); GetProtocolsResponse$1.fromJson(Map json) - : instances = ((v) => v != null - ? (v as List) - .map((v) => Instances$2.fromJson(v as Map)) - .toList() - : null)(json['instances']); - Map toJson() { - final instances = this.instances; - return { - if (instances != null) + : instances = (json['instances'] as List) + .map((v) => Instances$2.fromJson(v as Map)) + .toList(); + Map toJson() => { 'instances': instances.map((v) => v.toJson()).toList(), - }; - } + }; /// A list of objects representing independent instances of configuration. /// For example, multiple networks on IRC if multiple are provided by the @@ -5397,7 +6377,7 @@ class GetProtocolsResponse$1 { /// [`GET /_matrix/app/v1/thirdparty/protocol/{protocol}`](https://spec.matrix.org/unstable/application-service-api/#get_matrixappv1thirdpartyprotocolprotocol) /// to include an `instance_id` to serve as a unique identifier for each /// instance on the homeserver. - List? instances; + List instances; @dart.override bool operator ==(Object other) => @@ -5418,7 +6398,7 @@ class GetProtocolsResponse$2 implements Protocol, GetProtocolsResponse$1 { required this.icon, required this.locationFields, required this.userFields, - this.instances, + required this.instances, }); GetProtocolsResponse$2.fromJson(Map json) @@ -5430,23 +6410,17 @@ class GetProtocolsResponse$2 implements Protocol, GetProtocolsResponse$1 { (json['location_fields'] as List).map((v) => v as String).toList(), userFields = (json['user_fields'] as List).map((v) => v as String).toList(), - instances = ((v) => v != null - ? (v as List) - .map((v) => Instances$2.fromJson(v as Map)) - .toList() - : null)(json['instances']); + instances = (json['instances'] as List) + .map((v) => Instances$2.fromJson(v as Map)) + .toList(); @override - Map toJson() { - final instances = this.instances; - return { - 'field_types': fieldTypes.map((k, v) => MapEntry(k, v.toJson())), - 'icon': icon, - 'location_fields': locationFields.map((v) => v).toList(), - 'user_fields': userFields.map((v) => v).toList(), - if (instances != null) + Map toJson() => { + 'field_types': fieldTypes.map((k, v) => MapEntry(k, v.toJson())), + 'icon': icon, + 'location_fields': locationFields.map((v) => v).toList(), + 'user_fields': userFields.map((v) => v).toList(), 'instances': instances.map((v) => v.toJson()).toList(), - }; - } + }; /// The type definitions for the fields defined in `user_fields` and /// `location_fields`. Each entry in those arrays MUST have an entry here. @@ -5483,7 +6457,7 @@ class GetProtocolsResponse$2 implements Protocol, GetProtocolsResponse$1 { /// to include an `instance_id` to serve as a unique identifier for each /// instance on the homeserver. @override - List? instances; + List instances; @dart.override bool operator ==(Object other) => diff --git a/lib/src/client.dart b/lib/src/client.dart index cea838e5..b8226386 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1611,14 +1611,23 @@ class Client extends MatrixApi { // 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('')); + await setProfileField( + userID!, + 'avatar_url', + {'avatar_url': Uri.parse('')}, + ); + return; } final uploadResp = await uploadContent( file.bytes, filename: file.name, contentType: file.mimeType, ); - await setAvatarUrl(userID!, uploadResp); + await setProfileField( + userID!, + 'avatar_url', + {'avatar_url': uploadResp.toString()}, + ); return; } From 8439e2b11e172ae51eb1d8921f1cb2411a736615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 1 Oct 2025 08:00:31 +0200 Subject: [PATCH 13/15] chore: Remove unused callbacks Forgot to remove them in the last PR. --- lib/src/room.dart | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/src/room.dart b/lib/src/room.dart index 0c61e8f0..42c1ca08 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -838,11 +838,6 @@ class Room { Map? extraContent, String? threadRootEventId, String? threadLastEventId, - - /// Callback which gets triggered on progress containing the amount of - /// uploaded bytes. - void Function(int)? onUploadProgress, - void Function(int)? onThumbnailUploadProgress, }) async { txid ??= client.generateUniqueTransactionId(); sendingFilePlaceholders[txid] = file; @@ -2109,10 +2104,7 @@ class Room { /// Uploads a new avatar for this room. Returns the event ID of the new /// m.room.avatar event. Insert null to remove the current avatar. - Future setAvatar( - MatrixFile? file, { - void Function(int)? onUploadProgress, - }) async { + Future setAvatar(MatrixFile? file) async { final uploadResp = file == null ? null : await client.uploadContent( From b21df62a2e36269645a399d42ee2a722c18a170b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 1 Oct 2025 10:14:05 +0200 Subject: [PATCH 14/15] chore: Add deprecated type aliases for renamed models --- lib/matrix_api_lite/matrix_api.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/matrix_api_lite/matrix_api.dart b/lib/matrix_api_lite/matrix_api.dart index 028a88be..3ff616c0 100644 --- a/lib/matrix_api_lite/matrix_api.dart +++ b/lib/matrix_api_lite/matrix_api.dart @@ -244,3 +244,9 @@ class EventTooLarge implements Exception { int maxSize, actualSize; EventTooLarge(this.maxSize, this.actualSize); } + +@Deprecated('Use PublishedRoomsChunk instead') +typedef PublicRoomsChunk = PublishedRoomsChunk; + +@Deprecated('Use SpaceRoomsChunk\$1 or SpaceRoomsChunk\$2 instead') +typedef SpaceRoomsChunk = SpaceRoomsChunk$2; From baa56bd1b9c44e62bb67538d934121ffd1380dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 1 Oct 2025 13:28:46 +0200 Subject: [PATCH 15/15] chore: Remove unused dependency Looks like this was a misunderstanding. We are using dart:js_interop which has nothing to do with the package on pub.dev. So we don't need it. --- pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 33377ab7..8ae9fc29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,6 @@ dependencies: html_unescape: ^2.0.0 http: ">=0.13.0 <2.0.0" image: ^4.0.15 - js_interop: ^0.0.1 markdown: ^7.1.1 mime: ">=1.0.0 <3.0.0" path: ^1.9.1