This commit is contained in:
OfficialDakari 2025-10-02 11:33:16 +05:00
commit 58c4cf19d0
44 changed files with 2695 additions and 1432 deletions

View File

@ -1,2 +1,2 @@
flutter_version=3.27.4 flutter_version=3.35.4
dart_version=3.6.2 dart_version=3.9.2

View File

@ -6,16 +6,13 @@ Matrix (matrix.org) SDK written in dart.
For E2EE, vodozemac must be provided. 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) package.
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.
```sh ```sh
flutter pub add matrix flutter pub add matrix
# Optional: For end to end encryption: # Optional: For end to end encryption:
flutter pub add flutter_vodozemac flutter pub add flutter_vodozemac
flutter pub add flutter_openssl_crypto
``` ```
## Get started ## Get started

View File

@ -6,12 +6,6 @@ For Flutter you can use [flutter_vodozemac](https://pub.dev/packages/flutter_vod
flutter pub add flutter_vodozemac 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: Now before you create your `Client`, init vodozemac:
```dart ```dart

View File

@ -12,7 +12,6 @@ In your `pubspec.yaml` file add the following dependencies:
# (Optional) For end to end encryption, please head on the # (Optional) For end to end encryption, please head on the
# encryption guide and add these dependencies: # encryption guide and add these dependencies:
flutter_vodozemac: <latest-version> flutter_vodozemac: <latest-version>
flutter_openssl_crypto: <latest-version>
``` ```
## Step 2: Create the client ## Step 2: Create the client

View File

@ -23,7 +23,7 @@ import 'dart:typed_data';
import 'package:base58check/base58.dart'; import 'package:base58check/base58.dart';
import 'package:collection/collection.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/encryption.dart';
import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/encryption/utils/base64_unpadded.dart';
@ -74,16 +74,18 @@ class SSSS {
static DerivedKeys deriveKeys(Uint8List key, String name) { static DerivedKeys deriveKeys(Uint8List key, String name) {
final zerosalt = Uint8List(8); final zerosalt = Uint8List(8);
final prk = Hmac(sha256, zerosalt).convert(key); final prk = CryptoUtils.hmac(key: zerosalt, input: key);
final b = Uint8List(1); final b = Uint8List(1);
b[0] = 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; b[0] = 2;
final hmacKey = final hmacKey = CryptoUtils.hmac(
Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b); key: prk,
input: aesKey + utf8.encode(name) + b,
);
return DerivedKeys( return DerivedKeys(
aesKey: Uint8List.fromList(aesKey.bytes), aesKey: Uint8List.fromList(aesKey),
hmacKey: Uint8List.fromList(hmacKey.bytes), hmacKey: Uint8List.fromList(hmacKey),
); );
} }
@ -105,14 +107,15 @@ class SSSS {
final keys = deriveKeys(key, name); final keys = deriveKeys(key, name);
final plain = Uint8List.fromList(utf8.encode(data)); 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( return EncryptedContent(
iv: base64.encode(iv), iv: base64.encode(iv),
ciphertext: base64.encode(ciphertext), 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 keys = deriveKeys(key, name);
final cipher = base64decodeUnpadded(data.ciphertext); final cipher = base64decodeUnpadded(data.ciphertext);
final hmac = base64 final hmac = base64
.encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes) .encode(CryptoUtils.hmac(key: keys.hmacKey, input: cipher))
.replaceAll(RegExp(r'=+$'), ''); .replaceAll(RegExp(r'=+$'), '');
if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) { if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) {
throw Exception('Bad MAC'); throw Exception('Bad MAC');
} }
final decipher = await uc.aesCtr final decipher = CryptoUtils.aesCtr(
.encrypt(cipher, keys.aesKey, base64decodeUnpadded(data.iv)); input: cipher,
key: keys.aesKey,
iv: base64decodeUnpadded(data.iv),
);
return String.fromCharCodes(decipher); return String.fromCharCodes(decipher);
} }
@ -184,12 +190,10 @@ class SSSS {
if (info.salt == null) { if (info.salt == null) {
throw InvalidPassphraseException('Passphrase info without salt'); throw InvalidPassphraseException('Passphrase info without salt');
} }
return await uc.pbkdf2( return CryptoUtils.pbkdf2(
Uint8List.fromList(utf8.encode(passphrase)), passphrase: Uint8List.fromList(utf8.encode(passphrase)),
Uint8List.fromList(utf8.encode(info.salt!)), salt: Uint8List.fromList(utf8.encode(info.salt!)),
uc.sha512, iterations: info.iterations!,
info.iterations!,
info.bits ?? 256,
); );
} }
@ -742,7 +746,7 @@ class OpenSSSS {
info: keyData.passphrase!, info: keyData.passphrase!,
), ),
), ),
).timeout(Duration(seconds: 10)); ).timeout(Duration(minutes: 2));
} else if (recoveryKey != null) { } else if (recoveryKey != null) {
privateKey = SSSS.decodeRecoveryKey(recoveryKey); privateKey = SSSS.decodeRecoveryKey(recoveryKey);
} else { } else {

View File

@ -21,7 +21,6 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:canonical_json/canonical_json.dart'; import 'package:canonical_json/canonical_json.dart';
import 'package:crypto/crypto.dart' as crypto;
import 'package:typed_data/typed_data.dart'; import 'package:typed_data/typed_data.dart';
import 'package:vodozemac/vodozemac.dart' as vod; import 'package:vodozemac/vodozemac.dart' as vod;
@ -748,9 +747,10 @@ class KeyVerification {
// no need to request cache, we already have it // no need to request cache, we already have it
return; return;
} }
// ignore: unawaited_futures unawaited(
encryption.ssss encryption.ssss
.maybeRequestAll(_verifiedDevices.whereType<DeviceKeys>().toList()); .maybeRequestAll(_verifiedDevices.whereType<DeviceKeys>().toList()),
);
if (requestInterval.length <= i) { if (requestInterval.length <= i) {
return; return;
} }
@ -1558,8 +1558,8 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod {
Future<String> _makeCommitment(String pubKey, String canonicalJson) async { Future<String> _makeCommitment(String pubKey, String canonicalJson) async {
if (hash == 'sha256') { if (hash == 'sha256') {
final bytes = utf8.encoder.convert(pubKey + canonicalJson); final bytes = utf8.encoder.convert(pubKey + canonicalJson);
final digest = crypto.sha256.convert(bytes); final digest = vod.CryptoUtils.sha256(input: bytes);
return encodeBase64Unpadded(digest.bytes); return encodeBase64Unpadded(digest);
} }
throw Exception('Unknown hash method'); throw Exception('Unknown hash method');
} }

View File

@ -1740,6 +1740,28 @@ class FakeMatrixApi extends BaseClient {
'origin_server_ts': 1432735824653, 'origin_server_ts': 1432735824653,
'unsigned': {'age': 1234}, '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':
'<b>This is an example text message</b>',
},
'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': '/client/v3/rooms/new_room_id/messages?from=emptyHistoryResponse&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D':
(var req) => emptyHistoryResponse, (var req) => emptyHistoryResponse,
'/client/v3/rooms/new_room_id/messages?from=1&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D': '/client/v3/rooms/new_room_id/messages?from=1&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D':

View File

@ -84,7 +84,7 @@ export 'msc_extensions/extension_timeline_export/timeline_export.dart';
export 'msc_extensions/msc_4140_delayed_events/api.dart'; export 'msc_extensions/msc_4140_delayed_events/api.dart';
export 'src/utils/web_worker/web_worker_stub.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' 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';

View File

@ -34,6 +34,12 @@ class Api {
/// suitably namespaced for each application and reduces the risk of /// suitably namespaced for each application and reduces the risk of
/// clashes. /// 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, /// Note that this endpoint is not necessarily handled by the homeserver,
/// but by another webserver, to be used for discovering the homeserver URL. /// but by another webserver, to be used for discovering the homeserver URL.
Future<DiscoveryInformation> getWellknown() async { Future<DiscoveryInformation> getWellknown() async {
@ -49,10 +55,13 @@ class Api {
/// Gets server admin contact and support page of the domain. /// 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), /// **NOTE:**
/// this should be accessed with the hostname of the homeserver by making a /// 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`. /// GET request to `https://hostname/.well-known/matrix/support`.
/// ///
///
/// Note that this endpoint is not necessarily handled by the homeserver. /// Note that this endpoint is not necessarily handled by the homeserver.
/// It may be served by another webserver, used for discovering support /// It may be served by another webserver, used for discovering support
/// information for the homeserver. /// information for the homeserver.
@ -112,6 +121,36 @@ class Api {
return json['duration_ms'] as int; 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<GetAuthMetadataResponse> 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<String, Object?>);
}
/// Optional endpoint - the server is not required to implement this endpoint if it does not /// Optional endpoint - the server is not required to implement this endpoint if it does not
/// intend to use or support this functionality. /// intend to use or support this functionality.
/// ///
@ -169,12 +208,12 @@ class Api {
/// All values are intentionally left optional. Clients SHOULD follow /// All values are intentionally left optional. Clients SHOULD follow
/// the advice given in the field description when the field is not available. /// 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 /// Both clients and server administrators should be aware that proxies
/// between the client and the server may affect the apparent behaviour of content /// 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 /// repository APIs, for example, proxies may enforce a lower upload size limit
/// than is advertised by the server on this endpoint. /// than is advertised by the server on this endpoint.
/// {{% /boxes/note %}} ///
Future<MediaConfig> getConfigAuthed() async { Future<MediaConfig> getConfigAuthed() async {
final requestUri = Uri(path: '_matrix/client/v1/media/config'); final requestUri = Uri(path: '_matrix/client/v1/media/config');
final request = Request('GET', baseUri!.resolveUri(requestUri)); final request = Request('GET', baseUri!.resolveUri(requestUri));
@ -187,11 +226,11 @@ class Api {
return MediaConfig.fromJson(json as Map<String, Object?>); return MediaConfig.fromJson(json as Map<String, Object?>);
} }
/// {{% boxes/note %}} /// **NOTE:**
/// Clients SHOULD NOT generate or use URLs which supply the access token in /// 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 /// 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. /// 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 /// Clients MAY be redirected using the 307/308 responses below to download
/// the request object. This is typical when the homeserver uses a Content /// 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 /// the previous endpoint) but replaces the target file name with the one
/// provided by the caller. /// provided by the caller.
/// ///
/// {{% boxes/note %}} /// **NOTE:**
/// Clients SHOULD NOT generate or use URLs which supply the access token in /// 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 /// 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. /// 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 /// Clients MAY be redirected using the 307/308 responses below to download
/// the request object. This is typical when the homeserver uses a Content /// 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 /// 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. /// 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 /// Clients should consider avoiding this endpoint for URLs posted in encrypted
/// rooms. Encrypted rooms often contain more sensitive information the users /// 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 /// 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. /// being shared should also not be shared with the homeserver.
/// {{% /boxes/note %}} ///
/// ///
/// [url] The URL to get a preview of. /// [url] The URL to get a preview of.
/// ///
@ -320,11 +359,11 @@ class Api {
/// Download a thumbnail of content from the content repository. /// 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. /// 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 /// 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 /// 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. /// 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 /// Clients MAY be redirected using the 307/308 responses below to download
/// the request object. This is typical when the homeserver uses a Content /// the request object. This is typical when the homeserver uses a Content
@ -427,6 +466,48 @@ class Api {
return json['valid'] as bool; 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<GetRoomSummaryResponse$3> getRoomSummary(
String roomIdOrAlias, {
List<String>? 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<String, Object?>);
}
/// Paginates over the space tree in a depth-first manner to locate child rooms of a given space. /// 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. /// 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 /// 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. /// 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 /// [auth] Additional authentication information for the
/// user-interactive authentication API. /// 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 /// 2. An `m.room.member` event for the creator to join the room. This is
/// needed so the remaining events can be sent. /// needed so the remaining events can be sent.
/// ///
/// 3. A default `m.room.power_levels` event, giving the room creator /// 3. A default `m.room.power_levels` event. Overridden by the
/// (and not other members) permission to send state events. Overridden /// `power_level_content_override` parameter.
/// 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. /// 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 /// 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 /// 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 /// [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 /// keys: `creator`, `room_version`. Future versions of the specification
/// may allow the server to overwrite other keys. /// 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 /// [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 /// the user to override the default state events set in the new
/// room. The expected format of the state events are an object /// 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 /// `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. /// `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 /// [name] If this is included, an [`m.room.name`](https://spec.matrix.org/unstable/client-server-api/#mroomname) event
/// into the room to indicate the name of the room. See Room /// will be sent into the room to indicate the name for the room.
/// Events for more information on `m.room.name`. /// 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 /// [powerLevelContentOverride] The power level content to override in the default power level
/// event. This object is applied on top of the generated /// 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 /// 400 error with the errcode `M_UNSUPPORTED_ROOM_VERSION` if it does not
/// support the room version. /// support the room version.
/// ///
/// [topic] If this is included, an `m.room.topic` event will be sent /// [topic] If this is included, an [`m.room.topic`](https://spec.matrix.org/unstable/client-server-api/#mroomtopic)
/// into the room to indicate the topic for the room. See Room /// event with a `text/plain` mimetype will be sent into the room
/// Events for more information on `m.room.topic`. /// 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 /// [visibility] The room's visibility in the server's
/// in the published room list. A `private` visibility will hide /// [published room directory](https://spec.matrix.org/unstable/client-server-api#published-room-directory).
/// the room from the published room list. Rooms default to /// Defaults to `private`.
/// `private` visibility if this key is not included. NB: This
/// should not be confused with `join_rules` which also uses the
/// word `public`.
/// ///
/// returns `room_id`: /// returns `room_id`:
/// The created room's ID. /// The created room's ID.
@ -1737,6 +1843,11 @@ class Api {
/// ///
/// Deletes the given devices, and invalidates any access token associated with them. /// 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 /// [auth] Additional authentication information for the
/// user-interactive authentication API. /// user-interactive authentication API.
/// ///
@ -1787,6 +1898,11 @@ class Api {
/// ///
/// Deletes the given device, and invalidates any access token associated with it. /// 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. /// [deviceId] The device to delete.
/// ///
/// [auth] Additional authentication information for the /// [auth] Additional authentication information for the
@ -1851,11 +1967,12 @@ class Api {
return ignore(json); return ignore(json);
} }
/// Updates the visibility of a given room on the application service's room /// Updates the visibility of a given room in the application service's
/// directory. /// published room directory.
/// ///
/// This API is similar to the room directory visibility API used by clients /// This API is similar to the
/// to update the homeserver's more general room directory. /// [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`) /// 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 /// instead of a typical client's access_token. This API cannot be invoked by
@ -1894,7 +2011,8 @@ class Api {
return json as Map<String, Object?>; return json as Map<String, Object?>;
} }
/// 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. /// [roomId] The room ID.
/// ///
@ -1916,17 +2034,16 @@ class Api {
: null)(json['visibility']); : null)(json['visibility']);
} }
/// Sets the visibility of a given room in the server's public room /// Sets the visibility of a given room in the server's published room directory.
/// directory.
/// ///
/// Servers may choose to implement additional access control checks /// Servers MAY implement additional access control checks, for instance,
/// here, for instance that room visibility can only be changed by /// to ensure that a room's visibility can only be changed by the room creator
/// the room creator or a server administrator. /// or a server administrator.
/// ///
/// [roomId] The room ID. /// [roomId] The room ID.
/// ///
/// [visibility] The new visibility setting for the room. /// [visibility] The new visibility setting for the room.
/// Defaults to 'public'. /// Defaults to `public`.
Future<void> setRoomVisibilityOnDirectory( Future<void> setRoomVisibilityOnDirectory(
String roomId, { String roomId, {
Visibility? visibility, Visibility? visibility,
@ -2296,6 +2413,11 @@ class Api {
/// makes this endpoint idempotent in the case where the response is lost over the network, /// makes this endpoint idempotent in the case where the response is lost over the network,
/// which would otherwise cause a UIA challenge upon retry. /// 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 /// [auth] Additional authentication information for the
/// user-interactive authentication API. /// user-interactive authentication API.
/// ///
@ -2639,7 +2761,7 @@ class Api {
/// deleted alongside the device. /// 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 /// 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 /// 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 /// 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 /// the request, and therefore the attacker is unable to take over the account in
@ -2742,9 +2864,7 @@ class Api {
return ignore(json); return ignore(json);
} }
/// Get the combined profile information for this user. This API may be used /// Get the complete profile for a user.
/// to fetch the user's own profile information or other users; either
/// locally or on remote homeservers.
/// ///
/// [userId] The user whose profile information to get. /// [userId] The user whose profile information to get.
Future<ProfileInformation> getUserProfile(String userId) async { Future<ProfileInformation> getUserProfile(String userId) async {
@ -2762,18 +2882,41 @@ class Api {
return ProfileInformation.fromJson(json as Map<String, Object?>); return ProfileInformation.fromJson(json as Map<String, Object?>);
} }
/// Get the user's avatar URL. This API may be used to fetch the user's /// Remove a specific field from a user's profile.
/// own avatar URL or to query the URL of other users; either locally or
/// on remote homeservers.
/// ///
/// [userId] The user whose avatar URL to get. /// [userId] The user whose profile field should be deleted.
/// ///
/// returns `avatar_url`: /// [keyName] The name of the profile field to delete.
/// The user's avatar URL if they have set one, otherwise not present. Future<Map<String, Object?>> deleteProfileField(
Future<Uri?> getAvatarUrl(String userId) async { String userId,
String keyName,
) async {
final requestUri = Uri( final requestUri = Uri(
path: 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<String, Object?>;
}
/// 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<Map<String, Object?>> 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)); final request = Request('GET', baseUri!.resolveUri(requestUri));
if (bearerToken != null) { if (bearerToken != null) {
@ -2784,90 +2927,44 @@ class Api {
if (response.statusCode != 200) unexpectedResponse(response, responseBody); if (response.statusCode != 200) unexpectedResponse(response, responseBody);
final responseString = utf8.decode(responseBody); final responseString = utf8.decode(responseBody);
final json = jsonDecode(responseString); final json = jsonDecode(responseString);
return ((v) => return json as Map<String, Object?>;
v != null ? Uri.parse(v as String) : null)(json['avatar_url']);
} }
/// This API sets the given user's avatar URL. You must have permission to /// Set or update a profile field for a user. Must be authenticated with an
/// set this user's avatar URL, e.g. you need to have their `access_token`. /// 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. /// [userId] The user whose profile field should be set.
Future<void> setAvatarUrl(String userId, Uri? avatarUrl) async { ///
/// [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<Map<String, Object?>> setProfileField(
String userId,
String keyName,
Map<String, Object?> body,
) async {
final requestUri = Uri( final requestUri = Uri(
path: 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)); final request = Request('PUT', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}'; request.headers['authorization'] = 'Bearer ${bearerToken!}';
request.headers['content-type'] = 'application/json'; request.headers['content-type'] = 'application/json';
request.bodyBytes = utf8.encode( request.bodyBytes = utf8.encode(jsonEncode(body));
jsonEncode({
if (avatarUrl != null) 'avatar_url': avatarUrl.toString(),
}),
);
final response = await httpClient.send(request); final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes(); final responseBody = await response.stream.toBytes();
if (response.statusCode != 200) unexpectedResponse(response, responseBody); if (response.statusCode != 200) unexpectedResponse(response, responseBody);
final responseString = utf8.decode(responseBody); final responseString = utf8.decode(responseBody);
final json = jsonDecode(responseString); final json = jsonDecode(responseString);
return ignore(json); return json as Map<String, Object?>;
} }
/// Get the user's display name. This API may be used to fetch the user's /// Lists a server's published room directory.
/// 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<String?> 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<void> 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.
/// ///
/// This API returns paginated responses. The rooms are ordered by the number /// This API returns paginated responses. The rooms are ordered by the number
/// of joined members, with the largest rooms first. /// of joined members, with the largest rooms first.
@ -2879,8 +2976,8 @@ class Api {
/// The direction of pagination is specified solely by which token /// The direction of pagination is specified solely by which token
/// is supplied, rather than via an explicit flag. /// is supplied, rather than via an explicit flag.
/// ///
/// [server] The server to fetch the public room lists from. Defaults to the /// [server] The server to fetch the published room directory from. Defaults
/// local server. Case sensitive. /// to the local server. Case sensitive.
Future<GetPublicRoomsResponse> getPublicRooms({ Future<GetPublicRoomsResponse> getPublicRooms({
int? limit, int? limit,
String? since, String? since,
@ -2903,13 +3000,13 @@ class Api {
return GetPublicRoomsResponse.fromJson(json as Map<String, Object?>); return GetPublicRoomsResponse.fromJson(json as Map<String, Object?>);
} }
/// 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 /// This API returns paginated responses. The rooms are ordered by the number
/// of joined members, with the largest rooms first. /// of joined members, with the largest rooms first.
/// ///
/// [server] The server to fetch the public room lists from. Defaults to the /// [server] The server to fetch the published room directory from. Defaults
/// local server. Case sensitive. /// to the local server. Case sensitive.
/// ///
/// [filter] Filter to apply to the results. /// [filter] Filter to apply to the results.
/// ///
@ -4134,9 +4231,6 @@ class Api {
/// ///
/// - The matrix user ID who invited them to the room /// - 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. /// [roomId] The room identifier (not alias) to which to invite the user.
/// ///
/// [body] /// [body]
@ -4739,14 +4833,23 @@ class Api {
/// ///
/// [stateKey] The key of the state to look up. Defaults to an empty string. When /// [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. /// 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<Map<String, Object?>> getRoomStateWithKey( Future<Map<String, Object?>> getRoomStateWithKey(
String roomId, String roomId,
String eventType, String eventType,
String stateKey, String stateKey, {
) async { Format? format,
}) async {
final requestUri = Uri( final requestUri = Uri(
path: path:
'_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/state/${Uri.encodeComponent(eventType)}/${Uri.encodeComponent(stateKey)}', '_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)); final request = Request('GET', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}'; request.headers['authorization'] = 'Bearer ${bearerToken!}';
@ -4886,11 +4989,26 @@ class Api {
/// ///
/// [roomId] The ID of the room to upgrade. /// [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. /// [newVersion] The new version for the room.
/// ///
/// returns `replacement_room`: /// returns `replacement_room`:
/// The ID of the new room. /// The ID of the new room.
Future<String> upgradeRoom(String roomId, String newVersion) async { Future<String> upgradeRoom(
String roomId,
String newVersion, {
List<String>? additionalCreators,
}) async {
final requestUri = Uri( final requestUri = Uri(
path: '_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/upgrade', path: '_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/upgrade',
); );
@ -4899,6 +5017,8 @@ class Api {
request.headers['content-type'] = 'application/json'; request.headers['content-type'] = 'application/json';
request.bodyBytes = utf8.encode( request.bodyBytes = utf8.encode(
jsonEncode({ jsonEncode({
if (additionalCreators != null)
'additional_creators': additionalCreators.map((v) => v).toList(),
'new_version': newVersion, 'new_version': newVersion,
}), }),
); );
@ -5043,12 +5163,32 @@ class Api {
/// ///
/// By default, this is `0`, so the server will return immediately /// By default, this is `0`, so the server will return immediately
/// even if the response is empty. /// 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<SyncUpdate> sync({ Future<SyncUpdate> sync({
String? filter, String? filter,
String? since, String? since,
bool? fullState, bool? fullState,
PresenceType? setPresence, PresenceType? setPresence,
int? timeout, int? timeout,
bool? useStateAfter,
}) async { }) async {
final requestUri = Uri( final requestUri = Uri(
path: '_matrix/client/v3/sync', path: '_matrix/client/v3/sync',
@ -5058,6 +5198,7 @@ class Api {
if (fullState != null) 'full_state': fullState.toString(), if (fullState != null) 'full_state': fullState.toString(),
if (setPresence != null) 'set_presence': setPresence.name, if (setPresence != null) 'set_presence': setPresence.name,
if (timeout != null) 'timeout': timeout.toString(), if (timeout != null) 'timeout': timeout.toString(),
if (useStateAfter != null) 'use_state_after': useStateAfter.toString(),
}, },
); );
final request = Request('GET', baseUri!.resolveUri(requestUri)); final request = Request('GET', baseUri!.resolveUri(requestUri));
@ -5507,10 +5648,17 @@ class Api {
return ignore(json); return ignore(json);
} }
/// Performs a search for users. The homeserver may /// Performs a search for users. The homeserver may determine which
/// determine which subset of users are searched, however the homeserver /// subset of users are searched. However, the homeserver MUST at a
/// MUST at a minimum consider the users the requesting user shares a /// minimum consider users who are visible to the requester based
/// room with and those who reside in public rooms (known to the homeserver). /// 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 /// The search MUST consider local users to the homeserver, and SHOULD
/// query remote users as part of the search. /// query remote users as part of the search.
/// ///
@ -5663,9 +5811,9 @@ class Api {
return CreateContentResponse.fromJson(json as Map<String, Object?>); return CreateContentResponse.fromJson(json as Map<String, Object?>);
} }
/// {{% boxes/note %}} /// **NOTE:**
/// Replaced by [`GET /_matrix/client/v1/media/config`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediaconfig). /// 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 /// This endpoint allows clients to retrieve the configuration of the content
/// repository, such as upload limitations. /// repository, such as upload limitations.
@ -5690,17 +5838,17 @@ class Api {
return MediaConfig.fromJson(json as Map<String, Object?>); return MediaConfig.fromJson(json as Map<String, Object?>);
} }
/// {{% boxes/note %}} /// **NOTE:**
/// Replaced by [`GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid) /// Replaced by [`GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid)
/// (requires authentication). /// (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 /// 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 /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more
/// information. /// information.
/// {{% /boxes/warning %}} ///
/// ///
/// [serverName] The server name from the `mxc://` URI (the authority component). /// [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) /// Replaced by [`GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediadownloadservernamemediaidfilename)
/// (requires authentication). /// (requires authentication).
/// {{% /boxes/note %}} ///
/// ///
/// This will download content from the content repository (same as /// This will download content from the content repository (same as
/// the previous endpoint) but replace the target file name with the one /// the previous endpoint) but replace the target file name with the one
/// provided by the caller. /// provided by the caller.
/// ///
/// {{% boxes/warning %}} /// **WARNING:**
/// {{% changed-in v="1.11" %}} This endpoint MAY return `404 M_NOT_FOUND` /// **[Changed in `v1.11`]** This endpoint MAY return `404 M_NOT_FOUND`
/// for media which exists, but is after the server froze unauthenticated /// 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 /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more
/// information. /// information.
/// {{% /boxes/warning %}} ///
/// ///
/// [serverName] The server name from the `mxc://` URI (the authority component). /// [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). /// 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 /// 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. /// 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<String, Object?>); return PreviewForUrl.fromJson(json as Map<String, Object?>);
} }
/// {{% boxes/note %}} /// **NOTE:**
/// Replaced by [`GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid) /// Replaced by [`GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`](https://spec.matrix.org/unstable/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid)
/// (requires authentication). /// (requires authentication).
/// {{% /boxes/note %}} ///
/// ///
/// Download a thumbnail of content from the content repository. /// 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. /// See the [Thumbnails](https://spec.matrix.org/unstable/client-server-api/#thumbnails) section for more information.
/// ///
/// {{% boxes/warning %}} /// **WARNING:**
/// {{% changed-in v="1.11" %}} This endpoint MAY return `404 M_NOT_FOUND` /// **[Changed in `v1.11`]** This endpoint MAY return `404 M_NOT_FOUND`
/// for media which exists, but is after the server froze unauthenticated /// 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 /// media access. See [Client Behaviour](https://spec.matrix.org/unstable/client-server-api/#content-repo-client-behaviour) for more
/// information. /// information.
/// {{% /boxes/warning %}} ///
/// ///
/// [serverName] The server name from the `mxc://` URI (the authority component). /// [serverName] The server name from the `mxc://` URI (the authority component).
/// ///

File diff suppressed because it is too large Load Diff

View File

@ -184,6 +184,17 @@ class MatrixApi extends Api {
return; return;
} }
/// Variant of updateDevice operation that deletes the device displayname by
/// setting `display_name: null`.
Future<void> 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 /// This API provides credentials for the client to use when initiating
/// calls. /// calls.
@override @override
@ -233,3 +244,9 @@ class EventTooLarge implements Exception {
int maxSize, actualSize; int maxSize, actualSize;
EventTooLarge(this.maxSize, this.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;

View File

@ -61,6 +61,10 @@ abstract class EventTypes {
'org.matrix.call.asserted_identity'; 'org.matrix.call.asserted_identity';
static const String Unknown = 'm.unknown'; 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 // To device event types
static const String RoomKey = 'm.room_key'; static const String RoomKey = 'm.room_key';
static const String ForwardedRoomKey = 'm.forwarded_room_key'; static const String ForwardedRoomKey = 'm.forwarded_room_key';

View File

@ -22,7 +22,7 @@
*/ */
import 'package:matrix/matrix_api_lite/utils/print_logs_native.dart' 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 { enum Level {
wtf, wtf,

View File

@ -1,4 +1,6 @@
import 'dart:html'; import 'dart:js_interop';
import 'package:web/web.dart';
import 'package:matrix/matrix_api_lite.dart'; import 'package:matrix/matrix_api_lite.dart';
@ -13,22 +15,22 @@ extension PrintLogs on LogEvent {
} }
switch (level) { switch (level) {
case Level.wtf: case Level.wtf:
window.console.error('!!!CRITICAL!!! $logsStr'); console.error('!!!CRITICAL!!! $logsStr'.toJS);
break; break;
case Level.error: case Level.error:
window.console.error(logsStr); console.error(logsStr.toJS);
break; break;
case Level.warning: case Level.warning:
window.console.warn(logsStr); console.warn(logsStr.toJS);
break; break;
case Level.info: case Level.info:
window.console.info(logsStr); console.info(logsStr.toJS);
break; break;
case Level.debug: case Level.debug:
window.console.debug(logsStr); console.debug(logsStr.toJS);
break; break;
case Level.verbose: case Level.verbose:
window.console.log(logsStr); console.log(logsStr.toJS);
break; break;
} }
} }

View File

@ -1611,14 +1611,23 @@ class Client extends MatrixApi {
// We send an empty String to remove the avatar. Sending Null **should** // We send an empty String to remove the avatar. Sending Null **should**
// work but it doesn't with Synapse. See: // work but it doesn't with Synapse. See:
// https://gitlab.com/famedly/company/frontend/famedlysdk/-/issues/254 // 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( final uploadResp = await uploadContent(
file.bytes, file.bytes,
filename: file.name, filename: file.name,
contentType: file.mimeType, contentType: file.mimeType,
); );
await setAvatarUrl(userID!, uploadResp); await setProfileField(
userID!,
'avatar_url',
{'avatar_url': uploadResp.toString()},
);
return; return;
} }
@ -2632,13 +2641,15 @@ class Client extends MatrixApi {
final id = entry.key; final id = entry.key;
final syncRoomUpdate = entry.value; final syncRoomUpdate = entry.value;
final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate);
// Is the timeline limited? Then all previous messages should be // Is the timeline limited? Then all previous messages should be
// removed from the database! // removed from the database!
if (syncRoomUpdate is JoinedRoomUpdate && if (syncRoomUpdate is JoinedRoomUpdate &&
syncRoomUpdate.timeline?.limited == true) { syncRoomUpdate.timeline?.limited == true) {
await database.deleteTimelineForRoom(id); await database.deleteTimelineForRoom(id);
room.lastEvent = null;
} }
final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate);
final timelineUpdateType = direction != null final timelineUpdateType = direction != null
? (direction == Direction.b ? (direction == Direction.b
@ -2738,6 +2749,24 @@ class Client extends MatrixApi {
Logs().d('Skip store LeftRoomUpdate for unknown room', id); Logs().d('Skip store LeftRoomUpdate for unknown room', id);
continue; 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); await database.storeRoomUpdate(id, syncRoomUpdate, room.lastEvent, this);
} }
} }
@ -3035,13 +3064,6 @@ class Client extends MatrixApi {
} }
if (type != EventUpdateType.timeline) break; 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? // Is this event redacting the last event?
if (event.type == EventTypes.Redaction && if (event.type == EventTypes.Redaction &&
({ ({
@ -3758,10 +3780,30 @@ class Client extends MatrixApi {
/// Ignore another user. This will clear the local cached messages to /// Ignore another user. This will clear the local cached messages to
/// hide all previous messages from this user. /// hide all previous messages from this user.
Future<void> ignoreUser(String userId) async { Future<void> ignoreUser(
String userId, {
/// Whether to also decline all invites and leave DM rooms with this user.
bool leaveRooms = true,
}) async {
if (!userId.isValidMatrixId) { if (!userId.isValidMatrixId) {
throw Exception('$userId is not a valid mxid!'); 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', { await setAccountData(userID!, 'm.ignored_user_list', {
'ignored_users': Map.fromEntries( 'ignored_users': Map.fromEntries(
(ignoredUsers..add(userId)).map((key) => MapEntry(key, {})), (ignoredUsers..add(userId)).map((key) => MapEntry(key, {})),

View File

@ -1,13 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:html'; import 'dart:js_interop';
import 'dart:indexed_db';
import 'package:web/web.dart';
import 'package:matrix/matrix_api_lite/utils/logs.dart';
import 'package:matrix/src/database/zone_transaction_mixin.dart'; import 'package:matrix/src/database/zone_transaction_mixin.dart';
/// Key-Value store abstraction over IndexedDB so that the sdk database can use /// Key-Value store abstraction over IndexedDB so that the sdk database can use
/// a single interface for all platforms. API is inspired by Hive. /// a single interface for all platforms. API is inspired by Hive.
class BoxCollection with ZoneTransactionMixin { class BoxCollection with ZoneTransactionMixin {
final Database _db; final IDBDatabase _db;
final Set<String> boxNames; final Set<String> boxNames;
final String name; final String name;
@ -18,23 +20,45 @@ class BoxCollection with ZoneTransactionMixin {
Set<String> boxNames, { Set<String> boxNames, {
Object? sqfliteDatabase, Object? sqfliteDatabase,
Object? sqfliteFactory, Object? sqfliteFactory,
IdbFactory? idbFactory, IDBFactory? idbFactory,
int version = 1, int version = 1,
}) async { }) async {
idbFactory ??= window.indexedDB!; idbFactory ??= window.indexedDB;
final db = await idbFactory.open( final dbOpenCompleter = Completer<BoxCollection>();
name, final request = idbFactory.open(name, version);
version: version,
onUpgradeNeeded: (VersionChangeEvent event) {
final db = event.target.result;
for (final name in boxNames) {
if (db.objectStoreNames.contains(name)) continue;
db.createObjectStore(name, autoIncrement: true); request.onerror = (Event event) {
} Logs().e(
}, '[IndexedDBBox] Error loading database - ${request.error?.toString()}',
); );
return BoxCollection(db, boxNames, name); 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<V> openBox<V>(String name) { Box<V> openBox<V>(String name) {
@ -44,7 +68,7 @@ class BoxCollection with ZoneTransactionMixin {
return Box<V>(name, this); return Box<V>(name, this);
} }
List<Future<void> Function(Transaction txn)>? _txnCache; List<Future<void> Function(IDBTransaction txn)>? _txnCache;
Future<void> transaction( Future<void> transaction(
Future<void> Function() action, { Future<void> Function() action, {
@ -52,15 +76,18 @@ class BoxCollection with ZoneTransactionMixin {
bool readOnly = false, bool readOnly = false,
}) => }) =>
zoneTransaction(() async { zoneTransaction(() async {
boxNames ??= _db.objectStoreNames!.toList();
final txnCache = _txnCache = []; final txnCache = _txnCache = [];
await action(); await action();
final cache = final cache =
List<Future<void> Function(Transaction txn)>.from(txnCache); List<Future<void> Function(IDBTransaction txn)>.from(txnCache);
_txnCache = null; _txnCache = null;
if (cache.isEmpty) return; if (cache.isEmpty) return;
final txn =
_db.transaction(boxNames, readOnly ? 'readonly' : 'readwrite'); final transactionCompleter = Completer<void>();
final txn = _db.transaction(
boxNames?.jsify() ?? _db.objectStoreNames,
readOnly ? 'readonly' : 'readwrite',
);
for (final fun in cache) { for (final fun in cache) {
// The IDB methods return a Future in Dart but must not be awaited in // 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 // 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 // https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction
unawaited(fun(txn)); 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<void> clear() async { Future<void> 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) { 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<void> close() async { Future<void> close() async {
@ -87,13 +152,24 @@ class BoxCollection with ZoneTransactionMixin {
return zoneTransaction(() async => _db.close()); return zoneTransaction(() async => _db.close());
} }
@Deprecated('use collection.deleteDatabase now')
static Future<void> delete(String name, [dynamic factory]) =>
(factory ?? window.indexedDB!).deleteDatabase(name);
Future<void> deleteDatabase(String name, [dynamic factory]) async { Future<void> deleteDatabase(String name, [dynamic factory]) async {
await close(); 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<V> {
Box(this.name, this.boxCollection); Box(this.name, this.boxCollection);
Future<List<String>> getAllKeys([Transaction? txn]) async { Future<List<String>> getAllKeys([IDBTransaction? txn]) async {
if (_quickAccessCachedKeys != null) return _quickAccessCachedKeys!.toList(); 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 store = txn.objectStore(name);
final request = store.getAllKeys(null); final getAllKeysCompleter = Completer();
await request.onSuccess.first; final request = store.getAllKeys();
final keys = request.result.cast<String>(); 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<String>() ?? [];
_quickAccessCachedKeys = keys.toSet(); _quickAccessCachedKeys = keys.toSet();
return keys; return keys;
} }
Future<Map<String, V>> getAllValues([Transaction? txn]) async { Future<Map<String, V>> getAllValues([IDBTransaction? txn]) async {
txn ??= boxCollection._db.transaction(name, 'readonly'); txn ??= boxCollection._db.transaction(name.toJS, 'readonly');
final store = txn.objectStore(name); final store = txn.objectStore(name);
final map = <String, V>{}; final map = <String, V>{};
final cursorStream = store.openCursor(autoAdvance: true);
await for (final cursor in cursorStream) { /// NOTE: This is a workaround to get the keys as [IDBObjectStore.getAll()]
map[cursor.key as String] = _fromValue(cursor.value) as V; /// 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; return map;
} }
Future<V?> get(String key, [Transaction? txn]) async { Future<V?> get(String key, [IDBTransaction? txn]) async {
if (_quickAccessCache.containsKey(key)) return _quickAccessCache[key]; 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); 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]; return _quickAccessCache[key];
} }
Future<List<V?>> getAll(List<String> keys, [Transaction? txn]) async { Future<List<V?>> getAll(List<String> keys, [IDBTransaction? txn]) async {
if (keys.every((key) => _quickAccessCache.containsKey(key))) { if (keys.every((key) => _quickAccessCache.containsKey(key))) {
return keys.map((key) => _quickAccessCache[key]).toList(); 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 store = txn.objectStore(name);
final list = await Future.wait( 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++) { for (var i = 0; i < keys.length; i++) {
_quickAccessCache[keys[i]] = list[i]; _quickAccessCache[keys[i]] = list[i];
@ -156,7 +297,7 @@ class Box<V> {
return list; return list;
} }
Future<void> put(String key, V val, [Transaction? txn]) async { Future<void> put(String key, V val, [IDBTransaction? txn]) async {
if (boxCollection._txnCache != null) { if (boxCollection._txnCache != null) {
boxCollection._txnCache!.add((txn) => put(key, val, txn)); boxCollection._txnCache!.add((txn) => put(key, val, txn));
_quickAccessCache[key] = val; _quickAccessCache[key] = val;
@ -164,15 +305,28 @@ class Box<V> {
return; return;
} }
txn ??= boxCollection._db.transaction(name, 'readwrite'); txn ??= boxCollection._db.transaction(name.toJS, 'readwrite');
final store = txn.objectStore(name); 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; _quickAccessCache[key] = val;
_quickAccessCachedKeys?.add(key); _quickAccessCachedKeys?.add(key);
return; return;
} }
Future<void> delete(String key, [Transaction? txn]) async { Future<void> delete(String key, [IDBTransaction? txn]) async {
if (boxCollection._txnCache != null) { if (boxCollection._txnCache != null) {
boxCollection._txnCache!.add((txn) => delete(key, txn)); boxCollection._txnCache!.add((txn) => delete(key, txn));
_quickAccessCache[key] = null; _quickAccessCache[key] = null;
@ -180,9 +334,23 @@ class Box<V> {
return; return;
} }
txn ??= boxCollection._db.transaction(name, 'readwrite'); txn ??= boxCollection._db.transaction(name.toJS, 'readwrite');
final store = txn.objectStore(name); 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 // Set to null instead remove() so that inside of transactions null is
// returned. // returned.
@ -191,7 +359,7 @@ class Box<V> {
return; return;
} }
Future<void> deleteAll(List<String> keys, [Transaction? txn]) async { Future<void> deleteAll(List<String> keys, [IDBTransaction? txn]) async {
if (boxCollection._txnCache != null) { if (boxCollection._txnCache != null) {
boxCollection._txnCache!.add((txn) => deleteAll(keys, txn)); boxCollection._txnCache!.add((txn) => deleteAll(keys, txn));
for (final key in keys) { for (final key in keys) {
@ -201,10 +369,24 @@ class Box<V> {
return; return;
} }
txn ??= boxCollection._db.transaction(name, 'readwrite'); txn ??= boxCollection._db.transaction(name.toJS, 'readwrite');
final store = txn.objectStore(name); final store = txn.objectStore(name);
for (final key in keys) { 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; _quickAccessCache[key] = null;
_quickAccessCachedKeys?.remove(key); _quickAccessCachedKeys?.remove(key);
} }
@ -216,15 +398,28 @@ class Box<V> {
_quickAccessCachedKeys = null; _quickAccessCachedKeys = null;
} }
Future<void> clear([Transaction? txn]) async { Future<void> clear([IDBTransaction? txn]) async {
if (boxCollection._txnCache != null) { if (boxCollection._txnCache != null) {
boxCollection._txnCache!.add((txn) => clear(txn)); boxCollection._txnCache!.add((txn) => clear(txn));
} else { } else {
txn ??= boxCollection._db.transaction(name, 'readwrite'); txn ??= boxCollection._db.transaction(name.toJS, 'readwrite');
final store = txn.objectStore(name); 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(); clearQuickAccessCache();
} }

View File

@ -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/queued_to_device_event.dart';
import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_benchmarked.dart';
import 'package:matrix/src/database/indexeddb_box.dart' import 'package:matrix/src/database/sqflite_box.dart'
if (dart.library.io) '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' import 'package:matrix/src/database/database_file_storage_stub.dart'
if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.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; Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would /// 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 /// lead to an error to import "package:web/web.dart" so this is dynamically
/// typed. /// typed.
final dynamic idbFactory; final dynamic idbFactory;

View File

@ -16,17 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/file_send_request_credentials.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/html_to_text.dart';
import 'package:matrix/src/utils/markdown.dart'; import 'package:matrix/src/utils/markdown.dart';
import 'package:matrix/src/utils/multipart_request_progress.dart';
abstract class RelationshipTypes { abstract class RelationshipTypes {
static const String reply = 'm.in_reply_to'; static const String reply = 'm.in_reply_to';
@ -747,6 +750,10 @@ class Event extends MatrixEvent {
bool getThumbnail = false, bool getThumbnail = false,
Future<Uint8List> Function(Uri)? downloadCallback, Future<Uint8List> Function(Uri)? downloadCallback,
bool fromLocalStoreOnly = false, bool fromLocalStoreOnly = false,
/// Callback which gets triggered on progress containing the amount of
/// downloaded bytes.
void Function(int)? onDownloadProgress,
}) async { }) async {
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
throw ("This event has the type '$type' and so it can't contain an attachment."); 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, (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<String> userIds, bool room}) get mentions {
final mentionsMap = content.tryGetMap<String, Object?>('m.mentions');
return (
userIds: mentionsMap?.tryGetList<String>('user_ids') ?? [],
room: mentionsMap?.tryGet<bool>('room') ?? false,
);
}
} }
enum FileSendingStatus { enum FileSendingStatus {

View File

@ -377,6 +377,73 @@ class Room {
Event? lastEvent; 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<Event?> refreshLastEvent({
timeout = const Duration(seconds: 30),
}) async {
final lastEvent = _refreshingLastEvent ??= _refreshLastEvent();
_refreshingLastEvent = null;
return lastEvent;
}
Future<Event?>? _refreshingLastEvent;
Future<Event?> _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) { void setEphemeral(BasicEvent ephemeral) {
ephemerals[ephemeral.type] = ephemeral; ephemerals[ephemeral.type] = ephemeral;
if (ephemeral.type == 'm.typing') { if (ephemeral.type == 'm.typing') {
@ -443,8 +510,16 @@ class Room {
String get displayname => getLocalizedDisplayname(); String get displayname => getLocalizedDisplayname();
/// When was the last event received. /// When was the last event received.
DateTime get latestEventReceivedTime => DateTime get latestEventReceivedTime {
lastEvent?.originServerTs ?? DateTime.now(); 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 /// Call the Matrix API to change the name of this room. Returns the event ID of the
/// new m.room.name event. /// new m.room.name event.
@ -635,6 +710,7 @@ class Room {
String? threadRootEventId, String? threadRootEventId,
String? threadLastEventId, String? threadLastEventId,
StringBuffer? commandStdout, StringBuffer? commandStdout,
bool addMentions = true,
}) { }) {
if (parseCommands) { if (parseCommands) {
return client.parseAndRunCommand( return client.parseAndRunCommand(
@ -652,6 +728,41 @@ class Room {
'msgtype': msgtype, 'msgtype': msgtype,
'body': message, '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) { if (parseMarkdown) {
final html = markdown( final html = markdown(
event['body'], event['body'],
@ -1996,7 +2107,10 @@ class Room {
Future<String> setAvatar(MatrixFile? file) async { Future<String> setAvatar(MatrixFile? file) async {
final uploadResp = file == null final uploadResp = file == null
? null ? null
: await client.uploadContent(file.bytes, filename: file.name); : await client.uploadContent(
file.bytes,
filename: file.name,
);
return await client.setRoomStateWithKey( return await client.setRoomStateWithKey(
id, id,
EventTypes.RoomAvatar, EventTypes.RoomAvatar,

View File

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
export 'native.dart' if (dart.library.js) 'js.dart';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';

View File

@ -19,6 +19,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:vodozemac/vodozemac.dart';
import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/encryption/utils/base64_unpadded.dart';
import 'package:matrix/src/utils/crypto/crypto.dart'; import 'package:matrix/src/utils/crypto/crypto.dart';
@ -38,8 +40,8 @@ class EncryptedFile {
Future<EncryptedFile> encryptFile(Uint8List input) async { Future<EncryptedFile> encryptFile(Uint8List input) async {
final key = secureRandomBytes(32); final key = secureRandomBytes(32);
final iv = secureRandomBytes(16); final iv = secureRandomBytes(16);
final data = await aesCtr.encrypt(input, key, iv); final data = CryptoUtils.aesCtr(input: input, key: key, iv: iv);
final hash = await sha256(data); final hash = CryptoUtils.sha256(input: data);
return EncryptedFile( return EncryptedFile(
data: data, data: data,
k: base64Url.encode(key).replaceAll('=', ''), k: base64Url.encode(key).replaceAll('=', ''),
@ -51,12 +53,12 @@ Future<EncryptedFile> encryptFile(Uint8List input) async {
/// you would likely want to use [NativeImplementations] and /// you would likely want to use [NativeImplementations] and
/// [Client.nativeImplementations] instead /// [Client.nativeImplementations] instead
Future<Uint8List?> decryptFileImplementation(EncryptedFile input) async { Future<Uint8List?> decryptFileImplementation(EncryptedFile input) async {
if (base64.encode(await sha256(input.data)) != if (base64.encode(CryptoUtils.sha256(input: input.data)) !=
base64.normalize(input.sha256)) { base64.normalize(input.sha256)) {
return null; return null;
} }
final key = base64decodeUnpadded(base64.normalize(input.k)); final key = base64decodeUnpadded(base64.normalize(input.k));
final iv = base64decodeUnpadded(base64.normalize(input.iv)); 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);
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Uint8> pass,
IntPtr passlen,
Pointer<Uint8> salt,
IntPtr saltlen,
IntPtr iter,
Pointer<NativeType> digest,
IntPtr keylen,
Pointer<Uint8> out,
),
int Function(
Pointer<Uint8> pass,
int passlen,
Pointer<Uint8> salt,
int saltlen,
int iter,
Pointer<NativeType> digest,
int keylen,
Pointer<Uint8> out,
)>('PKCS5_PBKDF2_HMAC');
final EVP_sha1 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha1');
final EVP_sha256 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha256');
final EVP_sha512 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha512');
final EVP_aes_128_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_aes_128_ctr');
final EVP_aes_256_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_aes_256_ctr');
final EVP_CIPHER_CTX_new = libcrypto.lookupFunction<
Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_CIPHER_CTX_new');
final EVP_EncryptInit_ex = libcrypto.lookupFunction<
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<NativeType> alg,
Pointer<NativeType> some,
Pointer<Uint8> key,
Pointer<Uint8> iv,
),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<NativeType> alg,
Pointer<NativeType> some,
Pointer<Uint8> key,
Pointer<Uint8> iv,
)>('EVP_EncryptInit_ex');
final EVP_EncryptUpdate = libcrypto.lookupFunction<
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<Uint8> output,
Pointer<IntPtr> outputLen,
Pointer<Uint8> input,
IntPtr inputLen,
),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<Uint8> output,
Pointer<IntPtr> outputLen,
Pointer<Uint8> input,
int inputLen,
)>('EVP_EncryptUpdate');
final EVP_EncryptFinal_ex = libcrypto.lookupFunction<
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<Uint8> data,
Pointer<IntPtr> len,
),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<Uint8> data,
Pointer<IntPtr> len,
)>('EVP_EncryptFinal_ex');
final EVP_CIPHER_CTX_free = libcrypto.lookupFunction<
Pointer<NativeType> Function(Pointer<NativeType> ctx),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
)>('EVP_CIPHER_CTX_free');
final EVP_Digest = libcrypto.lookupFunction<
IntPtr Function(
Pointer<Uint8> data,
IntPtr len,
Pointer<Uint8> hash,
Pointer<IntPtr> hsize,
Pointer<NativeType> alg,
Pointer<NativeType> engine,
),
int Function(
Pointer<Uint8> data,
int len,
Pointer<Uint8> hash,
Pointer<IntPtr> hsize,
Pointer<NativeType> alg,
Pointer<NativeType> 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<IntPtr Function(Pointer<NativeType> ctx),
int Function(Pointer<NativeType> ctx)>('EVP_MD_get_size');
} catch (e) {
return libcrypto.lookupFunction<IntPtr Function(Pointer<NativeType> ctx),
int Function(Pointer<NativeType> ctx)>('EVP_MD_size');
}
}();

View File

@ -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<Uint8List> 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<Uint8List> 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<Uint8List> 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);
}

View File

@ -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<NativeType> ptr;
FutureOr<Uint8List> call(Uint8List data) {
final outSize = EVP_MD_size(ptr);
final mem = malloc.call<Uint8>(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<NativeType> getAlg(int keysize);
FutureOr<Uint8List> encrypt(Uint8List input, Uint8List key, Uint8List iv) {
final alg = getAlg(key.length * 8);
final mem = malloc
.call<Uint8>(sizeOf<IntPtr>() + key.length + iv.length + input.length);
final lenMem = mem.cast<IntPtr>();
final keyMem = mem.elementAt(sizeOf<IntPtr>());
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<NativeType> 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<Uint8List> pbkdf2(
Uint8List passphrase,
Uint8List salt,
Hash hash,
int iterations,
int bits,
) {
final outLen = bits ~/ 8;
final mem = malloc.call<Uint8>(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);
}
}

View File

@ -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<ByteBuffer> 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<ByteBuffer> 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<String> keyUsages,
);
Future<dynamic> importKey(
String format,
dynamic keyData,
dynamic algorithm,
bool extractable,
List<String> keyUsages,
) {
return promiseToFuture(
_importKey(format, keyData, algorithm, extractable, keyUsages),
);
}
@JS('crypto.subtle.exportKey')
external dynamic _exportKey(String algorithm, dynamic key);
Future<dynamic> 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<String> keyUsages,
);
Future<ByteBuffer> deriveKey(
dynamic algorithm,
dynamic baseKey,
dynamic derivedKeyAlgorithm,
bool extractable,
List<String> keyUsages,
) {
return promiseToFuture(
_deriveKey(
algorithm,
baseKey,
derivedKeyAlgorithm,
extractable,
keyUsages,
),
);
}
@JS('crypto.subtle.deriveBits')
external dynamic _deriveBits(dynamic algorithm, dynamic baseKey, int length);
Future<ByteBuffer> 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<ByteBuffer> digest(String algorithm, Uint8List data) {
return promiseToFuture(_digest(algorithm, data));
}

View File

@ -299,5 +299,6 @@ abstract class EventLocalizations {
?.tryGet<String>('key') ?? ?.tryGet<String>('key') ??
body, body,
), ),
EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent,
}; };
} }

View File

@ -318,4 +318,7 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
: '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')} '; : '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')} ';
return '$senderName: ${durationString}Voice message'; return '$senderName: ${durationString}Voice message';
} }
@override
String get refreshingLastEvent => 'Refreshing last event...';
} }

View File

@ -62,6 +62,8 @@ abstract class MatrixLocalizations {
String get cancelledSend; String get cancelledSend;
String get refreshingLastEvent;
String youInvitedBy(String senderName); String youInvitedBy(String senderName);
String invitedBy(String senderName); String invitedBy(String senderName);

View File

@ -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<Uint8List> toBytesWithProgress(void Function(int)? onProgress) {
var length = 0;
final completer = Completer<Uint8List>();
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;
}
}

View File

@ -155,7 +155,10 @@ class NativeImplementationsIsolate extends NativeImplementations {
bool retryInDummy = true, bool retryInDummy = true,
}) { }) {
return runInBackground<Uint8List?, EncryptedFile>( return runInBackground<Uint8List?, EncryptedFile>(
NativeImplementations.dummy.decryptFile, (EncryptedFile args) async {
await vodozemacInit?.call();
return NativeImplementations.dummy.decryptFile(args);
},
file, file,
); );
} }
@ -180,7 +183,10 @@ class NativeImplementationsIsolate extends NativeImplementations {
bool retryInDummy = true, bool retryInDummy = true,
}) { }) {
return runInBackground<Uint8List, KeyFromPassphraseArgs>( return runInBackground<Uint8List, KeyFromPassphraseArgs>(
NativeImplementations.dummy.keyFromPassphrase, (KeyFromPassphraseArgs args) async {
await vodozemacInit?.call();
return NativeImplementations.dummy.keyFromPassphrase(args);
},
args, args,
); );
} }

View File

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:html'; import 'dart:js_interop';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:web/web.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
class NativeImplementationsWebWorker extends NativeImplementations { class NativeImplementationsWebWorker extends NativeImplementations {
@ -23,8 +25,8 @@ class NativeImplementationsWebWorker extends NativeImplementations {
Uri href, { Uri href, {
this.timeout = const Duration(seconds: 30), this.timeout = const Duration(seconds: 30),
this.onStackTrace = defaultStackTraceHandler, this.onStackTrace = defaultStackTraceHandler,
}) : worker = Worker(href.toString()) { }) : worker = Worker(href.toString().toJS) {
worker.onMessage.listen(_handleIncomingMessage); worker.onmessage = _handleIncomingMessage.toJS;
} }
Future<T> operation<T, U>(WebWorkerOperations name, U argument) async { Future<T> operation<T, U>(WebWorkerOperations name, U argument) async {
@ -32,27 +34,26 @@ class NativeImplementationsWebWorker extends NativeImplementations {
final completer = Completer<T>(); final completer = Completer<T>();
_completers[label] = completer; _completers[label] = completer;
final message = WebWorkerData(label, name, argument); final message = WebWorkerData(label, name, argument);
worker.postMessage(message.toJson()); worker.postMessage(message.toJson().jsify());
return completer.future.timeout(timeout); return completer.future.timeout(timeout);
} }
Future<void> _handleIncomingMessage(MessageEvent event) async { void _handleIncomingMessage(MessageEvent event) async {
final data = event.data; final data = event.data.dartify() as LinkedHashMap;
// don't forget handling errors of our second thread... // don't forget handling errors of our second thread...
if (data['label'] == 'stacktrace') { if (data['label'] == 'stacktrace') {
final origin = event.data['origin']; final origin = data['origin'];
final completer = _completers[origin]; final completer = _completers[origin];
final error = event.data['error']!; final error = data['error']!;
final stackTrace = final stackTrace = await onStackTrace.call(data['stacktrace'] as String);
await onStackTrace.call(event.data['stacktrace'] as String);
completer?.completeError( completer?.completeError(
WebWorkerError(error: error, stackTrace: stackTrace), WebWorkerError(error: error, stackTrace: stackTrace),
); );
} else { } else {
final response = WebWorkerData.fromJson(event.data); final response = WebWorkerData.fromJson(data);
_completers[response.label]!.complete(response.data); _completers[response.label]!.complete(response.data);
} }
} }

View File

@ -1,13 +1,11 @@
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
import 'dart:async'; import 'dart:async';
import 'dart:html'; import 'dart:collection';
import 'dart:indexed_db'; import 'dart:js_interop';
import 'dart:js';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:js/js.dart'; import 'package:web/web.dart';
import 'package:js/js_util.dart';
import 'package:matrix/matrix.dart' hide Event; import 'package:matrix/matrix.dart' hide Event;
import 'package:matrix/src/utils/web_worker/native_implementations_web_worker.dart'; 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. /// the web worker in your CI pipeline.
/// ///
DedicatedWorkerGlobalScope get _workerScope =>
(globalContext as DedicatedWorkerGlobalScope).self
as DedicatedWorkerGlobalScope;
@pragma('dart2js:tryInline') @pragma('dart2js:tryInline')
Future<void> startWebWorker() async { Future<void> startWebWorker() async {
print('[native implementations worker]: Starting...'); Logs().i('[native implementations worker]: Starting...');
setProperty( _workerScope.onmessage = (MessageEvent event) {
context['self'] as Object, final data = event.data.dartify() as LinkedHashMap;
'onmessage', try {
allowInterop( final operation = WebWorkerData.fromJson(data);
(MessageEvent event) async { switch (operation.name) {
final data = event.data; case WebWorkerOperations.shrinkImage:
try { final result = MatrixImageFile.resizeImplementation(
final operation = WebWorkerData.fromJson(data); MatrixImageFileResizeArguments.fromJson(
switch (operation.name) { Map.from(operation.data as Map),
case WebWorkerOperations.shrinkImage: ),
final result = MatrixImageFile.resizeImplementation( );
MatrixImageFileResizeArguments.fromJson( _sendResponse(
Map.from(operation.data as Map), operation.label as double,
), result?.toJson(),
); );
sendResponse(operation.label as double, result?.toJson()); break;
break; case WebWorkerOperations.calcImageMetadata:
case WebWorkerOperations.calcImageMetadata: final result = MatrixImageFile.calcMetadataImplementation(
final result = MatrixImageFile.calcMetadataImplementation( Uint8List.fromList(
Uint8List.fromList( (operation.data as List).whereType<int>().toList(),
(operation.data as JsArray).whereType<int>().toList(), ),
), );
); _sendResponse(
sendResponse(operation.label as double, result?.toJson()); operation.label as double,
break; result?.toJson(),
default: );
throw TypeError(); break;
} default:
} on Event catch (e, s) { throw TypeError();
allowInterop(_replyError) }
.call((e.target as Request).error, s, data['label'] as double); } catch (e, s) {
} catch (e, s) { _replyError(e, s, data['label'] as double);
allowInterop(_replyError).call(e, s, data['label'] as double); }
} }.toJS;
},
),
);
} }
void sendResponse(double label, dynamic response) { void _sendResponse(
double label,
dynamic response,
) {
try { try {
self.postMessage({ _workerScope.postMessage(
'label': label, {
'data': response, 'label': label,
}); 'data': response,
}.jsify(),
);
} catch (e, s) { } 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) { if (error != null) {
try { try {
final jsError = jsify(error); final jsError = error.jsify();
if (jsError != null) { if (jsError != null) {
error = jsError; error = jsError;
} }
@ -97,24 +105,15 @@ void _replyError(Object? error, StackTrace stackTrace, double origin) {
} }
} }
try { try {
self.postMessage({ _workerScope.postMessage(
'label': 'stacktrace', {
'origin': origin, 'label': 'stacktrace',
'error': error, 'origin': origin,
'stacktrace': stackTrace.toString(), 'error': error,
}); 'stacktrace': stackTrace.toString(),
}.jsify(),
);
} catch (e, s) { } catch (e, s) {
print('[native implementations worker] Error responding: $e, $s'); Logs().e('[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)]);
} }
} }

View File

@ -197,7 +197,6 @@ class GroupCallSession {
} }
return room.removeFamedlyCallMemberEvent( return room.removeFamedlyCallMemberEvent(
groupCallId, groupCallId,
client.deviceID!,
voip, voip,
application: application, application: application,
scope: scope, scope: scope,

View File

@ -121,12 +121,14 @@ extension FamedlyCallMemberEventsExtension on Room {
await setFamedlyCallMemberEvent( await setFamedlyCallMemberEvent(
newContent, newContent,
callMembership.voip, callMembership.voip,
callMembership.callId,
application: callMembership.application,
scope: callMembership.scope,
); );
} }
Future<void> removeFamedlyCallMemberEvent( Future<void> removeFamedlyCallMemberEvent(
String groupCallId, String groupCallId,
String deviceId,
VoIP voip, { VoIP voip, {
String? application = 'm.call', String? application = 'm.call',
String? scope = 'm.room', String? scope = 'm.room',
@ -140,7 +142,7 @@ extension FamedlyCallMemberEventsExtension on Room {
ownMemberships.removeWhere( ownMemberships.removeWhere(
(mem) => (mem) =>
mem.callId == groupCallId && mem.callId == groupCallId &&
mem.deviceId == deviceId && mem.deviceId == client.deviceID! &&
mem.application == application && mem.application == application &&
mem.scope == scope, mem.scope == scope,
); );
@ -148,7 +150,13 @@ extension FamedlyCallMemberEventsExtension on Room {
final newContent = { final newContent = {
'memberships': List.from(ownMemberships.map((e) => e.toJson())), 'memberships': List.from(ownMemberships.map((e) => e.toJson())),
}; };
await setFamedlyCallMemberEvent(newContent, voip); await setFamedlyCallMemberEvent(
newContent,
voip,
groupCallId,
application: application,
scope: scope,
);
_restartDelayedLeaveEventTimer?.cancel(); _restartDelayedLeaveEventTimer?.cancel();
if (_delayedLeaveEventId != null) { if (_delayedLeaveEventId != null) {
@ -163,7 +171,10 @@ extension FamedlyCallMemberEventsExtension on Room {
Future<void> setFamedlyCallMemberEvent( Future<void> setFamedlyCallMemberEvent(
Map<String, List> newContent, Map<String, List> newContent,
VoIP voip, VoIP voip,
) async { String groupCallId, {
String? application = 'm.call',
String? scope = 'm.room',
}) async {
if (canJoinGroupCall) { if (canJoinGroupCall) {
final stateKey = (roomVersion?.contains('msc3757') ?? false) final stateKey = (roomVersion?.contains('msc3757') ?? false)
? '${client.userID!}_${client.deviceID!}' ? '${client.userID!}_${client.deviceID!}'
@ -175,7 +186,7 @@ extension FamedlyCallMemberEventsExtension on Room {
/// can use delayed events and haven't used it yet /// can use delayed events and haven't used it yet
if (useDelayedEvents && _delayedLeaveEventId == null) { if (useDelayedEvents && _delayedLeaveEventId == null) {
// get existing ones // get existing ones and cancel them
final List<ScheduledDelayedEvent> alreadyScheduledEvents = []; final List<ScheduledDelayedEvent> alreadyScheduledEvents = [];
String? nextBatch; String? nextBatch;
final sEvents = await client.getScheduledDelayedEvents(); final sEvents = await client.getScheduledDelayedEvents();
@ -200,14 +211,39 @@ extension FamedlyCallMemberEventsExtension on Room {
); );
} }
Map<String, List> 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( _delayedLeaveEventId = await client.setRoomStateWithKeyWithDelay(
id, id,
EventTypes.GroupCallMember, EventTypes.GroupCallMember,
stateKey, stateKey,
voip.timeouts!.delayedEventApplyLeave.inMilliseconds, voip.timeouts!.delayedEventApplyLeave.inMilliseconds,
{ newContent,
'memberships': [],
},
); );
_restartDelayedLeaveEventTimer = Timer.periodic( _restartDelayedLeaveEventTimer = Timer.periodic(

View File

@ -47,7 +47,7 @@ class WrappedMediaStream {
Future<void> dispose() async { Future<void> dispose() async {
// AOT it // 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 // libwebrtc does not provide a way to clone MediaStreams. So stopping the
// local stream here would break calls with all other participants if anyone // local stream here would break calls with all other participants if anyone

View File

@ -14,14 +14,10 @@ dependencies:
blurhash_dart: ^1.1.0 blurhash_dart: ^1.1.0
canonical_json: ^1.1.0 canonical_json: ^1.1.0
collection: ^1.15.0 collection: ^1.15.0
crypto: ^3.0.0
enhanced_enum: ^0.2.4
ffi: ^2.0.0
html: ^0.15.0 html: ^0.15.0
html_unescape: ^2.0.0 html_unescape: ^2.0.0
http: ">=0.13.0 <2.0.0" http: ">=0.13.0 <2.0.0"
image: ^4.0.15 image: ^4.0.15
js: ^0.6.3
markdown: ^7.1.1 markdown: ^7.1.1
mime: ">=1.0.0 <3.0.0" mime: ">=1.0.0 <3.0.0"
path: ^1.9.1 path: ^1.9.1
@ -31,7 +27,8 @@ dependencies:
sqflite_common: ^2.4.5 sqflite_common: ^2.4.5
sqlite3: ^2.1.0 sqlite3: ^2.1.0
typed_data: ^1.3.2 typed_data: ^1.3.2
vodozemac: ^0.2.0 vodozemac: ^0.3.0
web: ^1.1.1
webrtc_interface: ^1.2.0 webrtc_interface: ^1.2.0
dev_dependencies: dev_dependencies:
@ -41,4 +38,4 @@ dev_dependencies:
import_sorter: ^4.6.0 import_sorter: ^4.6.0
lints: ^5.0.0 lints: ^5.0.0
sqflite_common_ffi: ^2.3.4+4 # sqflite_common_ffi aggressively requires newer dart versions sqflite_common_ffi: ^2.3.4+4 # sqflite_common_ffi aggressively requires newer dart versions
test: ^1.25.13 test: ^1.25.13

View File

@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
rm -rf rust 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 ./ mv ./dart-vodozemac/rust ./
rm -rf dart-vodozemac rm -rf dart-vodozemac
cd ./rust cd ./rust

View File

@ -1,8 +1,8 @@
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:matrix/src/database/indexeddb_box.dart' import 'package:matrix/src/database/sqflite_box.dart'
if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; if (dart.library.js_interop) 'package:matrix/src/database/indexeddb_box.dart';
void main() { void main() {
group('Box tests', () { group('Box tests', () {
@ -11,7 +11,7 @@ void main() {
const data = {'name': 'Fluffy', 'age': 2}; const data = {'name': 'Fluffy', 'age': 2};
const data2 = {'name': 'Loki', 'age': 4}; const data2 = {'name': 'Loki', 'age': 4};
Database? db; Database? db;
const isWeb = bool.fromEnvironment('dart.library.js_util'); const isWeb = bool.fromEnvironment('dart.library.js_interop');
setUp(() async { setUp(() async {
if (!isWeb) { if (!isWeb) {
db = await databaseFactoryFfi.openDatabase(':memory:'); db = await databaseFactoryFfi.openDatabase(':memory:');

View File

@ -201,26 +201,26 @@ void main() {
matrix.getDirectChatFromUserId('@bob:example.com'), matrix.getDirectChatFromUserId('@bob:example.com'),
'!726s6s6q: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); expect(matrix.directChats, matrix.accountData['m.direct']?.content);
// ignore: deprecated_member_use_from_same_package // ignore: deprecated_member_use_from_same_package
expect(matrix.presences.length, 1); expect(matrix.presences.length, 1);
expect(matrix.rooms[2].ephemerals.length, 2); expect(matrix.rooms[1].ephemerals.length, 2);
expect(matrix.rooms[2].typingUsers.length, 1); expect(matrix.rooms[1].typingUsers.length, 1);
expect(matrix.rooms[2].typingUsers[0].id, '@alice:example.com'); expect(matrix.rooms[1].typingUsers[0].id, '@alice:example.com');
expect(matrix.rooms[2].roomAccountData.length, 3); expect(matrix.rooms[1].roomAccountData.length, 3);
expect(matrix.rooms[2].encrypted, true); expect(matrix.rooms[1].encrypted, true);
expect( expect(
matrix.rooms[2].encryptionAlgorithm, matrix.rooms[1].encryptionAlgorithm,
Client.supportedGroupEncryptionAlgorithms.first, Client.supportedGroupEncryptionAlgorithms.first,
); );
expect( expect(
matrix matrix
.rooms[2].receiptState.global.otherUsers['@alice:example.com']?.ts, .rooms[1].receiptState.global.otherUsers['@alice:example.com']?.ts,
1436451550453, 1436451550453,
); );
expect( expect(
matrix.rooms[2].receiptState.global.otherUsers['@alice:example.com'] matrix.rooms[1].receiptState.global.otherUsers['@alice:example.com']
?.eventId, ?.eventId,
'\$7365636s6r6432:example.com', '\$7365636s6r6432:example.com',
); );
@ -231,7 +231,7 @@ void main() {
expect(inviteRoom.states[EventTypes.RoomMember]?.length, 1); expect(inviteRoom.states[EventTypes.RoomMember]?.length, 1);
expect(matrix.rooms.length, 3); expect(matrix.rooms.length, 3);
expect( expect(
matrix.rooms[2].canonicalAlias, matrix.rooms[1].canonicalAlias,
"#famedlyContactDiscovery:${matrix.userID!.split(":")[1]}", "#famedlyContactDiscovery:${matrix.userID!.split(":")[1]}",
); );
expect( expect(
@ -1488,8 +1488,10 @@ void main() {
}); });
test('upload', () async { test('upload', () async {
final client = await getClient(); final client = await getClient();
final response = final response = await client.uploadContent(
await client.uploadContent(Uint8List(0), filename: 'file.jpeg'); Uint8List(0),
filename: 'file.jpeg',
);
expect(response.toString(), 'mxc://example.com/AQwafuaFswefuhsfAFAgsw'); expect(response.toString(), 'mxc://example.com/AQwafuaFswefuhsfAFAgsw');
expect( expect(
await client.database.getFile(response) != null, await client.database.getFile(response) != null,

View File

@ -243,6 +243,9 @@ void main() {
expect(sent, { expect(sent, {
'msgtype': 'm.text', 'msgtype': 'm.text',
'body': '> <@test:fakeServer.notExisting> reply\n\nreply', 'body': '> <@test:fakeServer.notExisting> reply\n\nreply',
'm.mentions': {
'user_ids': ['@test:fakeServer.notExisting'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!1234:fakeServer.notExisting/\$parent_event">In reply to</a> <a href="https://matrix.to/#/@test:fakeServer.notExisting">@test:fakeServer.notExisting</a><br>reply</blockquote></mx-reply>reply', '<mx-reply><blockquote><a href="https://matrix.to/#/!1234:fakeServer.notExisting/\$parent_event">In reply to</a> <a href="https://matrix.to/#/@test:fakeServer.notExisting">@test:fakeServer.notExisting</a><br>reply</blockquote></mx-reply>reply',

View File

@ -48,462 +48,476 @@ class MockSSSS extends SSSS {
} }
void main() { void main() {
group('SSSS', tags: 'olm', () { group(
Logs().level = Level.error; 'SSSS',
tags: 'olm',
() {
Logs().level = Level.error;
late Client client; late Client client;
setUpAll(() async { setUpAll(() async {
await vod.init( await vod.init(
wasmPath: './pkg/', wasmPath: './pkg/',
libraryPath: './rust/target/debug/', libraryPath: './rust/target/debug/',
); );
client = await getClient(); 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),
}); });
expect(await handle.getStored('best animal'), 'foxies');
});
test('encode / decode recovery key', () async { test('basic things', () async {
final key = Uint8List.fromList(secureRandomBytes(32)); expect(
final encoded = SSSS.encodeRecoveryKey(key); client.encryption!.ssss.defaultKeyId,
var decoded = SSSS.decodeRecoveryKey(encoded); '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3',
expect(key, decoded); );
});
decoded = SSSS.decodeRecoveryKey('$encoded \n\t'); test('encrypt / decrypt', () async {
expect(key, decoded); final key = Uint8List.fromList(secureRandomBytes(32));
final handle = client.encryption!.ssss.open(); final enc = await SSSS.encryptAes('secret foxies', key, 'name');
await handle.unlock(recoveryKey: ssssKey); final dec = await SSSS.decryptAes(enc, key, 'name');
expect(handle.recoveryKey, ssssKey); expect(dec, 'secret foxies');
}); });
test('cache', () async { test('store', () async {
await client.encryption!.ssss.clearCache(); final handle = client.encryption!.ssss.open();
final handle = var failed = false;
client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); try {
await handle.unlock(recoveryKey: ssssKey, postUnlock: false); await handle.unlock(passphrase: 'invalid');
expect( } catch (_) {
(await client.encryption!.ssss failed = true;
.getCached(EventTypes.CrossSigningSelfSigning)) != }
null, expect(failed, true);
false, expect(handle.isUnlocked, false);
); failed = false;
expect( try {
(await client.encryption!.ssss await handle.unlock(recoveryKey: 'invalid');
.getCached(EventTypes.CrossSigningUserSigning)) != } catch (_) {
null, failed = true;
false, }
); expect(failed, true);
await handle.getStored(EventTypes.CrossSigningSelfSigning); expect(handle.isUnlocked, false);
expect( await handle.unlock(passphrase: ssssPassphrase);
(await client.encryption!.ssss await handle.unlock(recoveryKey: ssssKey);
.getCached(EventTypes.CrossSigningSelfSigning)) != expect(handle.isUnlocked, true);
null, FakeMatrixApi.calledEndpoints.clear();
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('postUnlock', () async { // OpenSSSS store waits for accountdata to be updated before returning
await client.encryption!.ssss.clearCache(); // but we can't update that before the below endpoint is not hit.
client.userDeviceKeys[client.userID!]!.masterKey! await handle.ssss
.setDirectVerified(false); .store('best animal', 'foxies', handle.keyId, handle.privateKey!);
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,
);
});
test('make share requests', () async { final content = FakeMatrixApi
final key = .calledEndpoints[
client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/best%20animal']!
key.setDirectVerified(true); .first;
FakeMatrixApi.calledEndpoints.clear(); client.accountData['best animal'] = BasicEvent.fromJson({
await client.encryption!.ssss.request('some.type', [key]); 'type': 'best animal',
expect( 'content': json.decode(content),
FakeMatrixApi.calledEndpoints.keys.any( });
(k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), expect(await handle.getStored('best animal'), 'foxies');
), });
true,
);
});
test('answer to share requests', () async { test('encode / decode recovery key', () async {
var event = ToDeviceEvent( final key = Uint8List.fromList(secureRandomBytes(32));
sender: client.userID!, final encoded = SSSS.encodeRecoveryKey(key);
type: 'm.secret.request', var decoded = SSSS.decodeRecoveryKey(encoded);
content: { expect(key, decoded);
'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,
);
// now test some fail scenarios decoded = SSSS.decodeRecoveryKey('$encoded \n\t');
expect(key, decoded);
// not by us final handle = client.encryption!.ssss.open();
event = ToDeviceEvent( await handle.unlock(recoveryKey: ssssKey);
sender: '@someotheruser:example.org', expect(handle.recoveryKey, ssssKey);
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 test('cache', () async {
event = ToDeviceEvent( await client.encryption!.ssss.clearCache();
sender: client.userID!, final handle =
type: 'm.secret.request', client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning);
content: { await handle.unlock(recoveryKey: ssssKey, postUnlock: false);
'action': 'request', expect(
'requesting_device_id': 'OTHERDEVICE', (await client.encryption!.ssss
'name': 'm.unknown.secret', .getCached(EventTypes.CrossSigningSelfSigning)) !=
'request_id': '1', null,
}, false,
); );
FakeMatrixApi.calledEndpoints.clear(); expect(
await client.encryption!.ssss.handleToDeviceEvent(event); (await client.encryption!.ssss
expect( .getCached(EventTypes.CrossSigningUserSigning)) !=
FakeMatrixApi.calledEndpoints.keys.any( null,
(k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), false,
), );
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 test('postUnlock', () async {
event = ToDeviceEvent( await client.encryption!.ssss.clearCache();
sender: client.userID!, client.userDeviceKeys[client.userID!]!.masterKey!
type: 'm.secret.request', .setDirectVerified(false);
content: { final handle =
'action': 'request_cancellation', client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning);
'requesting_device_id': 'OTHERDEVICE', await handle.unlock(recoveryKey: ssssKey);
'name': EventTypes.CrossSigningSelfSigning, expect(
'request_id': '1', (await client.encryption!.ssss
}, .getCached(EventTypes.CrossSigningSelfSigning)) !=
); null,
FakeMatrixApi.calledEndpoints.clear(); true,
await client.encryption!.ssss.handleToDeviceEvent(event); );
expect( expect(
FakeMatrixApi.calledEndpoints.keys.any( (await client.encryption!.ssss
(k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'), .getCached(EventTypes.CrossSigningUserSigning)) !=
), null,
false, true,
); );
expect(
(await client.encryption!.ssss.getCached(EventTypes.MegolmBackup)) !=
null,
true,
);
expect(
client.userDeviceKeys[client.userID!]!.masterKey!.directVerified,
true,
);
});
// device not verified test('make share requests', () async {
final key = final key =
client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!;
key.setDirectVerified(false); key.setDirectVerified(true);
client.userDeviceKeys[client.userID!]!.masterKey! FakeMatrixApi.calledEndpoints.clear();
.setDirectVerified(false); await client.encryption!.ssss.request('some.type', [key]);
event = ToDeviceEvent( expect(
sender: client.userID!, FakeMatrixApi.calledEndpoints.keys.any(
type: 'm.secret.request', (k) => k.startsWith('/client/v3/sendToDevice/m.room.encrypted'),
content: { ),
'action': 'request', true,
'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 { test('answer to share requests', () async {
final key = var event = ToDeviceEvent(
client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; sender: client.userID!,
key.setDirectVerified(true); type: 'm.secret.request',
final handle = content: {
client.encryption!.ssss.open(EventTypes.CrossSigningSelfSigning); 'action': 'request',
await handle.unlock(recoveryKey: ssssKey); '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(); // now test some fail scenarios
client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request('best animal', [key]); // not by us
var event = ToDeviceEvent( event = ToDeviceEvent(
sender: client.userID!, sender: '@someotheruser:example.org',
type: 'm.secret.send', type: 'm.secret.request',
content: { content: {
'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, 'action': 'request',
'secret': 'foxies!', 'requesting_device_id': 'OTHERDEVICE',
}, 'name': EventTypes.CrossSigningSelfSigning,
encryptedContent: { 'request_id': '1',
'sender_key': key.curve25519Key, },
}, );
); FakeMatrixApi.calledEndpoints.clear();
await client.encryption!.ssss.handleToDeviceEvent(event); await client.encryption!.ssss.handleToDeviceEvent(event);
expect(await client.encryption!.ssss.getCached('best animal'), 'foxies!'); 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(); await client.encryption!.ssss.clearCache();
client.encryption!.ssss.pendingShareRequests.clear(); client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request(type, [key]); await client.encryption!.ssss.request('best animal', [key]);
event = ToDeviceEvent( var event = ToDeviceEvent(
sender: client.userID!, sender: client.userID!,
type: 'm.secret.send', type: 'm.secret.send',
content: { content: {
'request_id': 'request_id':
client.encryption!.ssss.pendingShareRequests.keys.first, client.encryption!.ssss.pendingShareRequests.keys.first,
'secret': secret, 'secret': 'foxies!',
}, },
encryptedContent: { encryptedContent: {
'sender_key': key.curve25519Key, 'sender_key': key.curve25519Key,
}, },
); );
await client.encryption!.ssss.handleToDeviceEvent(event); 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 // test different fail scenarios
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);
// unknown request id // not encrypted
await client.encryption!.ssss.clearCache(); await client.encryption!.ssss.clearCache();
client.encryption!.ssss.pendingShareRequests.clear(); client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request('best animal', [key]); await client.encryption!.ssss.request('best animal', [key]);
event = ToDeviceEvent( event = ToDeviceEvent(
sender: client.userID!, sender: client.userID!,
type: 'm.secret.send', type: 'm.secret.send',
content: { content: {
'request_id': 'invalid', 'request_id':
'secret': 'foxies!', 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'), null);
await client.encryption!.ssss.handleToDeviceEvent(event);
expect(await client.encryption!.ssss.getCached('best animal'), null);
// not from a device we sent the request to // unknown request id
await client.encryption!.ssss.clearCache(); await client.encryption!.ssss.clearCache();
client.encryption!.ssss.pendingShareRequests.clear(); client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request('best animal', [key]); await client.encryption!.ssss.request('best animal', [key]);
event = ToDeviceEvent( event = ToDeviceEvent(
sender: client.userID!, sender: client.userID!,
type: 'm.secret.send', type: 'm.secret.send',
content: { content: {
'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, 'request_id': 'invalid',
'secret': 'foxies!', 'secret': 'foxies!',
}, },
encryptedContent: { encryptedContent: {
'sender_key': 'invalid', 'sender_key': key.curve25519Key,
}, },
); );
await client.encryption!.ssss.handleToDeviceEvent(event); await client.encryption!.ssss.handleToDeviceEvent(event);
expect(await client.encryption!.ssss.getCached('best animal'), null); expect(await client.encryption!.ssss.getCached('best animal'), null);
// secret not a string // not from a device we sent the request to
await client.encryption!.ssss.clearCache(); await client.encryption!.ssss.clearCache();
client.encryption!.ssss.pendingShareRequests.clear(); client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request('best animal', [key]); await client.encryption!.ssss.request('best animal', [key]);
event = ToDeviceEvent( event = ToDeviceEvent(
sender: client.userID!, sender: client.userID!,
type: 'm.secret.send', type: 'm.secret.send',
content: { content: {
'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, 'request_id':
'secret': 42, client.encryption!.ssss.pendingShareRequests.keys.first,
}, 'secret': 'foxies!',
encryptedContent: { },
'sender_key': key.curve25519Key, encryptedContent: {
}, 'sender_key': 'invalid',
); },
await client.encryption!.ssss.handleToDeviceEvent(event); );
expect(await client.encryption!.ssss.getCached('best animal'), null); await client.encryption!.ssss.handleToDeviceEvent(event);
expect(await client.encryption!.ssss.getCached('best animal'), null);
// validator doesn't check out // secret not a string
await client.encryption!.ssss.clearCache(); await client.encryption!.ssss.clearCache();
client.encryption!.ssss.pendingShareRequests.clear(); client.encryption!.ssss.pendingShareRequests.clear();
await client.encryption!.ssss.request(EventTypes.MegolmBackup, [key]); await client.encryption!.ssss.request('best animal', [key]);
event = ToDeviceEvent( event = ToDeviceEvent(
sender: client.userID!, sender: client.userID!,
type: 'm.secret.send', type: 'm.secret.send',
content: { content: {
'request_id': client.encryption!.ssss.pendingShareRequests.keys.first, 'request_id':
'secret': 'foxies!', client.encryption!.ssss.pendingShareRequests.keys.first,
}, 'secret': 42,
encryptedContent: { },
'sender_key': key.curve25519Key, encryptedContent: {
}, 'sender_key': key.curve25519Key,
); },
await client.encryption!.ssss.handleToDeviceEvent(event); );
expect( await client.encryption!.ssss.handleToDeviceEvent(event);
await client.encryption!.ssss.getCached(EventTypes.MegolmBackup), expect(await client.encryption!.ssss.getCached('best animal'), null);
null,
);
});
test('request all', () async { // validator doesn't check out
final key = await client.encryption!.ssss.clearCache();
client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!; client.encryption!.ssss.pendingShareRequests.clear();
key.setDirectVerified(true); await client.encryption!.ssss.request(EventTypes.MegolmBackup, [key]);
await client.encryption!.ssss.clearCache(); event = ToDeviceEvent(
client.encryption!.ssss.pendingShareRequests.clear(); sender: client.userID!,
await client.encryption!.ssss.maybeRequestAll([key]); type: 'm.secret.send',
expect(client.encryption!.ssss.pendingShareRequests.length, 3); 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 { test('request all', () async {
client.userDeviceKeys[client.userID!]!.masterKey!.setDirectVerified(true); final key =
client.encryption!.ssss = MockSSSS(client.encryption!); client.userDeviceKeys[client.userID!]!.deviceKeys['OTHERDEVICE']!;
(client.encryption!.ssss as MockSSSS).requestedSecrets = false; key.setDirectVerified(true);
await client.encryption!.ssss.periodicallyRequestMissingCache(); await client.encryption!.ssss.clearCache();
expect((client.encryption!.ssss as MockSSSS).requestedSecrets, true); client.encryption!.ssss.pendingShareRequests.clear();
// it should only retry once every 15 min await client.encryption!.ssss.maybeRequestAll([key]);
(client.encryption!.ssss as MockSSSS).requestedSecrets = false; expect(client.encryption!.ssss.pendingShareRequests.length, 3);
await client.encryption!.ssss.periodicallyRequestMissingCache(); });
expect((client.encryption!.ssss as MockSSSS).requestedSecrets, false);
});
test('createKey', () async { test('periodicallyRequestMissingCache', () async {
// with passphrase client.userDeviceKeys[client.userID!]!.masterKey!
var newKey = await client.encryption!.ssss.createKey('test'); .setDirectVerified(true);
expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); client.encryption!.ssss = MockSSSS(client.encryption!);
var testKey = client.encryption!.ssss.open(newKey.keyId); (client.encryption!.ssss as MockSSSS).requestedSecrets = false;
await testKey.unlock(passphrase: 'test'); await client.encryption!.ssss.periodicallyRequestMissingCache();
await testKey.setPrivateKey(newKey.privateKey!); 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 test('createKey', () async {
newKey = await client.encryption!.ssss.createKey(); // with passphrase
expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true); var newKey = await client.encryption!.ssss.createKey('test');
testKey = client.encryption!.ssss.open(newKey.keyId); expect(client.encryption!.ssss.isKeyValid(newKey.keyId), true);
await testKey.setPrivateKey(newKey.privateKey!); var testKey = client.encryption!.ssss.open(newKey.keyId);
}); await testKey.unlock(passphrase: 'test');
await testKey.setPrivateKey(newKey.privateKey!);
test('dispose client', () async { // without passphrase
await client.dispose(closeDatabase: true); 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)),
);
} }

View File

@ -2485,6 +2485,30 @@ void main() async {
await room.client.dispose(closeDatabase: true); 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 = <int>[];
await event.downloadAndDecryptAttachment(
onDownloadProgress: progressList.add,
);
await client.dispose();
expect(progressList, [112]);
});
test('downloadAndDecryptAttachment store', tags: 'olm', () async { test('downloadAndDecryptAttachment store', tags: 'olm', () async {
final FILE_BUFF = Uint8List.fromList([0]); final FILE_BUFF = Uint8List.fromList([0]);
var serverHits = 0; var serverHits = 0;
@ -2951,5 +2975,28 @@ void main() async {
Timeline(room: room, chunk: TimelineChunk(events: [targetEvent])); Timeline(room: room, chunk: TimelineChunk(events: [targetEvent]));
expect(await event.getReplyEvent(timeline), 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);
});
}); });
} }

View File

@ -22,10 +22,14 @@ import 'package:http/http.dart' as http;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'fake_client.dart';
void main() { void main() {
/// All Tests related to device keys /// All Tests related to device keys
group('Matrix File', tags: 'olm', () { group('Matrix File', tags: 'olm', () {
setUpAll(() async {
await getClient(); // To trigger vodozemac init
});
Logs().level = Level.error; Logs().level = Level.error;
test('Decrypt', () async { test('Decrypt', () async {
final text = 'hello world'; final text = 'hello world';

View File

@ -883,6 +883,29 @@ void main() {
expect(timeline.events.length, 17); 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', () { test('isFederated', () {
expect(room.isFederated, true); expect(room.isFederated, true);
room.setState( 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 { test('send edit', () async {
FakeMatrixApi.calledEndpoints.clear(); FakeMatrixApi.calledEndpoints.clear();
final dynamic resp = await room.sendTextEvent( final dynamic resp = await room.sendTextEvent(
@ -1066,6 +1119,9 @@ void main() {
expect(content, { expect(content, {
'body': '> <@alice:example.org> Blah\n\nHello world', 'body': '> <@alice:example.org> Blah\n\nHello world',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Blah</blockquote></mx-reply>Hello world', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Blah</blockquote></mx-reply>Hello world',
@ -1102,6 +1158,9 @@ void main() {
'body': 'body':
'> <@alice:example.org> <b>Blah</b>\n> beep\n\nHello world\nfox', '> <@alice:example.org> <b>Blah</b>\n> beep\n\nHello world\nfox',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>&lt;b&gt;Blah&lt;&#47;b&gt;<br>beep</blockquote></mx-reply>Hello world<br/>fox', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>&lt;b&gt;Blah&lt;&#47;b&gt;<br>beep</blockquote></mx-reply>Hello world<br/>fox',
@ -1139,6 +1198,9 @@ void main() {
expect(content, { expect(content, {
'body': '> <@alice:example.org> plaintext meow\n\nHello world', 'body': '> <@alice:example.org> plaintext meow\n\nHello world',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>meow</blockquote></mx-reply>Hello world', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>meow</blockquote></mx-reply>Hello world',
@ -1174,6 +1236,9 @@ void main() {
expect(content, { expect(content, {
'body': '> <@alice:example.org> Hey @\u{200b}room\n\nHello world', 'body': '> <@alice:example.org> Hey @\u{200b}room\n\nHello world',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hey @room</blockquote></mx-reply>Hello world', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hey @room</blockquote></mx-reply>Hello world',
@ -1191,6 +1256,9 @@ void main() {
'content': { 'content': {
'body': '> <@alice:example.org> Hey\n\nHello world', 'body': '> <@alice:example.org> Hey\n\nHello world',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hey</blockquote></mx-reply>Hello world', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hey</blockquote></mx-reply>Hello world',
@ -1215,6 +1283,9 @@ void main() {
expect(content, { expect(content, {
'body': '> <@alice:example.org> Hello world\n\nFox', 'body': '> <@alice:example.org> Hello world\n\nFox',
'msgtype': 'm.text', 'msgtype': 'm.text',
'm.mentions': {
'user_ids': ['@alice:example.org'],
},
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': 'formatted_body':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hello world</blockquote></mx-reply>Fox', '<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Hello world</blockquote></mx-reply>Fox',
@ -1273,7 +1344,7 @@ void main() {
test('sendFileEvent', () async { test('sendFileEvent', () async {
final testFile = MatrixFile(bytes: Uint8List(0), name: 'file.jpeg'); final testFile = MatrixFile(bytes: Uint8List(0), name: 'file.jpeg');
final resp = await room.sendFileEvent(testFile, txid: 'testtxid'); final resp = await room.sendFileEvent(testFile, txid: 'testtxid');
expect(resp.toString(), '\$event10'); expect(resp.toString(), '\$event12');
}); });
test('pushRuleState', () async { test('pushRuleState', () async {