This commit is contained in:
OfficialDakari 2025-11-06 11:24:38 +05:00
commit 8272294736
24 changed files with 1876 additions and 57 deletions

View File

@ -119,10 +119,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: cat .github/workflows/versions.env >> $GITHUB_ENV - run: cat .github/workflows/versions.env >> $GITHUB_ENV
- uses: subosito/flutter-action@48cafc24713cca54bbe03cdc3a423187d413aafa - uses: dart-lang/setup-dart@a57a6c04cf7d4840e88432aad6281d1e125f0d46
with: with:
flutter-version: ${{ env.flutter_version }} sdk: ${{ env.dart_version }}
cache: true
- name: Ensure SDK compiles on web - name: Ensure SDK compiles on web
run: | run: |
pushd web_test pushd web_test

View File

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

View File

@ -1,3 +1,9 @@
## [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 ## [3.0.1] 15th October 2025
- feat: Make display sending event configurable in Room.sendEvent() (Christian Kußowski) - feat: Make display sending event configurable in Room.sendEvent() (Christian Kußowski)
- chore: tidy up call membership event (td) - chore: tidy up call membership event (td)

View File

@ -2679,6 +2679,10 @@ class FakeMatrixApi extends BaseClient {
(var req) => {'event_id': '1234'}, (var req) => {'event_id': '1234'},
'/client/v3/rooms/!1234%3Aexample.com/redact/1143273582443PhrSn%3Aexample.org/1234': '/client/v3/rooms/!1234%3Aexample.com/redact/1143273582443PhrSn%3Aexample.org/1234':
(var req) => {'event_id': '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/room/!localpart%3Aserver.abc': (var req) =>
{}, {},
'/client/v3/pushrules/global/override/.m.rule.master/enabled': '/client/v3/pushrules/global/override/.m.rule.master/enabled':
@ -2753,6 +2757,14 @@ class FakeMatrixApi extends BaseClient {
(var reqI) => { (var reqI) => {
'event_id': '42', '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/directory/list/room/!localpart%3Aexample.com': (var req) =>
{}, {},
'/client/v3/room_keys/version/5': (var req) => {}, '/client/v3/room_keys/version/5': (var req) => {},

View File

@ -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/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart';
export 'msc_extensions/extension_timeline_export/timeline_export.dart'; 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 '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' export 'src/utils/web_worker/web_worker_stub.dart'
if (dart.library.js_interop) 'src/utils/web_worker/web_worker.dart'; if (dart.library.js_interop) 'src/utils/web_worker/web_worker.dart';

View File

@ -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();
```

View File

@ -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<String, dynamic> json) =>
PollEventContent(
mText: json[mTextJsonKey],
pollStartContent: PollStartContent.fromJson(json[startType]),
);
Map<String, dynamic> toJson() => {
mTextJsonKey: mText,
startType: pollStartContent.toJson(),
};
}
class PollStartContent {
final PollKind? kind;
final int maxSelections;
final PollQuestion question;
final List<PollAnswer> answers;
const PollStartContent({
this.kind,
required this.maxSelections,
required this.question,
required this.answers,
});
factory PollStartContent.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => PollQuestion(
mText: json[PollEventContent.mTextJsonKey] ?? json['body'],
);
Map<String, dynamic> 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<String, Object?> json) => PollAnswer(
id: json['id'] as String,
mText: json[PollEventContent.mTextJsonKey] as String,
);
Map<String, Object?> 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;
}

View File

@ -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<String, Set<String>> 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 = <String, Event>{};
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<String, Object?>(PollEventContent.responseType)
?.tryGetList<String>('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<int>('redact') ??
50);
return aggregatedEvents.firstWhereOrNull(
(event) {
if (event.content
.tryGetMap<String, Object?>(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<String?> answerPoll(
List<String> 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<String?> 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,
);
}
}

View File

@ -0,0 +1,39 @@
import 'package:matrix/matrix.dart';
extension PollRoomExtension on Room {
Future<String?> startPoll({
required String question,
required List<PollAnswer> 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,
);
}
}

View File

@ -2236,8 +2236,8 @@ class Client extends MatrixApi {
await dispose(); await dispose();
} }
_id = accessToken = _syncFilterId = _id = accessToken = _syncFilterId = homeserver =
homeserver = _userID = _deviceID = _deviceName = _prevBatch = null; _userID = _deviceID = _deviceName = _prevBatch = _trackedUserIds = null;
_rooms = []; _rooms = [];
_eventsPendingDecryption.clear(); _eventsPendingDecryption.clear();
await encryption?.dispose(); await encryption?.dispose();
@ -2548,6 +2548,7 @@ class Client extends MatrixApi {
for (final userId in deviceLists.left ?? []) { for (final userId in deviceLists.left ?? []) {
if (_userDeviceKeys.containsKey(userId)) { if (_userDeviceKeys.containsKey(userId)) {
_userDeviceKeys.remove(userId); _userDeviceKeys.remove(userId);
_trackedUserIds?.remove(userId);
} }
} }
} }
@ -2817,16 +2818,26 @@ class Client extends MatrixApi {
final callEvents = <Event>[]; final callEvents = <Event>[];
for (var event in events) { for (var event in events) {
// The client must ignore any new m.room.encryption event to prevent if (event.type == EventTypes.Encryption) {
// man-in-the-middle attacks! // The client must ignore any new m.room.encryption event to prevent
if ((event.type == EventTypes.Encryption && // man-in-the-middle attacks!
room.encrypted && if ((room.encrypted &&
event.content.tryGet<String>('algorithm') != event.content.tryGet<String>('algorithm') !=
room room
.getState(EventTypes.Encryption) .getState(EventTypes.Encryption)
?.content ?.content
.tryGet<String>('algorithm'))) { .tryGet<String>('algorithm'))) {
continue; 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 && if (event is MatrixEvent &&
@ -3060,10 +3071,20 @@ class Client extends MatrixApi {
final event = Event.fromMatrixEvent(eventUpdate, room); final event = Event.fromMatrixEvent(eventUpdate, room);
// Update the room state: // Update the room state:
if (event.stateKey != null && final stateKey = event.stateKey;
if (stateKey != null &&
(!room.partial || importantStateEvents.contains(event.type))) { (!room.partial || importantStateEvents.contains(event.type))) {
room.setState(event); room.setState(event);
if (room.encrypted &&
event.type == EventTypes.RoomMember &&
{'join', 'invite'}
.contains(event.content.tryGet<String>('membership'))) {
// New members should be added to the tracked user IDs for encryption:
_trackedUserIds?.add(stateKey);
}
} }
if (type != EventUpdateType.timeline) break; if (type != EventUpdateType.timeline) break;
// Is this event redacting the last event? // Is this event redacting the last event?
@ -3199,13 +3220,11 @@ class Client extends MatrixApi {
for (final room in rooms) { for (final room in rooms) {
if (room.encrypted && room.membership == Membership.join) { if (room.encrypted && room.membership == Membership.join) {
try { try {
final userList = await room.requestParticipants(); final userList = await room.requestParticipants(
for (final user in userList) { [Membership.join, Membership.invite],
if ([Membership.join, Membership.invite] true,
.contains(user.membership)) { );
userIds.add(user.id); userIds.addAll(userList.map((user) => user.id));
}
}
} catch (e, s) { } catch (e, s) {
Logs().e('[E2EE] Failed to fetch participants', e, s); Logs().e('[E2EE] Failed to fetch participants', e, s);
} }
@ -3216,13 +3235,25 @@ class Client extends MatrixApi {
final Map<String, DateTime> _keyQueryFailures = {}; final Map<String, DateTime> _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<String>? _trackedUserIds;
Future<void> updateUserDeviceKeys({Set<String>? additionalUsers}) async { Future<void> updateUserDeviceKeys({Set<String>? additionalUsers}) async {
try { try {
final database = this.database; final database = this.database;
if (!isLogged()) return; if (!isLogged()) return;
final dbActions = <Future<dynamic> Function()>[]; final dbActions = <Future<dynamic> Function()>[];
final trackedUserIds = await _getUserIdsInEncryptedRooms(); final trackedUserIds =
if (!isLogged()) return; _trackedUserIds ??= await _getUserIdsInEncryptedRooms();
if (!isLogged()) {
// For the case we get logged out while `_getUserIdsInEncryptedRooms()`
// was already started.
_trackedUserIds = null;
return;
}
trackedUserIds.add(userID!); trackedUserIds.add(userID!);
if (additionalUsers != null) trackedUserIds.addAll(additionalUsers); if (additionalUsers != null) trackedUserIds.addAll(additionalUsers);
@ -3762,6 +3793,7 @@ class Client extends MatrixApi {
Future<void> clearCache() async { Future<void> clearCache() async {
await abortSync(); await abortSync();
_prevBatch = null; _prevBatch = null;
_trackedUserIds = null;
rooms.clear(); rooms.clear();
await database.clearCache(); await database.clearCache();
encryption?.keyManager.clearOutboundGroupSessions(); encryption?.keyManager.clearOutboundGroupSessions();

View File

@ -2526,18 +2526,30 @@ class Room {
JoinRules joinRules, { JoinRules joinRules, {
/// For restricted rooms, the id of the room where a user needs to be member. /// 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 /// Learn more at https://spec.matrix.org/latest/client-server-api/#restricted-rooms
List<String>? allowConditionRoomIds,
@Deprecated('Use allowConditionRoomIds instead!')
String? allowConditionRoomId, String? allowConditionRoomId,
}) async { }) async {
if (allowConditionRoomId != null) {
allowConditionRoomIds ??= [];
allowConditionRoomIds.add(allowConditionRoomId);
}
await client.setRoomStateWithKey( await client.setRoomStateWithKey(
id, id,
EventTypes.RoomJoinRules, EventTypes.RoomJoinRules,
'', '',
{ {
'join_rule': joinRules.toString().replaceAll('JoinRules.', ''), 'join_rule': joinRules.text,
if (allowConditionRoomId != null) if (allowConditionRoomIds != null && allowConditionRoomIds.isNotEmpty)
'allow': [ 'allow': allowConditionRoomIds
{'room_id': allowConditionRoomId, 'type': 'm.room_membership'}, .map(
], (allowConditionRoomId) => {
'room_id': allowConditionRoomId,
'type': 'm.room_membership',
},
)
.toList(),
}, },
); );
return; return;
@ -2779,10 +2791,21 @@ class Room {
); );
} }
/// Remove a child from this space by setting the `via` to an empty list. /// Remove a child from this space by removing the space child and optionally
Future<void> removeSpaceChild(String roomId) => !isSpace /// space parent state events.
? throw Exception('Room is not a space!') Future<void> removeSpaceChild(String roomId) async {
: setSpaceChild(roomId, via: const []); 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 @override
bool operator ==(Object other) => (other is Room && other.id == id); bool operator ==(Object other) => (other is Room && other.id == id);

View File

@ -300,5 +300,9 @@ abstract class EventLocalizations {
body, body,
), ),
EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent, EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent,
PollEventContent.startType: (event, i18n, body) => i18n.startedAPoll(
event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n),
),
PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded,
}; };
} }

View File

@ -140,7 +140,7 @@ class HtmlToText {
.join('\n'); .join('\n');
} }
static const _listBulletPoints = <String>['', '', '', '']; static const _listBulletPoints = <String>['', '', '', ''];
static List<String> _listChildNodes( static List<String> _listChildNodes(
_ConvertOpts opts, _ConvertOpts opts,

View File

@ -321,4 +321,10 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
@override @override
String get refreshingLastEvent => 'Refreshing last event...'; String get refreshingLastEvent => 'Refreshing last event...';
@override
String startedAPoll(String senderName) => '$senderName started a poll';
@override
String get pollHasBeenEnded => 'Poll has been ended';
} }

View File

@ -187,6 +187,10 @@ abstract class MatrixLocalizations {
String completedKeyVerification(String senderName); String completedKeyVerification(String senderName);
String canceledKeyVerification(String senderName); String canceledKeyVerification(String senderName);
String startedAPoll(String senderName);
String get pollHasBeenEnded;
} }
extension HistoryVisibilityDisplayString on HistoryVisibility { extension HistoryVisibilityDisplayString on HistoryVisibility {

View File

@ -133,7 +133,9 @@ class MeshBackend extends CallBackend {
Future<void> _addCall(GroupCallSession groupCall, CallSession call) async { Future<void> _addCall(GroupCallSession groupCall, CallSession call) async {
_callSessions.add(call); _callSessions.add(call);
_initCall(groupCall, call); _initCall(groupCall, call);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
groupCall.matrixRTCEventStream.add(CallAddedEvent(call));
} }
/// init a peer call from group calls. /// init a peer call from group calls.
@ -183,7 +185,10 @@ class MeshBackend extends CallBackend {
_registerListenersBeforeCallAdd(replacementCall); _registerListenersBeforeCallAdd(replacementCall);
_initCall(groupCall, replacementCall); _initCall(groupCall, replacementCall);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
groupCall.matrixRTCEventStream
.add(CallReplacedEvent(existingCall, replacementCall));
} }
/// Removes a peer call from group calls. /// Removes a peer call from group calls.
@ -196,7 +201,9 @@ class MeshBackend extends CallBackend {
_callSessions.removeWhere((element) => call.callId == element.callId); _callSessions.removeWhere((element) => call.callId == element.callId);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
groupCall.matrixRTCEventStream.add(CallRemovedEvent(call));
} }
Future<void> _disposeCall( Future<void> _disposeCall(
@ -375,7 +382,10 @@ class MeshBackend extends CallBackend {
if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) { if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) {
_activeSpeaker = nextActiveSpeaker; _activeSpeaker = nextActiveSpeaker;
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged);
groupCall.matrixRTCEventStream
.add(GroupCallActiveSpeakerChanged(_activeSpeaker!));
} }
_activeSpeakerLoopTimeout?.cancel(); _activeSpeakerLoopTimeout?.cancel();
_activeSpeakerLoopTimeout = Timer( _activeSpeakerLoopTimeout = Timer(
@ -401,8 +411,12 @@ class MeshBackend extends CallBackend {
) { ) {
_screenshareStreams.add(stream); _screenshareStreams.add(stream);
onStreamAdd.add(stream); onStreamAdd.add(stream);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.screenshareStreamsChanged); .add(GroupCallStateChange.screenshareStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamAdded(GroupCallStreamType.screenshare));
} }
Future<void> _replaceScreenshareStream( Future<void> _replaceScreenshareStream(
@ -423,8 +437,12 @@ class MeshBackend extends CallBackend {
_screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]); _screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]);
await existingStream.dispose(); await existingStream.dispose();
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.screenshareStreamsChanged); .add(GroupCallStateChange.screenshareStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamReplaced(GroupCallStreamType.screenshare));
} }
Future<void> _removeScreenshareStream( Future<void> _removeScreenshareStream(
@ -450,8 +468,12 @@ class MeshBackend extends CallBackend {
await stopMediaStream(stream.stream); await stopMediaStream(stream.stream);
} }
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.screenshareStreamsChanged); .add(GroupCallStateChange.screenshareStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamRemoved(GroupCallStreamType.screenshare));
} }
Future<void> _onCallStateChanged(CallSession call, CallState state) async { Future<void> _onCallStateChanged(CallSession call, CallState state) async {
@ -486,8 +508,12 @@ class MeshBackend extends CallBackend {
) async { ) async {
_userMediaStreams.add(stream); _userMediaStreams.add(stream);
onStreamAdd.add(stream); onStreamAdd.add(stream);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.userMediaStreamsChanged); .add(GroupCallStateChange.userMediaStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamAdded(GroupCallStreamType.userMedia));
} }
Future<void> _replaceUserMediaStream( Future<void> _replaceUserMediaStream(
@ -508,8 +534,12 @@ class MeshBackend extends CallBackend {
_userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]); _userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]);
await existingStream.dispose(); await existingStream.dispose();
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.userMediaStreamsChanged); .add(GroupCallStateChange.userMediaStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamReplaced(GroupCallStreamType.userMedia));
} }
Future<void> _removeUserMediaStream( Future<void> _removeUserMediaStream(
@ -536,12 +566,19 @@ class MeshBackend extends CallBackend {
await stopMediaStream(stream.stream); await stopMediaStream(stream.stream);
} }
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.userMediaStreamsChanged); .add(GroupCallStateChange.userMediaStreamsChanged);
groupCall.matrixRTCEventStream
.add(GroupCallStreamRemoved(GroupCallStreamType.userMedia));
if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) { if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) {
_activeSpeaker = _userMediaStreams[0].participant; _activeSpeaker = _userMediaStreams[0].participant;
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); 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.onGroupCallEvent.add(GroupCallStateChange.localMuteStateChanged);
groupCall.matrixRTCEventStream.add(GroupCallLocalMutedChanged(muted, kind));
return; return;
} }
@ -799,8 +838,12 @@ class MeshBackend extends CallBackend {
_addScreenshareStream(groupCall, localScreenshareStream!); _addScreenshareStream(groupCall, localScreenshareStream!);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.localScreenshareStateChanged); .add(GroupCallStateChange.localScreenshareStateChanged);
groupCall.matrixRTCEventStream
.add(GroupCallLocalScreenshareStateChanged(true));
for (final call in _callSessions) { for (final call in _callSessions) {
await call.addLocalStream( await call.addLocalStream(
await localScreenshareStream!.stream!.clone(), await localScreenshareStream!.stream!.clone(),
@ -813,7 +856,10 @@ class MeshBackend extends CallBackend {
return; return;
} catch (e, s) { } catch (e, s) {
Logs().e('[VOIP] Enabling screensharing error', e, s); Logs().e('[VOIP] Enabling screensharing error', e, s);
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent.add(GroupCallStateChange.error); groupCall.onGroupCallEvent.add(GroupCallStateChange.error);
groupCall.matrixRTCEventStream
.add(GroupCallStateError(e.toString(), s));
return; return;
} }
} else { } else {
@ -826,8 +872,12 @@ class MeshBackend extends CallBackend {
await groupCall.sendMemberStateEvent(); await groupCall.sendMemberStateEvent();
// ignore: deprecated_member_use_from_same_package
groupCall.onGroupCallEvent groupCall.onGroupCallEvent
// ignore: deprecated_member_use_from_same_package
.add(GroupCallStateChange.localMuteStateChanged); .add(GroupCallStateChange.localMuteStateChanged);
groupCall.matrixRTCEventStream
.add(GroupCallLocalScreenshareStateChanged(false));
return; return;
} }
} }

View File

@ -54,9 +54,11 @@ class GroupCallSession {
String groupCallId; String groupCallId;
@Deprecated('Use matrixRTCEventStream instead')
final CachedStreamController<GroupCallState> onGroupCallState = final CachedStreamController<GroupCallState> onGroupCallState =
CachedStreamController(); CachedStreamController();
@Deprecated('Use matrixRTCEventStream instead')
final CachedStreamController<GroupCallStateChange> onGroupCallEvent = final CachedStreamController<GroupCallStateChange> onGroupCallEvent =
CachedStreamController(); CachedStreamController();
@ -105,8 +107,11 @@ class GroupCallSession {
void setState(GroupCallState newState) { void setState(GroupCallState newState) {
state = newState; state = newState;
// ignore: deprecated_member_use_from_same_package
onGroupCallState.add(newState); onGroupCallState.add(newState);
// ignore: deprecated_member_use_from_same_package
onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged); onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged);
matrixRTCEventStream.add(GroupCallStateChanged(newState));
} }
bool hasLocalParticipant() { bool hasLocalParticipant() {
@ -313,6 +318,7 @@ class GroupCallSession {
.add(ParticipantsLeftEvent(participants: anyLeft.toList())); .add(ParticipantsLeftEvent(participants: anyLeft.toList()));
} }
// ignore: deprecated_member_use_from_same_package
onGroupCallEvent.add(GroupCallStateChange.participantsChanged); onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
} }
} }

View File

@ -5,15 +5,18 @@ import 'package:matrix/matrix.dart';
/// often. /// often.
sealed class MatrixRTCCallEvent {} sealed class MatrixRTCCallEvent {}
/// Event type for participants change
sealed class ParticipantsChangeEvent implements MatrixRTCCallEvent {} sealed class ParticipantsChangeEvent implements MatrixRTCCallEvent {}
final class ParticipantsJoinEvent implements ParticipantsChangeEvent { final class ParticipantsJoinEvent implements ParticipantsChangeEvent {
/// The participants who joined the call
final List<CallParticipant> participants; final List<CallParticipant> participants;
ParticipantsJoinEvent({required this.participants}); ParticipantsJoinEvent({required this.participants});
} }
final class ParticipantsLeftEvent implements ParticipantsChangeEvent { final class ParticipantsLeftEvent implements ParticipantsChangeEvent {
/// The participants who left the call
final List<CallParticipant> participants; final List<CallParticipant> participants;
ParticipantsLeftEvent({required this.participants}); ParticipantsLeftEvent({required this.participants});
@ -46,3 +49,89 @@ final class CallReactionRemovedEvent implements CallReactionEvent {
required this.redactedEventId, 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);
}

View File

@ -165,6 +165,7 @@ class GroupCallError extends Error {
} }
} }
@Deprecated('Use the events implementing MatrixRTCCallEvent instead')
enum GroupCallStateChange { enum GroupCallStateChange {
groupCallStateChanged, groupCallStateChanged,
activeSpeakerChanged, activeSpeakerChanged,
@ -176,12 +177,3 @@ enum GroupCallStateChange {
participantsChanged, participantsChanged,
error error
} }
enum GroupCallState {
localCallFeedUninitialized,
initializingLocalCallFeed,
localCallFeedInitialized,
entering,
entered,
ended
}

View File

@ -1,6 +1,6 @@
name: matrix name: matrix
description: Matrix Dart SDK description: Matrix Dart SDK
version: 3.0.1 version: 3.0.2
homepage: https://famedly.com homepage: https://famedly.com
repository: https://github.com/famedly/matrix-dart-sdk.git repository: https://github.com/famedly/matrix-dart-sdk.git
issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues
@ -27,7 +27,7 @@ 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.3.0 vodozemac: ^0.4.0
web: ^1.1.1 web: ^1.1.1
webrtc_interface: ^1.2.0 webrtc_interface: ^1.2.0

View File

@ -35,7 +35,7 @@ void main() {
'(cw spiders) ███████████████████████', '(cw spiders) ███████████████████████',
'<img src="test.gif" alt="a test case" />': 'a test case', '<img src="test.gif" alt="a test case" />': 'a test case',
'List of cute animals:\n<ul>\n<li>Kittens</li>\n<li>Puppies</li>\n<li>Snakes<br/>(I think they\'re cute!)</li>\n</ul>\n(This list is incomplete, you can help by adding to it!)': 'List of cute animals:\n<ul>\n<li>Kittens</li>\n<li>Puppies</li>\n<li>Snakes<br/>(I think they\'re cute!)</li>\n</ul>\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!)',
'<em>fox</em>': '*fox*', '<em>fox</em>': '*fox*',
'<i>fox</i>': '*fox*', '<i>fox</i>': '*fox*',
'<strong>fox</i>': '**fox**', '<strong>fox</i>': '**fox**',
@ -67,15 +67,15 @@ void main() {
'<blockquote><blockquote>fox</blockquote>floof</blockquote>fluff': '<blockquote><blockquote>fox</blockquote>floof</blockquote>fluff':
'> > fox\n> floof\nfluff', '> > fox\n> floof\nfluff',
'<ul><li>hey<ul><li>a</li><li>b</li></ul></li><li>foxies</li></ul>': '<ul><li>hey<ul><li>a</li><li>b</li></ul></li><li>foxies</li></ul>':
'● hey\n ○ a\n ○ b\n foxies', '• hey\n ◦ a\n ◦ b\n foxies',
'<ol><li>a</li><li>b</li></ol>': '1. a\n2. b', '<ol><li>a</li><li>b</li></ol>': '1. a\n2. b',
'<ol start="42"><li>a</li><li>b</li></ol>': '42. a\n43. b', '<ol start="42"><li>a</li><li>b</li></ol>': '42. a\n43. b',
'<ol><li>a<ol><li>aa</li><li>bb</li></ol></li><li>b</li></ol>': '<ol><li>a<ol><li>aa</li><li>bb</li></ol></li><li>b</li></ol>':
'1. a\n 1. aa\n 2. bb\n2. b', '1. a\n 1. aa\n 2. bb\n2. b',
'<ol><li>a<ul><li>aa</li><li>bb</li></ul></li><li>b</li></ol>': '<ol><li>a<ul><li>aa</li><li>bb</li></ul></li><li>b</li></ol>':
'1. a\n ○ aa\n bb\n2. b', '1. a\n ◦ aa\n bb\n2. b',
'<ul><li>a<ol><li>aa</li><li>bb</li></ol></li><li>b</li></ul>': '<ul><li>a<ol><li>aa</li><li>bb</li></ol></li><li>b</li></ul>':
'● a\n 1. aa\n 2. bb\n b', '• a\n 1. aa\n 2. bb\n b',
'<mx-reply>bunny</mx-reply>fox': 'fox', '<mx-reply>bunny</mx-reply>fox': 'fox',
'fox<hr>floof': 'fox\n----------\nfloof', 'fox<hr>floof': 'fox\n----------\nfloof',
'<p>fox</p><hr><p>floof</p>': 'fox\n----------\nfloof', '<p>fox</p><hr><p>floof</p>': 'fox\n----------\nfloof',

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 { class MockMediaDevices implements MediaDevices {
@override @override
Function(dynamic event)? ondevicechange; Function(dynamic event)? ondevicechange;
@override @override
Future<List<MediaDeviceInfo>> enumerateDevices() { Future<List<MediaDeviceInfo>> enumerateDevices() async {
throw UnimplementedError(); return [
MockMediaDeviceInfo(
deviceId: 'default_audio_input',
kind: 'audioinput',
label: 'Default Audio Input',
),
MockMediaDeviceInfo(
deviceId: 'default_video_input',
kind: 'videoinput',
label: 'Default Video Input',
),
];
} }
@override @override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> mediaConstraints) { Future<MediaStream> getDisplayMedia(
throw UnimplementedError(); Map<String, dynamic> mediaConstraints,
) async {
return MockMediaStream('', '');
} }
@override @override
@ -160,6 +191,9 @@ class MockRTCPeerConnection implements RTCPeerConnection {
@override @override
Function(RTCTrackEvent event)? onTrack; Function(RTCTrackEvent event)? onTrack;
// Mock stats to simulate audio levels
double mockAudioLevel = 0.0;
@override @override
RTCSignalingState? get signalingState => throw UnimplementedError(); RTCSignalingState? get signalingState => throw UnimplementedError();
@ -276,8 +310,23 @@ class MockRTCPeerConnection implements RTCPeerConnection {
@override @override
Future<List<StatsReport>> getStats([MediaStreamTrack? track]) async { Future<List<StatsReport>> getStats([MediaStreamTrack? track]) async {
// Mock implementation for getting stats // Mock implementation for getting stats
Logs().i('Mock: Getting stats'); Logs().i('Mock: Getting stats with audioLevel: $mockAudioLevel');
return []; return [
MockStatsReport(
type: 'inbound-rtp',
values: {
'kind': 'audio',
'audioLevel': mockAudioLevel,
},
),
MockStatsReport(
type: 'media-source',
values: {
'kind': 'audio',
'audioLevel': mockAudioLevel,
},
),
];
} }
@override @override
@ -850,3 +899,45 @@ class MockVideoRenderer implements VideoRenderer {
Logs().i('Mock: Disposing VideoRenderer'); Logs().i('Mock: Disposing VideoRenderer');
} }
} }
class MockStatsReport implements StatsReport {
@override
final String type;
@override
final Map<String, dynamic> 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<MediaStream> streams;
@override
final RTCRtpTransceiver? transceiver;
MockRTCTrackEvent({
required this.track,
this.receiver,
required this.streams,
this.transceiver,
});
}