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 35cc121f..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; @@ -748,9 +747,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; } @@ -1558,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/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/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/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/matrix_api_lite/matrix_api.dart b/lib/matrix_api_lite/matrix_api.dart index 81023e13..3ff616c0 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 @@ -233,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; 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/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/client.dart b/lib/src/client.dart index 22a7b922..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; } @@ -2632,13 +2641,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 @@ -2738,6 +2749,24 @@ class Client extends MatrixApi { Logs().d('Skip store LeftRoomUpdate for unknown room', id); continue; } + + if (syncRoomUpdate is JoinedRoomUpdate && + (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); } } @@ -3035,13 +3064,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 && ({ @@ -3758,10 +3780,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/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/event.dart b/lib/src/event.dart index d01854cb..d893c4d9 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."); @@ -1183,6 +1190,17 @@ class Event extends MatrixEvent { (fileSendingStatus) => fileSendingStatus.name == status, ); } + + /// 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 { + final mentionsMap = content.tryGetMap('m.mentions'); + return ( + userIds: mentionsMap?.tryGetList('user_ids') ?? [], + room: mentionsMap?.tryGet('room') ?? false, + ); + } } enum FileSendingStatus { diff --git a/lib/src/room.dart b/lib/src/room.dart index 86ec752d..42c1ca08 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -377,6 +377,73 @@ 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. 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()), + ) + .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; + } + 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') { @@ -443,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. @@ -635,6 +710,7 @@ class Room { String? threadRootEventId, String? threadLastEventId, StringBuffer? commandStdout, + bool addMentions = true, }) { if (parseCommands) { return client.parseAndRunCommand( @@ -652,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'], @@ -1996,7 +2107,10 @@ class Room { Future setAvatar(MatrixFile? file) async { final uploadResp = file == null ? null - : await client.uploadContent(file.bytes, filename: file.name); + : await client.uploadContent( + file.bytes, + filename: file.name, + ); return await client.setRoomStateWithKey( id, EventTypes.RoomAvatar, diff --git a/lib/src/utils/crypto/crypto.dart b/lib/src/utils/crypto/crypto.dart index a97417ee..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) '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/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/lib/src/utils/multipart_request_progress.dart b/lib/src/utils/multipart_request_progress.dart new file mode 100644 index 00000000..0b05a230 --- /dev/null +++ b/lib/src/utils/multipart_request_progress.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +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/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/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/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( 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..8ae9fc29 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 markdown: ^7.1.1 mime: ">=1.0.0 <3.0.0" path: ^1.9.1 @@ -31,7 +27,8 @@ 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 dev_dependencies: @@ -41,4 +38,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/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:'); diff --git a/test/client_test.dart b/test/client_test.dart index e38378a9..2db2b1d9 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( @@ -1488,8 +1488,10 @@ void main() { }); test('upload', () async { final client = await getClient(); - final response = - await client.uploadContent(Uint8List(0), filename: 'file.jpeg'); + final response = await client.uploadContent( + Uint8List(0), + filename: 'file.jpeg', + ); expect(response.toString(), 'mxc://example.com/AQwafuaFswefuhsfAFAgsw'); expect( await client.database.getFile(response) != null, 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/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/event_test.dart b/test/event_test.dart index 7f244095..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; @@ -2951,5 +2975,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); + }); }); } 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'; diff --git a/test/room_test.dart b/test/room_test.dart index e6963a4c..175b3459 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( @@ -1015,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( @@ -1066,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', @@ -1102,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', @@ -1139,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', @@ -1174,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', @@ -1191,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', @@ -1215,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', @@ -1273,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 {