diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 1d82714a..26921fce 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -119,10 +119,9 @@ jobs: steps: - uses: actions/checkout@v4 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - - uses: subosito/flutter-action@48cafc24713cca54bbe03cdc3a423187d413aafa + - uses: dart-lang/setup-dart@a57a6c04cf7d4840e88432aad6281d1e125f0d46 with: - flutter-version: ${{ env.flutter_version }} - cache: true + sdk: ${{ env.dart_version }} - name: Ensure SDK compiles on web run: | pushd web_test diff --git a/.github/workflows/versions.env b/.github/workflows/versions.env index 0af8eef5..53542d5b 100644 --- a/.github/workflows/versions.env +++ b/.github/workflows/versions.env @@ -1,2 +1 @@ -flutter_version=3.35.4 dart_version=3.9.2 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 580873e9..c1c97269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [4.0.0] 13th November 2025 + +Matrix Dart SDK 4.0.0 comes with support for polls, adds first bits towards OIDC and improved +support for spaces and threads. +This release also fixes a major performance leak while updating user device keys in the sync loop. +Especially for larger accounts this should improve the performance a lot. +v4.0.0 It comes with some breaking changes: + +#### Migration guide + +- `Client.checkHomeserver()` now returns a fourth value. You can just ignore it if you don't need auth_metadata. +- `RelationshipType.reply` has been removed in favor of `Event.inReplyToEventId()` where you can set if you want to ignore fallbacks or not. This makes it easier to differenciate fallback replies and replies inside of a thread. + +#### All changes +- feat: (BREAKING) Discover OIDC auth metadata on Client.checkHomeserver() (Christian Kußowski) +- feat: Allow init with access token (Christian Kußowski) +- feat: Implement msc 3381 polls (krille-chan) +- feat: Use small versions of bullet point characters (Kelrap) +- fix: Correctly remove space child (Christian Kußowski) +- fix: Set join rules with knowk_restricted and multiple allow condition room ids (Christian Kußowski) +- refactor: (BREAKING) Replace Event.relationshipType and Event.relationshipEventId with Event.inReplyToEventId() for replies. (Christian Kußowski) +- refactor: Add option to always call auth metadata (Christian Kußowski) +- refactor: Escape HTML tags before markdown rendering (Christian Kußowski) +- refactor: Make direct chat getter type safe (Christian Kußowski) +- refactor: Simpler update user device keys (Christian Kußowski) +- chore: Cache auth metadata response in client (Christian Kußowski) +- chore: Remove flutter from CI (Christian Kußowski) + +## [3.0.2] 24th October 2025 + +- chore: bump vodozemac version to v0.4.0 (Karthikeyan S) +- refactor: merge onGroupCallState and onGroupCallEvent into matrixRTCEventStream with proper types (Karthikeyan S) +- test: matrixRTCEventStream emitted events in a group call (Karthikeyan S) + ## [3.0.1] 15th October 2025 - feat: Make display sending event configurable in Room.sendEvent() (Christian Kußowski) - chore: tidy up call membership event (td) diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index c2651629..0cf2c8ec 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -1188,6 +1188,20 @@ class FakeMatrixApi extends BaseClient { 'errcode': 'M_FORBIDDEN', 'error': 'Blabla', }, + '/client/v1/auth_metadata': (var req) => { + 'authorization_endpoint': + 'https://fakeserver.notexisting/oauth2/auth', + 'code_challenge_methods_supported': ['S256'], + 'grant_types_supported': ['authorization_code', 'refresh_token'], + 'issuer': 'https://fakeserver.notexisting/', + 'registration_endpoint': + 'https://fakeserver.notexisting/oauth2/clients/register', + 'response_modes_supported': ['query', 'fragment'], + 'response_types_supported': ['code'], + 'revocation_endpoint': + 'https://fakeserver.notexisting/oauth2/revoke', + 'token_endpoint': 'https://fakeserver.notexisting/oauth2/token', + }, '/media/v3/preview_url?url=https%3A%2F%2Fmatrix.org&ts=10': (var req) => { 'og:title': 'Matrix Blog Post', 'og:description': 'This is a really cool blog post from matrix.org', @@ -1338,7 +1352,7 @@ class FakeMatrixApi extends BaseClient { }, }, '/client/v3/account/whoami': (var req) => - {'user_id': 'alice@example.com'}, + {'user_id': 'alice@example.com', 'device_id': 'ABCDEFGH'}, '/client/v3/capabilities': (var req) => { 'capabilities': { 'm.change_password': {'enabled': false}, @@ -2679,6 +2693,10 @@ class FakeMatrixApi extends BaseClient { (var req) => {'event_id': '1234'}, '/client/v3/rooms/!1234%3Aexample.com/redact/1143273582443PhrSn%3Aexample.org/1234': (var req) => {'event_id': '1234'}, + '/client/v3/rooms/!696r7674%3Aexample.com/send/org.matrix.msc3381.poll.start/1234': + (var req) => {'event_id': '1234'}, + '/client/v3/rooms/!696r7674%3Aexample.com/send/org.matrix.msc3381.poll.response/1234': + (var req) => {'event_id': '1234'}, '/client/v3/pushrules/global/room/!localpart%3Aserver.abc': (var req) => {}, '/client/v3/pushrules/global/override/.m.rule.master/enabled': @@ -2717,8 +2735,6 @@ class FakeMatrixApi extends BaseClient { (var req) => {}, '/client/v3/user/%40test%3AfakeServer.notExisting/rooms/!localpart%3Aserver.abc/account_data/m.marked_unread': (var req) => {}, - '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/m.direct': - (var req) => {}, '/client/v3/user/%40othertest%3AfakeServer.notExisting/account_data/m.direct': (var req) => {}, '/client/v3/profile/%40alice%3Aexample.com/displayname': (var reqI) => {}, @@ -2753,6 +2769,14 @@ class FakeMatrixApi extends BaseClient { (var reqI) => { 'event_id': '42', }, + '/client/v3/rooms/!calls%3Aexample.com/state/com.famedly.call.member/%40test%3AfakeServer.notExisting': + (var reqI) => { + 'event_id': 'call_member_42', + }, + '/client/v3/rooms/!calls%3Aexample.com/state/com.famedly.call.member/%40remoteuser%3Aexample.com': + (var reqI) => { + 'event_id': 'call_member_remote_42', + }, '/client/v3/directory/list/room/!localpart%3Aexample.com': (var req) => {}, '/client/v3/room_keys/version/5': (var req) => {}, diff --git a/lib/matrix.dart b/lib/matrix.dart index 461d7fb3..1a43ff45 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -85,6 +85,9 @@ export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart'; export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart'; export 'msc_extensions/extension_timeline_export/timeline_export.dart'; export 'msc_extensions/msc_4140_delayed_events/api.dart'; +export 'msc_extensions/msc_3381_polls/models/poll_event_content.dart'; +export 'msc_extensions/msc_3381_polls/poll_event_extension.dart'; +export 'msc_extensions/msc_3381_polls/poll_room_extension.dart'; export 'src/utils/web_worker/web_worker_stub.dart' if (dart.library.js_interop) 'src/utils/web_worker/web_worker.dart'; diff --git a/lib/msc_extensions/msc_3381_polls/README.md b/lib/msc_extensions/msc_3381_polls/README.md new file mode 100644 index 00000000..4a3b1bf1 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/README.md @@ -0,0 +1,45 @@ +# Polls + +Implementation of [MSC-3381](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/3381-polls.md). + +```Dart + +// Start a poll: +final pollEventId = await room.startPoll( + question: 'What do you like more?', + kind: PollKind.undisclosed, + maxSelections: 2, + answers: [ + PollAnswer( + id: 'pepsi', // You should use `Client.generateUniqueTransactionId()` here + mText: 'Pepsi, + ), + PollAnswer( + id: 'coca', + mText: 'Coca Cola, + ), + ]; +); + +// Check if an event is a poll (Do this before performing any other action): +final isPoll = event.type == PollEventContent.startType; + +// Get the poll content +final pollEventContent = event.parsedPollEventContent; + +// Check if poll has not ended yet (do this before answerPoll or endPoll): +final hasEnded = event.getPollHasBeenEnded(timeline); + +// Responde to a poll: +final respondeId = await event.answerPoll(['pepsi', 'coca']); + +// Get poll responses: +final responses = event.getPollResponses(timeline); + +for(final userId in responses.keys) { + print('$userId voted for ${responses[userId]}'); +} + +// End poll: +final endPollId = await event.endPoll(); +``` \ No newline at end of file diff --git a/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart b/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart new file mode 100644 index 00000000..9ad9cdd4 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart @@ -0,0 +1,103 @@ +import 'package:collection/collection.dart'; + +class PollEventContent { + final String mText; + final PollStartContent pollStartContent; + + const PollEventContent({ + required this.mText, + required this.pollStartContent, + }); + static const String mTextJsonKey = 'org.matrix.msc1767.text'; + static const String startType = 'org.matrix.msc3381.poll.start'; + static const String responseType = 'org.matrix.msc3381.poll.response'; + static const String endType = 'org.matrix.msc3381.poll.end'; + + factory PollEventContent.fromJson(Map json) => + PollEventContent( + mText: json[mTextJsonKey], + pollStartContent: PollStartContent.fromJson(json[startType]), + ); + + Map toJson() => { + mTextJsonKey: mText, + startType: pollStartContent.toJson(), + }; +} + +class PollStartContent { + final PollKind? kind; + final int maxSelections; + final PollQuestion question; + final List answers; + + const PollStartContent({ + this.kind, + required this.maxSelections, + required this.question, + required this.answers, + }); + + factory PollStartContent.fromJson(Map json) => + PollStartContent( + kind: PollKind.values + .singleWhereOrNull((kind) => kind.name == json['kind']), + maxSelections: json['max_selections'], + question: PollQuestion.fromJson(json['question']), + answers: (json['answers'] as List) + .map((i) => PollAnswer.fromJson(i)) + .toList(), + ); + + Map toJson() => { + if (kind != null) 'kind': kind?.name, + 'max_selections': maxSelections, + 'question': question.toJson(), + 'answers': answers.map((i) => i.toJson()).toList(), + }; +} + +class PollQuestion { + final String mText; + + const PollQuestion({ + required this.mText, + }); + + factory PollQuestion.fromJson(Map json) => PollQuestion( + mText: json[PollEventContent.mTextJsonKey] ?? json['body'], + ); + + Map toJson() => { + PollEventContent.mTextJsonKey: mText, + // Compatible with older Element versions + 'msgtype': 'm.text', + 'body': mText, + }; +} + +class PollAnswer { + final String id; + final String mText; + + const PollAnswer({required this.id, required this.mText}); + + factory PollAnswer.fromJson(Map json) => PollAnswer( + id: json['id'] as String, + mText: json[PollEventContent.mTextJsonKey] as String, + ); + + Map toJson() => { + 'id': id, + PollEventContent.mTextJsonKey: mText, + }; +} + +enum PollKind { + disclosed('org.matrix.msc3381.poll.disclosed'), + undisclosed('org.matrix.msc3381.poll.undisclosed'); + + const PollKind(this.name); + + final String name; +} diff --git a/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart b/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart new file mode 100644 index 00000000..6c49e386 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart @@ -0,0 +1,132 @@ +import 'package:collection/collection.dart'; + +import 'package:matrix/matrix.dart'; + +extension PollEventExtension on Event { + PollEventContent get parsedPollEventContent { + assert(type == PollEventContent.startType); + return PollEventContent.fromJson(content); + } + + /// Returns a Map of user IDs to a Set of answer IDs. + Map> getPollResponses(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = timeline.aggregatedEvents[eventId] + ?[RelationshipTypes.reference] + ?.toList(); + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return {}; + aggregatedEvents + .removeWhere((event) => event.type != PollEventContent.responseType); + + final responses = {}; + + final endPollEvent = _getEndPollEvent(timeline); + + for (final event in aggregatedEvents) { + // Ignore older responses if we already have a newer one: + final existingEvent = responses[event.senderId]; + if (existingEvent != null && + existingEvent.originServerTs.isAfter(event.originServerTs)) { + continue; + } + // Ignore all responses sent **after** the poll end event: + if (endPollEvent != null && + event.originServerTs.isAfter(endPollEvent.originServerTs)) { + continue; + } + responses[event.senderId] = event; + } + return responses.map( + (userId, event) => MapEntry( + userId, + event.content + .tryGetMap(PollEventContent.responseType) + ?.tryGetList('answers') + ?.toSet() ?? + {}, + ), + ); + } + + Event? _getEndPollEvent(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = + timeline.aggregatedEvents[eventId]?[RelationshipTypes.reference]; + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return null; + + final redactPowerLevel = (room + .getState(EventTypes.RoomPowerLevels) + ?.content + .tryGet('redact') ?? + 50); + + return aggregatedEvents.firstWhereOrNull( + (event) { + if (event.content + .tryGetMap(PollEventContent.endType) == + null) { + return false; + } + + // If a m.poll.end event is received from someone other than the poll + //creator or user with permission to redact other's messages in the + //room, the event must be ignored by clients due to being invalid. + if (event.senderId == senderId || + event.senderFromMemoryOrFallback.powerLevel >= redactPowerLevel) { + return true; + } + Logs().w( + 'Ignore poll end event form user without permission ${event.senderId}', + ); + return false; + }, + ); + } + + bool getPollHasBeenEnded(Timeline timeline) => + _getEndPollEvent(timeline) != null; + + Future answerPoll( + List answerIds, { + String? txid, + }) { + if (type != PollEventContent.startType) { + throw Exception('Event is not a poll.'); + } + if (answerIds.length > + parsedPollEventContent.pollStartContent.maxSelections) { + throw Exception('Selected more answers than allowed in this poll.'); + } + return room.sendEvent( + { + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': eventId, + }, + PollEventContent.responseType: {'answers': answerIds}, + }, + type: PollEventContent.responseType, + txid: txid, + ); + } + + Future endPoll({String? txid}) { + if (type != PollEventContent.startType) { + throw Exception('Event is not a poll.'); + } + if (senderId != room.client.userID) { + throw Exception('You can not end a poll created by someone else.'); + } + return room.sendEvent( + { + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': eventId, + }, + PollEventContent.endType: {}, + }, + type: PollEventContent.endType, + txid: txid, + ); + } +} diff --git a/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart b/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart new file mode 100644 index 00000000..069b0521 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart @@ -0,0 +1,39 @@ +import 'package:matrix/matrix.dart'; + +extension PollRoomExtension on Room { + Future startPoll({ + required String question, + required List answers, + String? body, + PollKind kind = PollKind.undisclosed, + int maxSelections = 1, + String? txid, + }) async { + if (answers.length > 20) { + throw Exception('Client must not set more than 20 answers in a poll'); + } + + if (body == null) { + body = question; + for (var i = 0; i < answers.length; i++) { + body = '$body\n$i. ${answers[i].mText}'; + } + } + + final newPollEvent = PollEventContent( + mText: body!, + pollStartContent: PollStartContent( + kind: kind, + maxSelections: maxSelections, + question: PollQuestion(mText: question), + answers: answers, + ), + ); + + return sendEvent( + newPollEvent.toJson(), + type: PollEventContent.startType, + txid: txid, + ); + } +} diff --git a/lib/src/client.dart b/lib/src/client.dart index b4f6c7bc..854b1996 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -133,7 +133,7 @@ class Client extends MatrixApi { @override set homeserver(Uri? homeserver) { if (this.homeserver != null && homeserver?.host != this.homeserver?.host) { - _wellKnown = null; + _wellKnown = _getAuthMetadataResponseCache = null; } super.homeserver = homeserver; } @@ -440,8 +440,13 @@ class Client extends MatrixApi { return null; } - Map get directChats => - _accountData['m.direct']?.content ?? {}; + Map> get directChats => + (_accountData['m.direct']?.content ?? {}).map( + (userId, list) => MapEntry( + userId, + (list is! List) ? [] : list.whereType().toList(), + ), + ); /// Returns the first room ID from the store (the room with the latest event) /// which is a private chat with the user [userId]. @@ -516,9 +521,17 @@ class Client extends MatrixApi { DiscoveryInformation?, GetVersionsResponse versions, List, + GetAuthMetadataResponse? authMetadata, )> checkHomeserver( Uri homeserverUrl, { bool checkWellKnown = true, + + /// Weither this method should also call `/auth_metadata` to fetch + /// Matrix native OIDC information. Defaults to if the `/versions` endpoint + /// returns version v1.15 or higher. Set to `true` to always call the + /// endpoint if your homeserver supports the endpoint while not fully + /// supporting version v1.15 yet. + bool? fetchAuthMetadata, Set? overrideSupportedVersions, }) async { final supportedVersions = @@ -555,7 +568,21 @@ class Client extends MatrixApi { ); } - return (wellKnown, versions, loginTypes); + fetchAuthMetadata ??= versions.versions.any( + (v) => isVersionGreaterThanOrEqualTo(v, 'v1.15'), + ); + GetAuthMetadataResponse? authMetadata; + if (fetchAuthMetadata) { + try { + authMetadata = await getAuthMetadata(); + } on MatrixException catch (e, s) { + if (e.error != MatrixError.M_UNRECOGNIZED) { + Logs().w('Unable to discover OIDC auth metadata.', e, s); + } + } + } + + return (wellKnown, versions, loginTypes, authMetadata); } catch (_) { homeserver = null; rethrow; @@ -720,6 +747,12 @@ class Client extends MatrixApi { return response; } + GetAuthMetadataResponse? _getAuthMetadataResponseCache; + + @override + Future getAuthMetadata() async => + _getAuthMetadataResponseCache ??= await super.getAuthMetadata(); + /// Sends a logout command to the homeserver and clears all local data, /// including all persistent data from the store. @override @@ -1961,9 +1994,9 @@ class Client extends MatrixApi { /// /// Sends [LoginState.loggedIn] to [onLoginStateChanged]. /// - /// If one of [newToken], [newUserID], [newDeviceID], [newDeviceName] is set then - /// all of them must be set! If you don't set them, this method will try to - /// get them from the database. + /// If one of [newToken] is set, but one of [newUserID], [newDeviceID] is + /// null, then this method calls `/whoami` to fetch user ID and device ID + /// and rethrows if this request fails. /// /// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this /// up. You can then wait for `roomsLoading`, `_accountDataLoading` and @@ -1987,16 +2020,9 @@ class Client extends MatrixApi { /// To track what actually happens you can set a callback here. void Function(InitState)? onInitStateChanged, }) async { - if ((newToken != null || - newUserID != null || - newDeviceID != null || - newDeviceName != null) && - (newToken == null || - newUserID == null || - newDeviceID == null || - newDeviceName == null)) { + if (newToken != null && homeserver == null && newHomeserver == null) { throw ClientInitPreconditionError( - 'If one of [newToken, newUserID, newDeviceID, newDeviceName] is set then all of them must be set!', + 'init() can not be performed with an access token when no homeserver was specified.', ); } @@ -2088,6 +2114,12 @@ class Client extends MatrixApi { return; } + if (accessToken != null && (userID == null || deviceID == null)) { + final userInfo = await getTokenOwner(); + _userID = userID = userInfo.userId; + _deviceID = userInfo.deviceId; + } + if (accessToken == null || homeserver == null || userID == null) { if (legacyDatabaseBuilder != null) { await _migrateFromLegacyDatabase( @@ -2236,8 +2268,8 @@ class Client extends MatrixApi { await dispose(); } - _id = accessToken = _syncFilterId = - homeserver = _userID = _deviceID = _deviceName = _prevBatch = null; + _id = accessToken = _syncFilterId = homeserver = + _userID = _deviceID = _deviceName = _prevBatch = _trackedUserIds = null; _rooms = []; _eventsPendingDecryption.clear(); await encryption?.dispose(); @@ -2548,6 +2580,7 @@ class Client extends MatrixApi { for (final userId in deviceLists.left ?? []) { if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys.remove(userId); + _trackedUserIds?.remove(userId); } } } @@ -2817,16 +2850,26 @@ class Client extends MatrixApi { final callEvents = []; for (var event in events) { - // The client must ignore any new m.room.encryption event to prevent - // man-in-the-middle attacks! - if ((event.type == EventTypes.Encryption && - room.encrypted && - event.content.tryGet('algorithm') != - room - .getState(EventTypes.Encryption) - ?.content - .tryGet('algorithm'))) { - continue; + if (event.type == EventTypes.Encryption) { + // The client must ignore any new m.room.encryption event to prevent + // man-in-the-middle attacks! + if ((room.encrypted && + event.content.tryGet('algorithm') != + room + .getState(EventTypes.Encryption) + ?.content + .tryGet('algorithm'))) { + Logs().wtf( + 'Received an `m.room.encryption` event in a room, where encryption is already enabled! This event must be ignored as it could be an attack!', + jsonEncode(event.toJson()), + ); + continue; + } else { + // Encryption has been enabled in a room -> Reset tracked user IDs so + // sync they can be calculated again. + Logs().i('End to end encryption enabled in', room.id); + _trackedUserIds = null; + } } if (event is MatrixEvent && @@ -3060,10 +3103,20 @@ class Client extends MatrixApi { final event = Event.fromMatrixEvent(eventUpdate, room); // Update the room state: - if (event.stateKey != null && + final stateKey = event.stateKey; + if (stateKey != null && (!room.partial || importantStateEvents.contains(event.type))) { room.setState(event); + + if (room.encrypted && + event.type == EventTypes.RoomMember && + {'join', 'invite'} + .contains(event.content.tryGet('membership'))) { + // New members should be added to the tracked user IDs for encryption: + _trackedUserIds?.add(stateKey); + } } + if (type != EventUpdateType.timeline) break; // Is this event redacting the last event? @@ -3199,13 +3252,11 @@ class Client extends MatrixApi { for (final room in rooms) { if (room.encrypted && room.membership == Membership.join) { try { - final userList = await room.requestParticipants(); - for (final user in userList) { - if ([Membership.join, Membership.invite] - .contains(user.membership)) { - userIds.add(user.id); - } - } + final userList = await room.requestParticipants( + [Membership.join, Membership.invite], + true, + ); + userIds.addAll(userList.map((user) => user.id)); } catch (e, s) { Logs().e('[E2EE] Failed to fetch participants', e, s); } @@ -3216,13 +3267,25 @@ class Client extends MatrixApi { final Map _keyQueryFailures = {}; + /// These are the user IDs we share an encrypted room with and need to track + /// the devices from, cached here for performance reasons. + /// It gets initialized after the first sync of every + /// instance and then updated on member changes or sync device changes. + Set? _trackedUserIds; + Future updateUserDeviceKeys({Set? additionalUsers}) async { try { final database = this.database; if (!isLogged()) return; final dbActions = Function()>[]; - final trackedUserIds = await _getUserIdsInEncryptedRooms(); - if (!isLogged()) return; + final trackedUserIds = + _trackedUserIds ??= await _getUserIdsInEncryptedRooms(); + if (!isLogged()) { + // For the case we get logged out while `_getUserIdsInEncryptedRooms()` + // was already started. + _trackedUserIds = null; + return; + } trackedUserIds.add(userID!); if (additionalUsers != null) trackedUserIds.addAll(additionalUsers); @@ -3762,6 +3825,7 @@ class Client extends MatrixApi { Future clearCache() async { await abortSync(); _prevBatch = null; + _trackedUserIds = null; rooms.clear(); await database.clearCache(); encryption?.keyManager.clearOutboundGroupSessions(); diff --git a/lib/src/event.dart b/lib/src/event.dart index 4036e99e..03b7b241 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -32,7 +32,6 @@ import 'package:matrix/src/utils/markdown.dart'; import 'package:matrix/src/utils/multipart_request_progress.dart'; abstract class RelationshipTypes { - static const String reply = 'm.in_reply_to'; static const String edit = 'm.replace'; static const String reaction = 'm.annotation'; static const String reference = 'm.reference'; @@ -490,31 +489,13 @@ class Event extends MatrixEvent { /// event fallback if the relationship type is `m.thread`. /// https://spec.matrix.org/v1.14/client-server-api/#fallback-for-unthreaded-clients Future getReplyEvent(Timeline timeline) async { - switch (relationshipType) { - case RelationshipTypes.reply: - final relationshipEventId = this.relationshipEventId; - return relationshipEventId == null - ? null - : await timeline.getEventById(relationshipEventId); - - case RelationshipTypes.thread: - final relationshipContent = - content.tryGetMap('m.relates_to'); - if (relationshipContent == null) return null; - final String? relationshipEventId; - if (relationshipContent.tryGet('is_falling_back') == true) { - relationshipEventId = relationshipContent - .tryGetMap('m.in_reply_to') - ?.tryGet('event_id'); - } else { - relationshipEventId = this.relationshipEventId; - } - return relationshipEventId == null - ? null - : await timeline.getEventById(relationshipEventId); - default: - return null; - } + final relationshipEventId = content + .tryGetMap('m.relates_to') + ?.tryGetMap('m.in_reply_to') + ?.tryGet('event_id'); + return relationshipEventId == null + ? null + : await timeline.getEventById(relationshipEventId); } /// If this event is encrypted and the decryption was not successful because @@ -1021,30 +1002,30 @@ class Event extends MatrixEvent { return transactionId == search; } - /// Get the relationship type of an event. `null` if there is none - String? get relationshipType { - final mRelatesTo = content.tryGetMap('m.relates_to'); - if (mRelatesTo == null) { - return null; - } - final relType = mRelatesTo.tryGet('rel_type'); - if (relType == RelationshipTypes.thread) { - return RelationshipTypes.thread; - } + /// Get the relationship type of an event. `null` if there is none. + String? get relationshipType => content + .tryGetMap('m.relates_to') + ?.tryGet('rel_type'); - if (mRelatesTo.containsKey('m.in_reply_to')) { - return RelationshipTypes.reply; - } - return relType; - } + /// Get the event ID that this relationship will reference and `null` if there + /// is none. This could for example be the thread root, the original event for + /// an edit or the event, this is an reaction for. For replies please use + /// `Event.inReplyToEventId()` instead! + String? get relationshipEventId => content + .tryGetMap('m.relates_to') + ?.tryGet('event_id'); - /// Get the event ID that this relationship will reference. `null` if there is none - String? get relationshipEventId { - final relatesToMap = content.tryGetMap('m.relates_to'); - return relatesToMap?.tryGet('event_id') ?? - relatesToMap - ?.tryGetMap('m.in_reply_to') - ?.tryGet('event_id'); + /// If this event is in reply to another event, this returns the event ID or + /// null if this event is not a reply. + String? inReplyToEventId({bool includingFallback = true}) { + final isFallback = content + .tryGetMap('m.relates_to') + ?.tryGet('is_falling_back'); + if (isFallback == true && !includingFallback) return null; + return content + .tryGetMap('m.relates_to') + ?.tryGetMap('m.in_reply_to') + ?.tryGet('event_id'); } /// Get whether this event has aggregated events from a certain [type] diff --git a/lib/src/room.dart b/lib/src/room.dart index 1ae4c40f..3582c34b 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -436,7 +436,7 @@ class Room { final cache = _cachedDirectChatMatrixId; if (cache != null) { final roomIds = client.directChats[cache]; - if (roomIds is List && roomIds.contains(id)) { + if (roomIds != null && roomIds.contains(id)) { return cache; } } @@ -1608,21 +1608,21 @@ class Room { /// Sets this room as a direct chat for this user if not already. Future addToDirectChat(String userID) async { - final directChats = client.directChats; - if (directChats[userID] is List) { - if (!directChats[userID].contains(id)) { - directChats[userID].add(id); - } else { - return; - } // Is already in direct chats - } else { - directChats[userID] = [id]; + final dmRooms = List.from(client.directChats[userID] ?? []); + if (dmRooms.contains(id)) { + Logs().d('Already a direct chat.'); + return; } + dmRooms.add(id); + await client.setAccountData( client.userID!, 'm.direct', - directChats, + { + ...client.directChats, + userID: dmRooms, + }, ); return; } @@ -2526,18 +2526,30 @@ class Room { JoinRules joinRules, { /// For restricted rooms, the id of the room where a user needs to be member. /// Learn more at https://spec.matrix.org/latest/client-server-api/#restricted-rooms + List? allowConditionRoomIds, + @Deprecated('Use allowConditionRoomIds instead!') String? allowConditionRoomId, }) async { + if (allowConditionRoomId != null) { + allowConditionRoomIds ??= []; + allowConditionRoomIds.add(allowConditionRoomId); + } + await client.setRoomStateWithKey( id, EventTypes.RoomJoinRules, '', { - 'join_rule': joinRules.toString().replaceAll('JoinRules.', ''), - if (allowConditionRoomId != null) - 'allow': [ - {'room_id': allowConditionRoomId, 'type': 'm.room_membership'}, - ], + 'join_rule': joinRules.text, + if (allowConditionRoomIds != null && allowConditionRoomIds.isNotEmpty) + 'allow': allowConditionRoomIds + .map( + (allowConditionRoomId) => { + 'room_id': allowConditionRoomId, + 'type': 'm.room_membership', + }, + ) + .toList(), }, ); return; @@ -2779,10 +2791,21 @@ class Room { ); } - /// Remove a child from this space by setting the `via` to an empty list. - Future removeSpaceChild(String roomId) => !isSpace - ? throw Exception('Room is not a space!') - : setSpaceChild(roomId, via: const []); + /// Remove a child from this space by removing the space child and optionally + /// space parent state events. + Future removeSpaceChild(String roomId) async { + if (!isSpace) throw Exception('Room is not a space!'); + + await client.setRoomStateWithKey(id, EventTypes.SpaceChild, roomId, {}); + + // Optionally remove the space parent state event in the former space child. + if (client + .getRoomById(roomId) + ?.canChangeStateEvent(EventTypes.SpaceParent) == + true) { + await client.setRoomStateWithKey(roomId, EventTypes.SpaceParent, id, {}); + } + } @override bool operator ==(Object other) => (other is Room && other.id == id); diff --git a/lib/src/utils/event_localizations.dart b/lib/src/utils/event_localizations.dart index 4f19972e..3ddff085 100644 --- a/lib/src/utils/event_localizations.dart +++ b/lib/src/utils/event_localizations.dart @@ -300,5 +300,9 @@ abstract class EventLocalizations { body, ), EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent, + PollEventContent.startType: (event, i18n, body) => i18n.startedAPoll( + event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n), + ), + PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded, }; } diff --git a/lib/src/utils/html_to_text.dart b/lib/src/utils/html_to_text.dart index 110d715e..665a1b09 100644 --- a/lib/src/utils/html_to_text.dart +++ b/lib/src/utils/html_to_text.dart @@ -140,7 +140,7 @@ class HtmlToText { .join('\n'); } - static const _listBulletPoints = ['●', '○', '■', '‣']; + static const _listBulletPoints = ['•', '◦', '▪', '‣']; static List _listChildNodes( _ConvertOpts opts, diff --git a/lib/src/utils/markdown.dart b/lib/src/utils/markdown.dart index bae521d5..2a4c1437 100644 --- a/lib/src/utils/markdown.dart +++ b/lib/src/utils/markdown.dart @@ -212,7 +212,13 @@ String markdown( bool convertLinebreaks = true, }) { var ret = markdownToHtml( - text.replaceNewlines(), + text + .replaceAllMapped( + // Replace HTML tags + RegExp(r'<([^>]*)>'), + (match) => '<${match.group(1)}>', + ) + .replaceNewlines(), extensionSet: ExtensionSet.gitHubFlavored, blockSyntaxes: [ BlockLatexSyntax(), diff --git a/lib/src/utils/matrix_default_localizations.dart b/lib/src/utils/matrix_default_localizations.dart index c9076777..93ed4e57 100644 --- a/lib/src/utils/matrix_default_localizations.dart +++ b/lib/src/utils/matrix_default_localizations.dart @@ -321,4 +321,10 @@ class MatrixDefaultLocalizations extends MatrixLocalizations { @override String get refreshingLastEvent => 'Refreshing last event...'; + + @override + String startedAPoll(String senderName) => '$senderName started a poll'; + + @override + String get pollHasBeenEnded => 'Poll has been ended'; } diff --git a/lib/src/utils/matrix_localizations.dart b/lib/src/utils/matrix_localizations.dart index 37c9672f..4173aad6 100644 --- a/lib/src/utils/matrix_localizations.dart +++ b/lib/src/utils/matrix_localizations.dart @@ -187,6 +187,10 @@ abstract class MatrixLocalizations { String completedKeyVerification(String senderName); String canceledKeyVerification(String senderName); + + String startedAPoll(String senderName); + + String get pollHasBeenEnded; } extension HistoryVisibilityDisplayString on HistoryVisibility { diff --git a/lib/src/voip/backend/mesh_backend.dart b/lib/src/voip/backend/mesh_backend.dart index e6ca667b..ba1795df 100644 --- a/lib/src/voip/backend/mesh_backend.dart +++ b/lib/src/voip/backend/mesh_backend.dart @@ -133,7 +133,9 @@ class MeshBackend extends CallBackend { Future _addCall(GroupCallSession groupCall, CallSession call) async { _callSessions.add(call); _initCall(groupCall, call); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream.add(CallAddedEvent(call)); } /// init a peer call from group calls. @@ -183,7 +185,10 @@ class MeshBackend extends CallBackend { _registerListenersBeforeCallAdd(replacementCall); _initCall(groupCall, replacementCall); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream + .add(CallReplacedEvent(existingCall, replacementCall)); } /// Removes a peer call from group calls. @@ -196,7 +201,9 @@ class MeshBackend extends CallBackend { _callSessions.removeWhere((element) => call.callId == element.callId); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream.add(CallRemovedEvent(call)); } Future _disposeCall( @@ -375,7 +382,10 @@ class MeshBackend extends CallBackend { if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) { _activeSpeaker = nextActiveSpeaker; + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); + groupCall.matrixRTCEventStream + .add(GroupCallActiveSpeakerChanged(_activeSpeaker!)); } _activeSpeakerLoopTimeout?.cancel(); _activeSpeakerLoopTimeout = Timer( @@ -401,8 +411,12 @@ class MeshBackend extends CallBackend { ) { _screenshareStreams.add(stream); onStreamAdd.add(stream); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamAdded(GroupCallStreamType.screenshare)); } Future _replaceScreenshareStream( @@ -423,8 +437,12 @@ class MeshBackend extends CallBackend { _screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]); await existingStream.dispose(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamReplaced(GroupCallStreamType.screenshare)); } Future _removeScreenshareStream( @@ -450,8 +468,12 @@ class MeshBackend extends CallBackend { await stopMediaStream(stream.stream); } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamRemoved(GroupCallStreamType.screenshare)); } Future _onCallStateChanged(CallSession call, CallState state) async { @@ -486,8 +508,12 @@ class MeshBackend extends CallBackend { ) async { _userMediaStreams.add(stream); onStreamAdd.add(stream); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamAdded(GroupCallStreamType.userMedia)); } Future _replaceUserMediaStream( @@ -508,8 +534,12 @@ class MeshBackend extends CallBackend { _userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]); await existingStream.dispose(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamReplaced(GroupCallStreamType.userMedia)); } Future _removeUserMediaStream( @@ -536,12 +566,19 @@ class MeshBackend extends CallBackend { await stopMediaStream(stream.stream); } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamRemoved(GroupCallStreamType.userMedia)); if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) { _activeSpeaker = _userMediaStreams[0].participant; + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); + groupCall.matrixRTCEventStream + .add(GroupCallActiveSpeakerChanged(_activeSpeaker!)); } } @@ -663,7 +700,9 @@ class MeshBackend extends CallBackend { } } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.localMuteStateChanged); + groupCall.matrixRTCEventStream.add(GroupCallLocalMutedChanged(muted, kind)); return; } @@ -799,8 +838,12 @@ class MeshBackend extends CallBackend { _addScreenshareStream(groupCall, localScreenshareStream!); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.localScreenshareStateChanged); + groupCall.matrixRTCEventStream + .add(GroupCallLocalScreenshareStateChanged(true)); for (final call in _callSessions) { await call.addLocalStream( await localScreenshareStream!.stream!.clone(), @@ -813,7 +856,10 @@ class MeshBackend extends CallBackend { return; } catch (e, s) { Logs().e('[VOIP] Enabling screensharing error', e, s); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.error); + groupCall.matrixRTCEventStream + .add(GroupCallStateError(e.toString(), s)); return; } } else { @@ -826,8 +872,12 @@ class MeshBackend extends CallBackend { await groupCall.sendMemberStateEvent(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.localMuteStateChanged); + groupCall.matrixRTCEventStream + .add(GroupCallLocalScreenshareStateChanged(false)); return; } } diff --git a/lib/src/voip/group_call_session.dart b/lib/src/voip/group_call_session.dart index 9058f76e..27dfaa90 100644 --- a/lib/src/voip/group_call_session.dart +++ b/lib/src/voip/group_call_session.dart @@ -54,9 +54,11 @@ class GroupCallSession { String groupCallId; + @Deprecated('Use matrixRTCEventStream instead') final CachedStreamController onGroupCallState = CachedStreamController(); + @Deprecated('Use matrixRTCEventStream instead') final CachedStreamController onGroupCallEvent = CachedStreamController(); @@ -105,8 +107,11 @@ class GroupCallSession { void setState(GroupCallState newState) { state = newState; + // ignore: deprecated_member_use_from_same_package onGroupCallState.add(newState); + // ignore: deprecated_member_use_from_same_package onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged); + matrixRTCEventStream.add(GroupCallStateChanged(newState)); } bool hasLocalParticipant() { @@ -313,6 +318,7 @@ class GroupCallSession { .add(ParticipantsLeftEvent(participants: anyLeft.toList())); } + // ignore: deprecated_member_use_from_same_package onGroupCallEvent.add(GroupCallStateChange.participantsChanged); } } diff --git a/lib/src/voip/models/matrixrtc_call_event.dart b/lib/src/voip/models/matrixrtc_call_event.dart index 82086974..57259f8e 100644 --- a/lib/src/voip/models/matrixrtc_call_event.dart +++ b/lib/src/voip/models/matrixrtc_call_event.dart @@ -5,15 +5,18 @@ import 'package:matrix/matrix.dart'; /// often. sealed class MatrixRTCCallEvent {} +/// Event type for participants change sealed class ParticipantsChangeEvent implements MatrixRTCCallEvent {} final class ParticipantsJoinEvent implements ParticipantsChangeEvent { + /// The participants who joined the call final List participants; ParticipantsJoinEvent({required this.participants}); } final class ParticipantsLeftEvent implements ParticipantsChangeEvent { + /// The participants who left the call final List participants; ParticipantsLeftEvent({required this.participants}); @@ -46,3 +49,89 @@ final class CallReactionRemovedEvent implements CallReactionEvent { required this.redactedEventId, }); } + +/// Group call active speaker changed event +final class GroupCallActiveSpeakerChanged implements MatrixRTCCallEvent { + final CallParticipant participant; + GroupCallActiveSpeakerChanged(this.participant); +} + +/// Group calls changed event type +sealed class GroupCallChanged implements MatrixRTCCallEvent {} + +/// Group call, call added event +final class CallAddedEvent implements GroupCallChanged { + final CallSession call; + CallAddedEvent(this.call); +} + +/// Group call, call removed event +final class CallRemovedEvent implements GroupCallChanged { + final CallSession call; + CallRemovedEvent(this.call); +} + +/// Group call, call replaced event +final class CallReplacedEvent extends GroupCallChanged { + final CallSession existingCall, replacementCall; + CallReplacedEvent(this.existingCall, this.replacementCall); +} + +enum GroupCallStreamType { + userMedia, + screenshare, +} + +/// Group call stream added event +final class GroupCallStreamAdded implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamAdded(this.type); +} + +/// Group call stream removed event +final class GroupCallStreamRemoved implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamRemoved(this.type); +} + +/// Group call stream replaced event +final class GroupCallStreamReplaced implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamReplaced(this.type); +} + +/// Group call local screenshare state changed event +final class GroupCallLocalScreenshareStateChanged + implements MatrixRTCCallEvent { + final bool screensharing; + GroupCallLocalScreenshareStateChanged(this.screensharing); +} + +/// Group call local muted changed event +final class GroupCallLocalMutedChanged implements MatrixRTCCallEvent { + final bool muted; + final MediaInputKind kind; + GroupCallLocalMutedChanged(this.muted, this.kind); +} + +enum GroupCallState { + localCallFeedUninitialized, + initializingLocalCallFeed, + localCallFeedInitialized, + entering, + entered, + ended +} + +/// Group call state changed event +final class GroupCallStateChanged implements MatrixRTCCallEvent { + final GroupCallState state; + GroupCallStateChanged(this.state); +} + +/// Group call error event +final class GroupCallStateError implements MatrixRTCCallEvent { + final String msg; + final dynamic err; + GroupCallStateError(this.msg, this.err); +} diff --git a/lib/src/voip/utils/types.dart b/lib/src/voip/utils/types.dart index 09455726..29531fdd 100644 --- a/lib/src/voip/utils/types.dart +++ b/lib/src/voip/utils/types.dart @@ -165,6 +165,7 @@ class GroupCallError extends Error { } } +@Deprecated('Use the events implementing MatrixRTCCallEvent instead') enum GroupCallStateChange { groupCallStateChanged, activeSpeakerChanged, @@ -176,12 +177,3 @@ enum GroupCallStateChange { participantsChanged, error } - -enum GroupCallState { - localCallFeedUninitialized, - initializingLocalCallFeed, - localCallFeedInitialized, - entering, - entered, - ended -} diff --git a/pubspec.yaml b/pubspec.yaml index 902aba7c..7e9dc659 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 3.0.1 +version: 4.0.0 homepage: https://famedly.com repository: https://github.com/famedly/matrix-dart-sdk.git issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues @@ -27,7 +27,7 @@ dependencies: sqflite_common: ^2.4.5 sqlite3: ^2.1.0 typed_data: ^1.3.2 - vodozemac: ^0.3.0 + vodozemac: ^0.4.0 web: ^1.1.1 webrtc_interface: ^1.2.0 diff --git a/test/client_test.dart b/test/client_test.dart index 2db2b1d9..514ffe3f 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -524,6 +524,22 @@ void main() { FakeMatrixApi.currentApi?.api = oldapi!; }); + test('init() with access token', () async { + final client = Client( + 'testclient', + httpClient: FakeMatrixApi(), + database: await getDatabase(), + ); + await client.init( + newToken: 'abcd1234', + newHomeserver: Uri.parse('https://fakeserver.notexisting'), + ); + expect(client.isLogged(), true); + expect(client.userID, 'alice@example.com'); + expect(client.deviceID, 'ABCDEFGH'); + await client.dispose(); + }); + test('Login', () async { matrix = Client( 'testclient', @@ -531,9 +547,14 @@ void main() { database: await getDatabase(), ); - await matrix.checkHomeserver( + final (_, _, _, authMetadata) = await matrix.checkHomeserver( Uri.parse('https://fakeserver.notexisting'), checkWellKnown: false, + fetchAuthMetadata: true, + ); + expect( + authMetadata?.issuer.toString(), + 'https://fakeserver.notexisting/', ); final loginResp = await matrix.login( diff --git a/test/event_test.dart b/test/event_test.dart index 0bfb297f..d5b17d1d 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -79,7 +79,7 @@ void main() async { expect(event.formattedText, formatted_body); expect(event.body, body); expect(event.type, EventTypes.Message); - expect(event.relationshipType, RelationshipTypes.reply); + expect(event.inReplyToEventId(), '\$1234:example.com'); jsonObj['state_key'] = ''; final state = Event.fromJson(jsonObj, room); expect(state.eventId, id); @@ -178,8 +178,8 @@ void main() async { }; event = Event.fromJson(jsonObj, room); expect(event.messageType, MessageTypes.Text); - expect(event.relationshipType, RelationshipTypes.reply); - expect(event.relationshipEventId, '1234'); + expect(event.inReplyToEventId(), '1234'); + expect(event.relationshipEventId, null); }); test('relationship types', () async { @@ -212,8 +212,22 @@ void main() async { }, }; event = Event.fromJson(jsonObj, room); - expect(event.relationshipType, RelationshipTypes.reply); - expect(event.relationshipEventId, 'def'); + expect(event.inReplyToEventId(), 'def'); + expect(event.relationshipEventId, null); + + jsonObj['content']['m.relates_to'] = { + 'rel_type': 'm.thread', + 'event_id': '\$root', + 'm.in_reply_to': { + 'event_id': '\$target', + }, + 'is_falling_back': true, + }; + event = Event.fromJson(jsonObj, room); + expect(event.relationshipType, RelationshipTypes.thread); + expect(event.inReplyToEventId(), '\$target'); + expect(event.inReplyToEventId(includingFallback: false), null); + expect(event.relationshipEventId, '\$root'); }); test('redact', () async { diff --git a/test/html_to_text_test.dart b/test/html_to_text_test.dart index 27ba947a..886c8575 100644 --- a/test/html_to_text_test.dart +++ b/test/html_to_text_test.dart @@ -35,7 +35,7 @@ void main() { '(cw spiders) ███████████████████████', 'a test case': 'a test case', 'List of cute animals:\n
    \n
  • Kittens
  • \n
  • Puppies
  • \n
  • Snakes
    (I think they\'re cute!)
  • \n
\n(This list is incomplete, you can help by adding to it!)': - 'List of cute animals:\n● Kittens\n● Puppies\n● Snakes\n (I think they\'re cute!)\n(This list is incomplete, you can help by adding to it!)', + 'List of cute animals:\n• Kittens\n• Puppies\n• Snakes\n (I think they\'re cute!)\n(This list is incomplete, you can help by adding to it!)', 'fox': '*fox*', 'fox': '*fox*', 'fox': '**fox**', @@ -67,15 +67,15 @@ void main() { '
fox
floof
fluff': '> > fox\n> floof\nfluff', '
  • hey
    • a
    • b
  • foxies
': - '● hey\n ○ a\n ○ b\n● foxies', + '• hey\n ◦ a\n ◦ b\n• foxies', '
  1. a
  2. b
': '1. a\n2. b', '
  1. a
  2. b
': '42. a\n43. b', '
  1. a
    1. aa
    2. bb
  2. b
': '1. a\n 1. aa\n 2. bb\n2. b', '
  1. a
    • aa
    • bb
  2. b
': - '1. a\n ○ aa\n ○ bb\n2. b', + '1. a\n ◦ aa\n ◦ bb\n2. b', '
  • a
    1. aa
    2. bb
  • b
': - '● a\n 1. aa\n 2. bb\n● b', + '• a\n 1. aa\n 2. bb\n• b', 'bunnyfox': 'fox', 'fox
floof': 'fox\n----------\nfloof', '

fox


floof

': 'fox\n----------\nfloof', diff --git a/test/markdown_test.dart b/test/markdown_test.dart index ac3ae2f0..7b946fef 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -220,6 +220,10 @@ void main() { ), '

The first
codeblock

void main(){\nprint(something);\n}\n

And the second code block

meow\nmeow\n
', ); + expect( + markdown('Test *unescaped*'), + 'Test <m> unescaped', + ); }); test('Checkboxes', () { expect( diff --git a/test/matrixrtc_event_stream_test.dart b/test/matrixrtc_event_stream_test.dart new file mode 100644 index 00000000..4b5ae3eb --- /dev/null +++ b/test/matrixrtc_event_stream_test.dart @@ -0,0 +1,1061 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/voip/models/call_options.dart'; +import 'fake_client.dart'; +import 'webrtc_stub.dart'; + +void main() { + late Client matrix; + late Room room; + late VoIP voip; + late MeshBackend backend; + late GroupCallSession groupCall; + + group('MatrixRTC Event Stream Tests', () { + Logs().level = Level.info; + + setUp(() async { + matrix = await getClient(); + await matrix.abortSync(); + + voip = VoIP(matrix, MockWebRTCDelegate()); + final id = '!calls:example.com'; + room = matrix.getRoomById(id)!; + backend = MeshBackend(); + }); + + tearDown(() async { + if (voip.groupCalls.isNotEmpty) { + for (final groupCall in voip.groupCalls.values.toList()) { + try { + await groupCall.leave(); + } catch (e) { + // ignore errors during cleanup + } + } + } + }); + + group('GroupCallStateChanged Events', () { + test('emits GroupCallStateChanged when transitioning through all states', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-2', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStateChanged) + .cast() + .listen((event) { + events.add(event); + }); + + // Trigger state changes + groupCall.setState(GroupCallState.initializingLocalCallFeed); + groupCall.setState(GroupCallState.localCallFeedInitialized); + groupCall.setState(GroupCallState.entered); + + await pumpEventQueue(); + + expect(events.length, 3); + expect( + events[0].state, + GroupCallState.initializingLocalCallFeed, + ); + expect( + events[1].state, + GroupCallState.localCallFeedInitialized, + ); + expect(events[2].state, GroupCallState.entered); + }); + }); + + group('ParticipantsJoinEvent and ParticipantsLeftEvent', () { + test('emits ParticipantsJoinEvent when participants join', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-4', + ); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: '123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is ParticipantsJoinEvent) + .cast() + .listen((event) { + events.add(event); + }); + + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: '@remoteuser:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'DEVICE123', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].participants.length, 1); + expect(events[0].participants[0].userId, '@remoteuser:example.com'); + expect(events[0].participants[0].deviceId, 'DEVICE123'); + }); + + test('emits ParticipantsLeftEvent when participants leave', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-5', + ); + + // Initialize local stream + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + // Add a participant first + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@remoteuser:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'DEVICE123', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is ParticipantsLeftEvent) + .cast() + .listen((event) { + events.add(event); + }); + + // Remove the participant + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + content: { + 'memberships': [], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].participants.length, 1); + expect(events[0].participants[0].userId, '@remoteuser:example.com'); + expect(events[0].participants[0].deviceId, 'DEVICE123'); + }); + }); + + group('CallAddedEvent, CallRemovedEvent, and CallReplacedEvent', () { + test('emits CallAddedEvent when a call is added', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-7', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallAddedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-add-123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zane:example.com', + stateKey: '@zane:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zane:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZANEDEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zane-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].call.remoteUserId, '@zane:example.com'); + expect(events[0].call.remoteDeviceId, 'ZANEDEVICE'); + expect(events[0].call.groupCallId, groupCall.groupCallId); + }); + + test('emits CallRemovedEvent when a call is removed', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-8', + ); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-456', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-remove-456', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zoe:example.com', + stateKey: '@zoe:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zoe:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZOEDEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zoe-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallRemovedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + final call = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == '@zoe:example.com' && + c.groupCallId == groupCall.groupCallId, + ); + + await call.hangup(reason: CallErrorCode.userHangup); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].call.remoteUserId, '@zoe:example.com'); + expect(events[0].call.remoteDeviceId, 'ZOEDEVICE'); + }); + + test('emits CallReplacedEvent when a call is replaced', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-9', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallReplacedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-789', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-replace-789', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zara:example.com', + stateKey: '@zara:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zara:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZARADEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zara-session-id-1', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final existingCall = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == '@zara:example.com' && + c.groupCallId == groupCall.groupCallId, + ); + + final replacementCall = voip.createNewCall( + CallOptions( + callId: VoIP.customTxid ?? 'replacement-call-id', + room: room, + voip: voip, + dir: CallDirection.kOutgoing, + localPartyId: voip.currentSessionId, + groupCallId: groupCall.groupCallId, + type: CallType.kVideo, + iceServers: [], + ), + ); + replacementCall.remoteUserId = '@zara:example.com'; + replacementCall.remoteDeviceId = 'ZARADEVICE'; + + existingCall.onCallReplaced.add(replacementCall); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].existingCall.callId, existingCall.callId); + expect(events[0].replacementCall.callId, replacementCall.callId); + expect(events[0].existingCall.remoteUserId, '@zara:example.com'); + expect(events[0].replacementCall.remoteUserId, '@zara:example.com'); + }); + }); + + group('GroupCallStreamAdded, Removed, and Replaced Events', () { + test('emits GroupCallStreamAdded when user media stream is added', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-10', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].type, GroupCallStreamType.userMedia); + }); + + test('emits GroupCallStreamAdded when screenshare stream is added', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-11', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + expect(events.last.type, GroupCallStreamType.screenshare); + }); + + test('emits GroupCallStreamRemoved when stream is removed', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-12', + ); + + final addedEvents = []; + final removedEvents = []; + + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + addedEvents.add(event); + }); + + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamRemoved) + .cast() + .listen((event) { + removedEvents.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + await backend.setScreensharingEnabled(groupCall, false, ''); + await pumpEventQueue(); + + expect(removedEvents.last.type, GroupCallStreamType.screenshare); + }); + }); + + group('GroupCallActiveSpeakerChanged Event', () { + test('emits GroupCallActiveSpeakerChanged when active speaker changes', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-14', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallActiveSpeakerChanged) + .cast() + .listen((event) { + events.add(event); + }); + + room.setState( + Event( + room: room, + eventId: 'local-membership', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: room.client.userID!, + stateKey: room.client.userID!, + content: { + 'memberships': [ + CallMembership( + userId: room.client.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: room.client.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'local-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + final remoteUserId = '@zach:example.com'; + final remoteDeviceId = 'ZACHDEVICE'; + + room.setState( + Event( + room: room, + eventId: 'remote-member-1', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: remoteUserId, + stateKey: remoteUserId, + content: { + 'memberships': [ + CallMembership( + userId: remoteUserId, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: remoteDeviceId, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id-1', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.enter(); + await pumpEventQueue(); + + final call = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == remoteUserId && + c.groupCallId == groupCall.groupCallId, + ); + + await call.onSDPStreamMetadataReceived( + SDPStreamMetadata({ + 'remote-stream-id': SDPStreamPurpose( + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: false, + video_muted: false, + ), + }), + ); + + final mockRemoteStream = MockMediaStream('remote-stream-id', 'remote'); + final mockPeerConnection = call.pc as MockRTCPeerConnection; + mockPeerConnection.mockAudioLevel = 0.8; + + if (mockPeerConnection.onTrack != null) { + mockPeerConnection.onTrack!( + MockRTCTrackEvent( + track: MockMediaStreamTrack(), + streams: [mockRemoteStream], + ), + ); + } + + await pumpEventQueue(); + // Keep the 6-second delay as it's likely testing timer-based active speaker detection + await Future.delayed(Duration(seconds: 6)); + + expect(events.length, 1); + expect(events[0].participant.userId, remoteUserId); + expect(events[0].participant.deviceId, remoteDeviceId); + }); + }); + + group('GroupCallLocalMutedChanged Event', () { + test('emits GroupCallLocalMutedChanged for audio and video mute/unmute', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-15', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallLocalMutedChanged) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + // Test audio muting + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].muted, true); + expect(events[0].kind, MediaInputKind.audioinput); + + // Test video muting + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(events.length, 2); + expect(events[1].muted, true); + expect(events[1].kind, MediaInputKind.videoinput); + + // Test audio unmuting + await backend.setDeviceMuted( + groupCall, + false, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(events.length, 3); + expect(events[2].muted, false); + expect(events[2].kind, MediaInputKind.audioinput); + + // Test video unmuting + await backend.setDeviceMuted( + groupCall, + false, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(events.length, 4); + expect(events[3].muted, false); + expect(events[3].kind, MediaInputKind.videoinput); + + // Verify all events have correct MediaInputKind + final audioEvents = + events.where((e) => e.kind == MediaInputKind.audioinput).toList(); + final videoEvents = + events.where((e) => e.kind == MediaInputKind.videoinput).toList(); + + expect(audioEvents.length, 2); + expect(videoEvents.length, 2); + expect(audioEvents[0].muted, true); + expect(audioEvents[1].muted, false); + expect(videoEvents[0].muted, true); + expect(videoEvents[1].muted, false); + }); + }); + + group('GroupCallLocalScreenshareStateChanged Event', () { + test( + 'emits GroupCallLocalScreenshareStateChanged when screenshare is enabled and disabled', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-19', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallLocalScreenshareStateChanged) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].screensharing, true); + + await backend.setScreensharingEnabled(groupCall, false, ''); + await pumpEventQueue(); + + expect(events.length, 2); + expect(events[1].screensharing, false); + }); + }); + + group('Event Stream Integration Tests', () { + test('multiple event types can be emitted in sequence', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-23', + ); + + final allEvents = []; + groupCall.matrixRTCEventStream.stream.listen((event) { + allEvents.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(allEvents.length, 6); + + final stateChangedEvents = + allEvents.whereType().toList(); + final streamAddedEvents = + allEvents.whereType().toList(); + final mutedChangedEvents = + allEvents.whereType().toList(); + + expect(stateChangedEvents.length, 3); + expect(streamAddedEvents.length, 1); + expect(mutedChangedEvents.length, 2); + + expect(allEvents[0], isA()); + expect( + (allEvents[0] as GroupCallStateChanged).state, + GroupCallState.initializingLocalCallFeed, + ); + expect(allEvents[1], isA()); + expect( + (allEvents[1] as GroupCallStreamAdded).type, + GroupCallStreamType.userMedia, + ); + expect(allEvents[2], isA()); + expect( + (allEvents[2] as GroupCallStateChanged).state, + GroupCallState.localCallFeedInitialized, + ); + expect(allEvents[3], isA()); + expect( + (allEvents[3] as GroupCallStateChanged).state, + GroupCallState.entered, + ); + expect(allEvents[4], isA()); + expect( + (allEvents[4] as GroupCallLocalMutedChanged).kind, + MediaInputKind.audioinput, + ); + expect((allEvents[4] as GroupCallLocalMutedChanged).muted, true); + expect(allEvents[5], isA()); + expect( + (allEvents[5] as GroupCallLocalMutedChanged).kind, + MediaInputKind.videoinput, + ); + expect((allEvents[5] as GroupCallLocalMutedChanged).muted, true); + }); + + test( + 'event stream supports multiple listeners and filtering by event type', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-24', + ); + + final allEvents1 = []; + final allEvents2 = []; + final stateChangedEvents = []; + final streamAddedEvents = []; + final mutedChangedEvents = []; + + groupCall.matrixRTCEventStream.stream.listen(allEvents1.add); + groupCall.matrixRTCEventStream.stream.listen(allEvents2.add); + + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallStateChanged) + .cast() + .listen(stateChangedEvents.add); + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallStreamAdded) + .cast() + .listen(streamAddedEvents.add); + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallLocalMutedChanged) + .cast() + .listen(mutedChangedEvents.add); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(allEvents1.length, 5); + expect(allEvents2.length, 5); + expect(stateChangedEvents.length, 3); + expect(streamAddedEvents.length, 1); + expect(mutedChangedEvents.length, 1); + + expect( + stateChangedEvents.length + + streamAddedEvents.length + + mutedChangedEvents.length, + allEvents1.length, + ); + + expect( + stateChangedEvents.map((e) => e.state).toList(), + [ + GroupCallState.initializingLocalCallFeed, + GroupCallState.localCallFeedInitialized, + GroupCallState.entered, + ], + ); + expect(streamAddedEvents[0].type, GroupCallStreamType.userMedia); + expect(mutedChangedEvents[0].kind, MediaInputKind.audioinput); + expect(mutedChangedEvents[0].muted, true); + }); + }); + }); +} diff --git a/test/msc_extensions/msc_3881_polls_test.dart b/test/msc_extensions/msc_3881_polls_test.dart new file mode 100644 index 00000000..e95bf620 --- /dev/null +++ b/test/msc_extensions/msc_3881_polls_test.dart @@ -0,0 +1,123 @@ +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/models/timeline_chunk.dart'; +import '../fake_client.dart'; + +void main() { + group('MSC 3881 Polls', () { + late Client client; + const roomId = '!696r7674:example.com'; + setUpAll(() async { + client = await getClient(); + }); + tearDownAll(() async => client.dispose()); + test('Start poll', () async { + final room = client.getRoomById(roomId)!; + final eventId = await room.startPoll( + question: 'What do you like more?', + kind: PollKind.undisclosed, + maxSelections: 2, + answers: [ + PollAnswer( + id: 'pepsi', + mText: 'Pepsi', + ), + PollAnswer( + id: 'coca', + mText: 'Coca Cola', + ), + ], + txid: '1234', + ); + + expect(eventId, '1234'); + }); + test('Check Poll Event', () async { + final room = client.getRoomById(roomId)!; + final pollEventContent = PollEventContent( + mText: 'TestPoll', + pollStartContent: PollStartContent( + maxSelections: 2, + question: PollQuestion(mText: 'Question'), + answers: [PollAnswer(id: 'id', mText: 'mText')], + ), + ); + final pollEvent = Event( + content: pollEventContent.toJson(), + type: PollEventContent.startType, + eventId: 'testevent', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 10)), + room: room, + ); + expect( + pollEvent.parsedPollEventContent.toJson(), + pollEventContent.toJson(), + ); + + final timeline = Timeline( + room: room, + chunk: TimelineChunk( + events: [pollEvent], + ), + ); + + expect(pollEvent.getPollResponses(timeline), {}); + expect(pollEvent.getPollHasBeenEnded(timeline), false); + + timeline.aggregatedEvents['testevent'] ??= {}; + timeline.aggregatedEvents['testevent']?['m.reference'] ??= {}; + + timeline.aggregatedEvents['testevent']!['m.reference']!.add( + Event( + content: { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': 'testevent', + }, + 'org.matrix.msc3381.poll.response': { + 'answers': ['pepsi'], + }, + }, + type: PollEventContent.responseType, + eventId: 'testevent2', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 9)), + room: room, + ), + ); + + expect( + pollEvent.getPollResponses(timeline), + { + '@test:fakeServer.notExisting': ['pepsi'], + }, + ); + + timeline.aggregatedEvents['testevent']!['m.reference']!.add( + Event( + content: { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': 'testevent', + }, + 'org.matrix.msc3381.poll.end': {}, + }, + type: PollEventContent.responseType, + eventId: 'testevent3', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 8)), + room: room, + ), + ); + expect(pollEvent.getPollHasBeenEnded(timeline), true); + + final respondeEventId = await pollEvent.answerPoll( + ['pepsi'], + txid: '1234', + ); + expect(respondeEventId, '1234'); + }); + }); +} diff --git a/test/user_test.dart b/test/user_test.dart index f732013f..89065bfd 100644 --- a/test/user_test.dart +++ b/test/user_test.dart @@ -139,6 +139,7 @@ void main() async { await user1.setPower(50); }); test('startDirectChat', () async { + FakeMatrixApi.client = user1.room.client; await user1.startDirectChat(waitForSync: false); }); test('getPresence', () async { diff --git a/test/webrtc_stub.dart b/test/webrtc_stub.dart index 70516e92..0744d614 100644 --- a/test/webrtc_stub.dart +++ b/test/webrtc_stub.dart @@ -86,18 +86,49 @@ class MockEncryptionKeyProvider implements EncryptionKeyProvider { } } +class MockMediaDeviceInfo implements MediaDeviceInfo { + @override + final String deviceId; + @override + final String kind; + @override + final String label; + @override + final String? groupId; + + MockMediaDeviceInfo({ + required this.deviceId, + required this.kind, + required this.label, + this.groupId, + }); +} + class MockMediaDevices implements MediaDevices { @override Function(dynamic event)? ondevicechange; @override - Future> enumerateDevices() { - throw UnimplementedError(); + Future> enumerateDevices() async { + return [ + MockMediaDeviceInfo( + deviceId: 'default_audio_input', + kind: 'audioinput', + label: 'Default Audio Input', + ), + MockMediaDeviceInfo( + deviceId: 'default_video_input', + kind: 'videoinput', + label: 'Default Video Input', + ), + ]; } @override - Future getDisplayMedia(Map mediaConstraints) { - throw UnimplementedError(); + Future getDisplayMedia( + Map mediaConstraints, + ) async { + return MockMediaStream('', ''); } @override @@ -160,6 +191,9 @@ class MockRTCPeerConnection implements RTCPeerConnection { @override Function(RTCTrackEvent event)? onTrack; + // Mock stats to simulate audio levels + double mockAudioLevel = 0.0; + @override RTCSignalingState? get signalingState => throw UnimplementedError(); @@ -276,8 +310,23 @@ class MockRTCPeerConnection implements RTCPeerConnection { @override Future> getStats([MediaStreamTrack? track]) async { // Mock implementation for getting stats - Logs().i('Mock: Getting stats'); - return []; + Logs().i('Mock: Getting stats with audioLevel: $mockAudioLevel'); + return [ + MockStatsReport( + type: 'inbound-rtp', + values: { + 'kind': 'audio', + 'audioLevel': mockAudioLevel, + }, + ), + MockStatsReport( + type: 'media-source', + values: { + 'kind': 'audio', + 'audioLevel': mockAudioLevel, + }, + ), + ]; } @override @@ -850,3 +899,45 @@ class MockVideoRenderer implements VideoRenderer { Logs().i('Mock: Disposing VideoRenderer'); } } + +class MockStatsReport implements StatsReport { + @override + final String type; + + @override + final Map values; + + @override + final String id; + + @override + final double timestamp; + + MockStatsReport({ + required this.type, + required this.values, + this.id = 'mock-stats-id', + this.timestamp = 0.0, + }); +} + +class MockRTCTrackEvent implements RTCTrackEvent { + @override + final MediaStreamTrack track; + + @override + final RTCRtpReceiver? receiver; + + @override + final List streams; + + @override + final RTCRtpTransceiver? transceiver; + + MockRTCTrackEvent({ + required this.track, + this.receiver, + required this.streams, + this.transceiver, + }); +}