Merge pull request #1696 from famedly/td/fosdemDemoFork
feat: famedly calls
This commit is contained in:
		
						commit
						32a425a362
					
				|  | @ -1,2 +1,2 @@ | ||||||
| flutter_version=3.19.0 | flutter_version=3.16.9 | ||||||
| dart_version=3.3.0 | dart_version=3.2.6 | ||||||
|  |  | ||||||
|  | @ -303,7 +303,7 @@ or before logout), excessive linebreaks in markdown messages and a few edge case | ||||||
| - fix: Check the max server file size after shrinking not before (Krille) | - fix: Check the max server file size after shrinking not before (Krille) | ||||||
| - fix: casting of a List<dynamic> to List<String> in getEventList and getEventIdList (td) | - fix: casting of a List<dynamic> to List<String> in getEventList and getEventIdList (td) | ||||||
| - fix: Skip rules with unknown conditions (Nicolas Werner) | - fix: Skip rules with unknown conditions (Nicolas Werner) | ||||||
| - fix: allow passing a WrappedMediaStream to GroupCall.enter() to use as the local user media stream (td) | - fix: allow passing a WrappedMediaStream to GroupCallSession.enter() to use as the local user media stream (td) | ||||||
| 
 | 
 | ||||||
| ## [0.19.0] - 21st April 2023 | ## [0.19.0] - 21st April 2023 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -561,6 +561,8 @@ class OlmManager { | ||||||
|         DateTime.now() |         DateTime.now() | ||||||
|             .subtract(Duration(hours: 1)) |             .subtract(Duration(hours: 1)) | ||||||
|             .isBefore(_restoredOlmSessionsTime[mapKey]!)) { |             .isBefore(_restoredOlmSessionsTime[mapKey]!)) { | ||||||
|  |       Logs().w( | ||||||
|  |           '[OlmManager] Skipping restore session, one was restored in the past hour'); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     _restoredOlmSessionsTime[mapKey] = DateTime.now(); |     _restoredOlmSessionsTime[mapKey] = DateTime.now(); | ||||||
|  | @ -736,7 +738,7 @@ class OlmManager { | ||||||
| 
 | 
 | ||||||
|   Future<void> handleToDeviceEvent(ToDeviceEvent event) async { |   Future<void> handleToDeviceEvent(ToDeviceEvent event) async { | ||||||
|     if (event.type == EventTypes.Dummy) { |     if (event.type == EventTypes.Dummy) { | ||||||
|       // We receive dan encrypted m.dummy. This means that the other end was not able to |       // We received an encrypted m.dummy. This means that the other end was not able to | ||||||
|       // decrypt our last message. So, we re-send it. |       // decrypt our last message. So, we re-send it. | ||||||
|       final encryptedContent = event.encryptedContent; |       final encryptedContent = event.encryptedContent; | ||||||
|       if (encryptedContent == null || encryption.olmDatabase == null) { |       if (encryptedContent == null || encryption.olmDatabase == null) { | ||||||
|  |  | ||||||
|  | @ -30,13 +30,22 @@ export 'src/database/sqflite_encryption_helper.dart'; | ||||||
| export 'src/event.dart'; | export 'src/event.dart'; | ||||||
| export 'src/presence.dart'; | export 'src/presence.dart'; | ||||||
| export 'src/event_status.dart'; | export 'src/event_status.dart'; | ||||||
| export 'src/voip/call.dart'; | export 'src/voip/call_session.dart'; | ||||||
| export 'src/voip/group_call.dart'; | export 'src/voip/group_call_session.dart'; | ||||||
| export 'src/voip/voip.dart'; | export 'src/voip/voip.dart'; | ||||||
| export 'src/voip/voip_content.dart'; | export 'src/voip/backend/livekit_backend.dart'; | ||||||
| export 'src/voip/conn_tester.dart'; | export 'src/voip/backend/call_backend_model.dart'; | ||||||
| export 'src/voip/utils.dart'; | export 'src/voip/backend/mesh_backend.dart'; | ||||||
| export 'src/voip/voip_room_extension.dart'; | export 'src/voip/models/call_events.dart'; | ||||||
|  | export 'src/voip/models/webrtc_delegate.dart'; | ||||||
|  | export 'src/voip/models/call_participant.dart'; | ||||||
|  | export 'src/voip/models/key_provider.dart'; | ||||||
|  | export 'src/voip/utils/conn_tester.dart'; | ||||||
|  | export 'src/voip/utils/voip_constants.dart'; | ||||||
|  | export 'src/voip/utils/rtc_candidate_extension.dart'; | ||||||
|  | export 'src/voip/utils/famedly_call_extension.dart'; | ||||||
|  | export 'src/voip/utils/types.dart'; | ||||||
|  | export 'src/voip/utils/wrapped_media_stream.dart'; | ||||||
| export 'src/room.dart'; | export 'src/room.dart'; | ||||||
| export 'src/timeline.dart'; | export 'src/timeline.dart'; | ||||||
| export 'src/user.dart'; | export 'src/user.dart'; | ||||||
|  |  | ||||||
|  | @ -59,8 +59,6 @@ abstract class EventTypes { | ||||||
|   static const String CallAssertedIdentity = 'm.call.asserted_identity'; |   static const String CallAssertedIdentity = 'm.call.asserted_identity'; | ||||||
|   static const String CallAssertedIdentityPrefix = |   static const String CallAssertedIdentityPrefix = | ||||||
|       'org.matrix.call.asserted_identity'; |       'org.matrix.call.asserted_identity'; | ||||||
|   static const String GroupCallPrefix = 'org.matrix.msc3401.call'; |  | ||||||
|   static const String GroupCallMemberPrefix = 'org.matrix.msc3401.call.member'; |  | ||||||
|   static const String Unknown = 'm.unknown'; |   static const String Unknown = 'm.unknown'; | ||||||
| 
 | 
 | ||||||
|   // To device event types |   // To device event types | ||||||
|  | @ -94,6 +92,26 @@ abstract class EventTypes { | ||||||
|   static String secretStorageKey(String keyId) => 'm.secret_storage.key.$keyId'; |   static String secretStorageKey(String keyId) => 'm.secret_storage.key.$keyId'; | ||||||
| 
 | 
 | ||||||
|   // Spaces |   // Spaces | ||||||
|   static const String spaceParent = 'm.space.parent'; |   static const String SpaceParent = 'm.space.parent'; | ||||||
|   static const String spaceChild = 'm.space.child'; |   static const String SpaceChild = 'm.space.child'; | ||||||
|  | 
 | ||||||
|  |   // MatrixRTC | ||||||
|  |   static const String GroupCallMember = 'com.famedly.call.member'; | ||||||
|  |   static const String GroupCallMemberEncryptionKeys = | ||||||
|  |       '$GroupCallMember.encryption_keys'; | ||||||
|  |   static const String GroupCallMemberEncryptionKeysRequest = | ||||||
|  |       '$GroupCallMember.encryption_keys_request'; | ||||||
|  |   static const String GroupCallMemberCandidates = '$GroupCallMember.candidates'; | ||||||
|  |   static const String GroupCallMemberInvite = '$GroupCallMember.invite'; | ||||||
|  |   static const String GroupCallMemberAnswer = '$GroupCallMember.answer'; | ||||||
|  |   static const String GroupCallMemberHangup = '$GroupCallMember.hangup'; | ||||||
|  |   static const String GroupCallMemberSelectAnswer = | ||||||
|  |       '$GroupCallMember.select_answer'; | ||||||
|  |   static const String GroupCallMemberReject = '$GroupCallMember.reject'; | ||||||
|  |   static const String GroupCallMemberNegotiate = '$GroupCallMember.negotiate'; | ||||||
|  |   static const String GroupCallMemberSDPStreamMetadataChanged = | ||||||
|  |       '$GroupCallMember.sdp_stream_metadata_changed'; | ||||||
|  |   static const String GroupCallMemberReplaces = '$GroupCallMember.replaces'; | ||||||
|  |   static const String GroupCallMemberAssertedIdentity = | ||||||
|  |       '$GroupCallMember.asserted_identity'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -219,8 +219,8 @@ class Client extends MatrixApi { | ||||||
|       EventTypes.Encryption, |       EventTypes.Encryption, | ||||||
|       EventTypes.RoomCanonicalAlias, |       EventTypes.RoomCanonicalAlias, | ||||||
|       EventTypes.RoomTombstone, |       EventTypes.RoomTombstone, | ||||||
|       EventTypes.spaceChild, |       EventTypes.SpaceChild, | ||||||
|       EventTypes.spaceParent, |       EventTypes.SpaceParent, | ||||||
|       EventTypes.RoomCreate, |       EventTypes.RoomCreate, | ||||||
|     ]); |     ]); | ||||||
|     roomPreviewLastEvents.addAll([ |     roomPreviewLastEvents.addAll([ | ||||||
|  | @ -231,8 +231,7 @@ class Client extends MatrixApi { | ||||||
|       EventTypes.CallAnswer, |       EventTypes.CallAnswer, | ||||||
|       EventTypes.CallReject, |       EventTypes.CallReject, | ||||||
|       EventTypes.CallHangup, |       EventTypes.CallHangup, | ||||||
|       EventTypes.GroupCallPrefix, |       EventTypes.GroupCallMember, | ||||||
|       EventTypes.GroupCallMemberPrefix, |  | ||||||
|     ]); |     ]); | ||||||
| 
 | 
 | ||||||
|     // register all the default commands |     // register all the default commands | ||||||
|  | @ -800,8 +799,7 @@ class Client extends MatrixApi { | ||||||
|     if (groupCall) { |     if (groupCall) { | ||||||
|       powerLevelContentOverride ??= {}; |       powerLevelContentOverride ??= {}; | ||||||
|       powerLevelContentOverride['events'] = <String, dynamic>{ |       powerLevelContentOverride['events'] = <String, dynamic>{ | ||||||
|         EventTypes.GroupCallMemberPrefix: 0, |         EventTypes.GroupCallMember: 0, | ||||||
|         EventTypes.GroupCallPrefix: 0, |  | ||||||
|       }; |       }; | ||||||
|     } |     } | ||||||
|     final roomId = await createRoom( |     final roomId = await createRoom( | ||||||
|  | @ -1264,6 +1262,10 @@ class Client extends MatrixApi { | ||||||
|   final CachedStreamController<ToDeviceEvent> onToDeviceEvent = |   final CachedStreamController<ToDeviceEvent> onToDeviceEvent = | ||||||
|       CachedStreamController(); |       CachedStreamController(); | ||||||
| 
 | 
 | ||||||
|  |   /// Tells you about to-device and room call specific events in sync | ||||||
|  |   final CachedStreamController<List<BasicEventWithSender>> onCallEvents = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|   /// Called when the login state e.g. user gets logged out. |   /// Called when the login state e.g. user gets logged out. | ||||||
|   final CachedStreamController<LoginState> onLoginStateChanged = |   final CachedStreamController<LoginState> onLoginStateChanged = | ||||||
|       CachedStreamController(); |       CachedStreamController(); | ||||||
|  | @ -1295,41 +1297,6 @@ class Client extends MatrixApi { | ||||||
|   final CachedStreamController<BasicEvent> onAccountData = |   final CachedStreamController<BasicEvent> onAccountData = | ||||||
|       CachedStreamController(); |       CachedStreamController(); | ||||||
| 
 | 
 | ||||||
|   /// Will be called on call invites. |  | ||||||
|   final CachedStreamController<Event> onCallInvite = CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on call hangups. |  | ||||||
|   final CachedStreamController<Event> onCallHangup = CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on call candidates. |  | ||||||
|   final CachedStreamController<Event> onCallCandidates = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on call answers. |  | ||||||
|   final CachedStreamController<Event> onCallAnswer = CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on call replaces. |  | ||||||
|   final CachedStreamController<Event> onCallReplaces = CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on select answers. |  | ||||||
|   final CachedStreamController<Event> onCallSelectAnswer = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on rejects. |  | ||||||
|   final CachedStreamController<Event> onCallReject = CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on negotiates. |  | ||||||
|   final CachedStreamController<Event> onCallNegotiate = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on Asserted Identity received. |  | ||||||
|   final CachedStreamController<Event> onAssertedIdentityReceived = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called on SDPStream Metadata changed. |  | ||||||
|   final CachedStreamController<Event> onSDPStreamMetadataChangedReceived = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   /// Will be called when another device is requesting session keys for a room. |   /// Will be called when another device is requesting session keys for a room. | ||||||
|   final CachedStreamController<RoomKeyRequest> onRoomKeyRequest = |   final CachedStreamController<RoomKeyRequest> onRoomKeyRequest = | ||||||
|       CachedStreamController(); |       CachedStreamController(); | ||||||
|  | @ -1343,9 +1310,6 @@ class Client extends MatrixApi { | ||||||
|   final CachedStreamController<UiaRequest> onUiaRequest = |   final CachedStreamController<UiaRequest> onUiaRequest = | ||||||
|       CachedStreamController(); |       CachedStreamController(); | ||||||
| 
 | 
 | ||||||
|   final CachedStreamController<Event> onGroupCallRequest = |  | ||||||
|       CachedStreamController(); |  | ||||||
| 
 |  | ||||||
|   final CachedStreamController<Event> onGroupMember = CachedStreamController(); |   final CachedStreamController<Event> onGroupMember = CachedStreamController(); | ||||||
| 
 | 
 | ||||||
|   final CachedStreamController<Event> onRoomState = CachedStreamController(); |   final CachedStreamController<Event> onRoomState = CachedStreamController(); | ||||||
|  | @ -2069,6 +2033,7 @@ class Client extends MatrixApi { | ||||||
| 
 | 
 | ||||||
|   Future<void> _handleToDeviceEvents(List<BasicEventWithSender> events) async { |   Future<void> _handleToDeviceEvents(List<BasicEventWithSender> events) async { | ||||||
|     final Map<String, List<String>> roomsWithNewKeyToSessionId = {}; |     final Map<String, List<String>> roomsWithNewKeyToSessionId = {}; | ||||||
|  |     final List<ToDeviceEvent> callToDeviceEvents = []; | ||||||
|     for (final event in events) { |     for (final event in events) { | ||||||
|       var toDeviceEvent = ToDeviceEvent.fromJson(event.toJson()); |       var toDeviceEvent = ToDeviceEvent.fromJson(event.toJson()); | ||||||
|       Logs().v('Got to_device event of type ${toDeviceEvent.type}'); |       Logs().v('Got to_device event of type ${toDeviceEvent.type}'); | ||||||
|  | @ -2089,9 +2054,16 @@ class Client extends MatrixApi { | ||||||
|         } |         } | ||||||
|         await encryption?.handleToDeviceEvent(toDeviceEvent); |         await encryption?.handleToDeviceEvent(toDeviceEvent); | ||||||
|       } |       } | ||||||
|  |       if (toDeviceEvent.type.startsWith(CallConstants.callEventsRegxp)) { | ||||||
|  |         callToDeviceEvents.add(toDeviceEvent); | ||||||
|  |       } | ||||||
|       onToDeviceEvent.add(toDeviceEvent); |       onToDeviceEvent.add(toDeviceEvent); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (callToDeviceEvents.isNotEmpty) { | ||||||
|  |       onCallEvents.add(callToDeviceEvents); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     // emit updates for all events in the queue |     // emit updates for all events in the queue | ||||||
|     for (final entry in roomsWithNewKeyToSessionId.entries) { |     for (final entry in roomsWithNewKeyToSessionId.entries) { | ||||||
|       final roomId = entry.key; |       final roomId = entry.key; | ||||||
|  | @ -2257,7 +2229,7 @@ class Client extends MatrixApi { | ||||||
|       {bool store = true}) async { |       {bool store = true}) async { | ||||||
|     // Calling events can be omitted if they are outdated from the same sync. So |     // Calling events can be omitted if they are outdated from the same sync. So | ||||||
|     // we collect them first before we handle them. |     // we collect them first before we handle them. | ||||||
|     final callEvents = <Event>{}; |     final callEvents = <Event>[]; | ||||||
| 
 | 
 | ||||||
|     for (final event in events) { |     for (final event in events) { | ||||||
|       // The client must ignore any new m.room.encryption event to prevent |       // The client must ignore any new m.room.encryption event to prevent | ||||||
|  | @ -2308,93 +2280,17 @@ class Client extends MatrixApi { | ||||||
|       if (prevBatch != null && |       if (prevBatch != null && | ||||||
|           (type == EventUpdateType.timeline || |           (type == EventUpdateType.timeline || | ||||||
|               type == EventUpdateType.decryptedTimelineQueue)) { |               type == EventUpdateType.decryptedTimelineQueue)) { | ||||||
|         if ((update.content.tryGet<String>('type')?.startsWith('m.call.') ?? |         if ((update.content | ||||||
|                 false) || |                 .tryGet<String>('type') | ||||||
|             (update.content |                 ?.startsWith(CallConstants.callEventsRegxp) ?? | ||||||
|                     .tryGet<String>('type') |             false)) { | ||||||
|                     ?.startsWith('org.matrix.call.') ?? |  | ||||||
|                 false)) { |  | ||||||
|           final callEvent = Event.fromJson(update.content, room); |           final callEvent = Event.fromJson(update.content, room); | ||||||
|           final callId = callEvent.content.tryGet<String>('call_id'); |  | ||||||
|           callEvents.add(callEvent); |           callEvents.add(callEvent); | ||||||
| 
 |  | ||||||
|           // Call Invites should be omitted for a call that is already answered, |  | ||||||
|           // has ended, is rejectd or replaced. |  | ||||||
|           const callEndedEventTypes = { |  | ||||||
|             EventTypes.CallAnswer, |  | ||||||
|             EventTypes.CallHangup, |  | ||||||
|             EventTypes.CallReject, |  | ||||||
|             EventTypes.CallReplaces, |  | ||||||
|           }; |  | ||||||
|           const ommitWhenCallEndedTypes = { |  | ||||||
|             EventTypes.CallInvite, |  | ||||||
|             EventTypes.CallCandidates, |  | ||||||
|             EventTypes.CallNegotiate, |  | ||||||
|             EventTypes.CallSDPStreamMetadataChanged, |  | ||||||
|             EventTypes.CallSDPStreamMetadataChangedPrefix, |  | ||||||
|           }; |  | ||||||
| 
 |  | ||||||
|           if (callEndedEventTypes.contains(callEvent.type)) { |  | ||||||
|             callEvents.removeWhere((event) { |  | ||||||
|               if (ommitWhenCallEndedTypes.contains(event.type) && |  | ||||||
|                   event.content.tryGet<String>('call_id') == callId) { |  | ||||||
|                 Logs().v( |  | ||||||
|                     'Ommit "${event.type}" event for an already terminated call'); |  | ||||||
|                 return true; |  | ||||||
|               } |  | ||||||
|               return false; |  | ||||||
|             }); |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           final age = callEvent.unsigned?.tryGet<int>('age') ?? |  | ||||||
|               (DateTime.now().millisecondsSinceEpoch - |  | ||||||
|                   callEvent.originServerTs.millisecondsSinceEpoch); |  | ||||||
| 
 |  | ||||||
|           callEvents.removeWhere((element) { |  | ||||||
|             if (callEvent.type == EventTypes.CallInvite && |  | ||||||
|                 age > |  | ||||||
|                     (callEvent.content.tryGet<int>('lifetime') ?? |  | ||||||
|                         CallTimeouts.callInviteLifetime.inMilliseconds)) { |  | ||||||
|               Logs().v( |  | ||||||
|                   'Ommiting invite event ${callEvent.eventId} as age was older than lifetime'); |  | ||||||
|               return true; |  | ||||||
|             } |  | ||||||
|             return false; |  | ||||||
|           }); |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 |     if (callEvents.isNotEmpty) { | ||||||
|     callEvents.forEach(_callStreamByCallEvent); |       onCallEvents.add(callEvents); | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   void _callStreamByCallEvent(Event event) { |  | ||||||
|     if (event.type == EventTypes.CallInvite) { |  | ||||||
|       onCallInvite.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallHangup) { |  | ||||||
|       onCallHangup.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallAnswer) { |  | ||||||
|       onCallAnswer.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallCandidates) { |  | ||||||
|       onCallCandidates.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallSelectAnswer) { |  | ||||||
|       onCallSelectAnswer.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallReject) { |  | ||||||
|       onCallReject.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallNegotiate) { |  | ||||||
|       onCallNegotiate.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallReplaces) { |  | ||||||
|       onCallReplaces.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallAssertedIdentity || |  | ||||||
|         event.type == EventTypes.CallAssertedIdentityPrefix) { |  | ||||||
|       onAssertedIdentityReceived.add(event); |  | ||||||
|     } else if (event.type == EventTypes.CallSDPStreamMetadataChanged || |  | ||||||
|         event.type == EventTypes.CallSDPStreamMetadataChangedPrefix) { |  | ||||||
|       onSDPStreamMetadataChangedReceived.add(event); |  | ||||||
|       // TODO(duan): Only used (org.matrix.msc3401.call) during the current test, |  | ||||||
|       // need to add GroupCallPrefix in matrix_api_lite |  | ||||||
|     } else if (event.type == EventTypes.GroupCallPrefix) { |  | ||||||
|       onGroupCallRequest.add(event); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1835,7 +1835,7 @@ class Room { | ||||||
|     return powerForChangingStateEvent(action) <= ownPowerLevel; |     return powerForChangingStateEvent(action) <= ownPowerLevel; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /// returns the powerlevel required for chaning the `action` defaults to |   /// returns the powerlevel required for changing the `action` defaults to | ||||||
|   /// state_default if `action` isn't specified in events override. |   /// state_default if `action` isn't specified in events override. | ||||||
|   /// If there is no state_default in the m.room.power_levels event, the |   /// If there is no state_default in the m.room.power_levels event, the | ||||||
|   /// state_default is 50. If the room contains no m.room.power_levels event, |   /// state_default is 50. If the room contains no m.room.power_levels event, | ||||||
|  | @ -1850,25 +1850,18 @@ class Room { | ||||||
|         50; |         50; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   bool get canCreateGroupCall => |   /// if returned value is not null `EventTypes.GroupCallMember` is present | ||||||
|       canChangeStateEvent(EventTypes.GroupCallPrefix) && groupCallsEnabled; |  | ||||||
| 
 |  | ||||||
|   bool get canJoinGroupCall => |  | ||||||
|       canChangeStateEvent(EventTypes.GroupCallMemberPrefix) && |  | ||||||
|       groupCallsEnabled; |  | ||||||
| 
 |  | ||||||
|   /// if returned value is not null `org.matrix.msc3401.call.member` is present |  | ||||||
|   /// and group calls can be used |   /// and group calls can be used | ||||||
|   bool get groupCallsEnabled { |   bool get groupCallsEnabledForEveryone { | ||||||
|     final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content; |     final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content; | ||||||
|     if (powerLevelMap == null) return false; |     if (powerLevelMap == null) return false; | ||||||
|     return powerForChangingStateEvent(EventTypes.GroupCallMemberPrefix) <= |     return powerForChangingStateEvent(EventTypes.GroupCallMember) <= | ||||||
|             getDefaultPowerLevel(powerLevelMap) && |         getDefaultPowerLevel(powerLevelMap); | ||||||
|         powerForChangingStateEvent(EventTypes.GroupCallPrefix) <= |  | ||||||
|             getDefaultPowerLevel(powerLevelMap); |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /// sets the `org.matrix.msc3401.call.member` power level to users default for |   bool get canJoinGroupCall => canChangeStateEvent(EventTypes.GroupCallMember); | ||||||
|  | 
 | ||||||
|  |   /// sets the `EventTypes.GroupCallMember` power level to users default for | ||||||
|   /// group calls, needs permissions to change power levels |   /// group calls, needs permissions to change power levels | ||||||
|   Future<void> enableGroupCalls() async { |   Future<void> enableGroupCalls() async { | ||||||
|     if (!canChangePowerLevel) return; |     if (!canChangePowerLevel) return; | ||||||
|  | @ -1878,9 +1871,7 @@ class Room { | ||||||
|       final eventsMap = newPowerLevelMap.tryGetMap<String, Object?>('events') ?? |       final eventsMap = newPowerLevelMap.tryGetMap<String, Object?>('events') ?? | ||||||
|           <String, Object?>{}; |           <String, Object?>{}; | ||||||
|       eventsMap.addAll({ |       eventsMap.addAll({ | ||||||
|         EventTypes.GroupCallPrefix: getDefaultPowerLevel(currentPowerLevelsMap), |         EventTypes.GroupCallMember: getDefaultPowerLevel(currentPowerLevelsMap) | ||||||
|         EventTypes.GroupCallMemberPrefix: |  | ||||||
|             getDefaultPowerLevel(currentPowerLevelsMap) |  | ||||||
|       }); |       }); | ||||||
|       newPowerLevelMap.addAll({'events': eventsMap}); |       newPowerLevelMap.addAll({'events': eventsMap}); | ||||||
|       await client.setRoomStateWithKey( |       await client.setRoomStateWithKey( | ||||||
|  | @ -2229,14 +2220,14 @@ class Room { | ||||||
|   /// `m.space`. |   /// `m.space`. | ||||||
|   bool get isSpace => |   bool get isSpace => | ||||||
|       getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') == |       getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') == | ||||||
|       RoomCreationTypes.mSpace; // TODO: Magic string! |       RoomCreationTypes.mSpace; | ||||||
| 
 | 
 | ||||||
|   /// The parents of this room. Currently this SDK doesn't yet set the canonical |   /// The parents of this room. Currently this SDK doesn't yet set the canonical | ||||||
|   /// flag and is not checking if this room is in fact a child of this space. |   /// flag and is not checking if this room is in fact a child of this space. | ||||||
|   /// You should therefore not rely on this and always check the children of |   /// You should therefore not rely on this and always check the children of | ||||||
|   /// the space. |   /// the space. | ||||||
|   List<SpaceParent> get spaceParents => |   List<SpaceParent> get spaceParents => | ||||||
|       states[EventTypes.spaceParent] |       states[EventTypes.SpaceParent] | ||||||
|           ?.values |           ?.values | ||||||
|           .map((state) => SpaceParent.fromState(state)) |           .map((state) => SpaceParent.fromState(state)) | ||||||
|           .where((child) => child.via.isNotEmpty) |           .where((child) => child.via.isNotEmpty) | ||||||
|  | @ -2249,7 +2240,7 @@ class Room { | ||||||
|   /// sorted at the end of the list. |   /// sorted at the end of the list. | ||||||
|   List<SpaceChild> get spaceChildren => !isSpace |   List<SpaceChild> get spaceChildren => !isSpace | ||||||
|       ? throw Exception('Room is not a space!') |       ? throw Exception('Room is not a space!') | ||||||
|       : (states[EventTypes.spaceChild] |       : (states[EventTypes.SpaceChild] | ||||||
|               ?.values |               ?.values | ||||||
|               .map((state) => SpaceChild.fromState(state)) |               .map((state) => SpaceChild.fromState(state)) | ||||||
|               .where((child) => child.via.isNotEmpty) |               .where((child) => child.via.isNotEmpty) | ||||||
|  | @ -2268,12 +2259,12 @@ class Room { | ||||||
|   }) async { |   }) async { | ||||||
|     if (!isSpace) throw Exception('Room is not a space!'); |     if (!isSpace) throw Exception('Room is not a space!'); | ||||||
|     via ??= [client.userID!.domain!]; |     via ??= [client.userID!.domain!]; | ||||||
|     await client.setRoomStateWithKey(id, EventTypes.spaceChild, roomId, { |     await client.setRoomStateWithKey(id, EventTypes.SpaceChild, roomId, { | ||||||
|       'via': via, |       'via': via, | ||||||
|       if (order != null) 'order': order, |       if (order != null) 'order': order, | ||||||
|       if (suggested != null) 'suggested': suggested, |       if (suggested != null) 'suggested': suggested, | ||||||
|     }); |     }); | ||||||
|     await client.setRoomStateWithKey(roomId, EventTypes.spaceParent, id, { |     await client.setRoomStateWithKey(roomId, EventTypes.SpaceParent, id, { | ||||||
|       'via': via, |       'via': via, | ||||||
|     }); |     }); | ||||||
|     return; |     return; | ||||||
|  |  | ||||||
|  | @ -235,7 +235,6 @@ class MatrixDefaultLocalizations extends MatrixLocalizations { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement youAcceptedTheInvitation |  | ||||||
|   String get youAcceptedTheInvitation => 'You accepted the invitation'; |   String get youAcceptedTheInvitation => 'You accepted the invitation'; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|  |  | ||||||
|  | @ -26,7 +26,7 @@ class SpaceChild { | ||||||
|   final bool? suggested; |   final bool? suggested; | ||||||
| 
 | 
 | ||||||
|   SpaceChild.fromState(Event state) |   SpaceChild.fromState(Event state) | ||||||
|       : assert(state.type == EventTypes.spaceChild), |       : assert(state.type == EventTypes.SpaceChild), | ||||||
|         roomId = state.stateKey, |         roomId = state.stateKey, | ||||||
|         via = state.content.tryGetList<String>('via') ?? [], |         via = state.content.tryGetList<String>('via') ?? [], | ||||||
|         order = state.content.tryGet<String>('order') ?? '', |         order = state.content.tryGet<String>('order') ?? '', | ||||||
|  | @ -39,7 +39,7 @@ class SpaceParent { | ||||||
|   final bool? canonical; |   final bool? canonical; | ||||||
| 
 | 
 | ||||||
|   SpaceParent.fromState(Event state) |   SpaceParent.fromState(Event state) | ||||||
|       : assert(state.type == EventTypes.spaceParent), |       : assert(state.type == EventTypes.SpaceParent), | ||||||
|         roomId = state.stateKey, |         roomId = state.stateKey, | ||||||
|         via = state.content.tryGetList<String>('via') ?? [], |         via = state.content.tryGetList<String>('via') ?? [], | ||||||
|         canonical = state.content.tryGet<bool>('canonical'); |         canonical = state.content.tryGet<bool>('canonical'); | ||||||
|  |  | ||||||
|  | @ -1,6 +1,14 @@ | ||||||
| # VOIP for Matrix SDK | # Famedly Calls | ||||||
| 
 | 
 | ||||||
| 1:1 and group calls | Supports | ||||||
|  | - 1:1 webrtc calls | ||||||
|  | - Group calls with: | ||||||
|  |   - mesh webrtc calls | ||||||
|  |   - just handling state of calls and signallnig for e2ee keys in sfu mode (check `isLivekitCall`) | ||||||
|  | 
 | ||||||
|  | Places where we diverted from spec afaik: | ||||||
|  | - To enable p2p calls between devices of the same user, pass a `invitee_device_id` to the `m.call.invite` method | ||||||
|  | - **to-device call events such as in msc3401 MUST `room_id` to map the event to a room** | ||||||
| 
 | 
 | ||||||
| ## Overview | ## Overview | ||||||
| 
 | 
 | ||||||
|  | @ -8,13 +16,82 @@ | ||||||
| 
 | 
 | ||||||
| `CallSession` objects are created by calling `inviteToCall` and `onCallInvite`. | `CallSession` objects are created by calling `inviteToCall` and `onCallInvite`. | ||||||
| 
 | 
 | ||||||
| `GroupCall` objects are created by calling `createGroupCall`. | `GroupCallSession` objects are created by calling `fetchOrCreateGroupCall`. | ||||||
|  | 
 | ||||||
|  | ## Group Calls | ||||||
|  | 
 | ||||||
|  | All communication for group calls happens over to-device events except the `com.famedly.call.member` event. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | Sends the `com.famedly.call.member` event to signal an active membership. The format has to be the following: | ||||||
|  | 
 | ||||||
|  | ### Events - | ||||||
|  | 
 | ||||||
|  | ```json5 | ||||||
|  | "content": { | ||||||
|  |     "memberships": [ | ||||||
|  |         { | ||||||
|  |             "application": "m.call", | ||||||
|  |             "backend": { | ||||||
|  |                 "type": "mesh" | ||||||
|  |             }, | ||||||
|  |             "call_id": "!qoQQTYnzXOHSdEgqQp:im.staging.famedly.de", | ||||||
|  |             "device_id": "YVGPEWNLDD", | ||||||
|  |             "expires_ts": 1705152401042, | ||||||
|  |             "scope": "m.room" | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | - **application**: could be anything f.ex `m.call`, `m.game` or `m.board` | ||||||
|  | - **backend**: see below | ||||||
|  | - **call_id**: the call id, currently setting it to the roomId makes the call for the whole room, this is to avoid parallel calls starting up. For user scoped calls in a room you could set this to `AuserId:BuserId`. The sdk does not restrict setting roomId for user scoped calls atm. | ||||||
|  | - **device_id**: The sdk supports calling between devices of same users, so this needs to be set to the sender device id. | ||||||
|  | - **expires_ts**: ms since epoch when this membership event should be considered expired. Check `lib/src/voip/utils/constants.dart` for current values of how long the inital period is and how often this gets autoupdated. | ||||||
|  | - **scope**: room scoped calls are `m.room`, user scoped can be `m.user` | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | #### The backend can be either `mesh` or `livekit` | ||||||
|  | 
 | ||||||
|  | ##### Livekit -  | ||||||
|  | ```json5 | ||||||
|  | "backend": { | ||||||
|  |     "livekit_alias": "!qoQQTYnzXOHSdEgqQp:im.staging.famedly.de", | ||||||
|  |     "livekit_service_url": "https://famedly-livekit-server.teedee.dev/jwt", | ||||||
|  |     "type": "livekit" | ||||||
|  | }, | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ##### Mesh - | ||||||
|  | ```json5 | ||||||
|  | "backend": { | ||||||
|  |     "type": "mesh" | ||||||
|  | }, | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | #### E2EE Events - | ||||||
|  | 
 | ||||||
|  | When in SFU/Livekit mode, the sdk can handle sending and requesting encryption keys. Currently it uses the following events:  | ||||||
|  | 
 | ||||||
|  | - sending: `com.famedly.call.encryption_keys` | ||||||
|  | - requesting: `com.famedly.call.encryption_keys.request` | ||||||
|  | 
 | ||||||
|  | As usual remember to send the `party_id`/`sender_session_id` to map your keys to the right userId and deviceId | ||||||
|  | 
 | ||||||
|  | You need to implement `EncryptionKeyProvider` and set the override the methods to interact with your actual keyProvider. The main one as of now is `onSetEncryptionKey`. | ||||||
|  | 
 | ||||||
|  | You can request missing keys whenever needed using `groupCall.requestEncrytionKey(remoteParticipants)`. | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| ## 1:1 calls | ## 1:1 calls | ||||||
| 
 | 
 | ||||||
| ### 1. Basic call flow | ### 1. Basic call flow | ||||||
| 
 | 
 | ||||||
| This flow explains the code flow for a 1v1 call. | This flow explains the code flow for a 1v1 call. | ||||||
|  | 
 | ||||||
| This code flow is still used in group call, the only difference is that group call uses `toDevice` message to send `m.call.*` events | This code flow is still used in group call, the only difference is that group call uses `toDevice` message to send `m.call.*` events | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -79,11 +156,14 @@ We need to use the matrix roomId to initiate the call, the initial call can be | ||||||
| 
 | 
 | ||||||
| After the call is sent, you can use `onCallStateChanged` to listen the call state events. These events are used to change the display of the call UI state, for example, change the control buttons, display `Hangup (cancel)` button before connecting, and display `mute mic, mute cam, hold/unhold, hangup` buttons after connected. | After the call is sent, you can use `onCallStateChanged` to listen the call state events. These events are used to change the display of the call UI state, for example, change the control buttons, display `Hangup (cancel)` button before connecting, and display `mute mic, mute cam, hold/unhold, hangup` buttons after connected. | ||||||
| 
 | 
 | ||||||
|  | You cannot call a whole room, please specify the userId you intend to call in `inviteToCall` | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| ```dart | ```dart | ||||||
| final voip = VoIP(client, MyVoipApp()); | final voip = VoIP(client, MyVoipApp()); | ||||||
| 
 | 
 | ||||||
| /// Create a new call | /// Create a new call | ||||||
| final newCall = await voip.inviteToCall(roomId, CallType.kVideo); | final newCall = await voip.inviteToCall(roomId, CallType.kVideo, userId); | ||||||
| 
 | 
 | ||||||
| newCall.onCallStateChanged.stream.listen((state) { | newCall.onCallStateChanged.stream.listen((state) { | ||||||
|   /// handle call state change event, |   /// handle call state change event, | ||||||
|  |  | ||||||
|  | @ -0,0 +1,112 @@ | ||||||
|  | import 'dart:async'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | 
 | ||||||
|  | abstract class CallBackend { | ||||||
|  |   String type; | ||||||
|  | 
 | ||||||
|  |   CallBackend({ | ||||||
|  |     required this.type, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   factory CallBackend.fromJson(Map<String, Object?> json) { | ||||||
|  |     final String type = json['type'] as String; | ||||||
|  |     if (type == 'mesh') { | ||||||
|  |       return MeshBackend( | ||||||
|  |         type: type, | ||||||
|  |       ); | ||||||
|  |     } else if (type == 'livekit') { | ||||||
|  |       return LiveKitBackend( | ||||||
|  |         livekitAlias: json['livekit_alias'] as String, | ||||||
|  |         livekitServiceUrl: json['livekit_service_url'] as String, | ||||||
|  |         type: type, | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       throw ArgumentError('Invalid type: $type'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Map<String, Object?> toJson(); | ||||||
|  | 
 | ||||||
|  |   bool get e2eeEnabled; | ||||||
|  | 
 | ||||||
|  |   CallParticipant? get activeSpeaker; | ||||||
|  | 
 | ||||||
|  |   WrappedMediaStream? get localUserMediaStream; | ||||||
|  | 
 | ||||||
|  |   WrappedMediaStream? get localScreenshareStream; | ||||||
|  | 
 | ||||||
|  |   List<WrappedMediaStream> get userMediaStreams; | ||||||
|  | 
 | ||||||
|  |   List<WrappedMediaStream> get screenShareStreams; | ||||||
|  | 
 | ||||||
|  |   bool get isLocalVideoMuted; | ||||||
|  | 
 | ||||||
|  |   bool get isMicrophoneMuted; | ||||||
|  | 
 | ||||||
|  |   Future<WrappedMediaStream?> initLocalStream( | ||||||
|  |     GroupCallSession groupCall, { | ||||||
|  |     WrappedMediaStream? stream, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   Future<void> updateMediaDeviceForCalls(); | ||||||
|  | 
 | ||||||
|  |   Future<void> setupP2PCallsWithExistingMembers(GroupCallSession groupCall); | ||||||
|  | 
 | ||||||
|  |   Future<void> setupP2PCallWithNewMember( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     CallParticipant rp, | ||||||
|  |     CallMembership mem, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> dispose(GroupCallSession groupCall); | ||||||
|  | 
 | ||||||
|  |   Future<void> onNewParticipant( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> anyJoined, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> onLeftParticipant( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> anyLeft, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> requestEncrytionKey( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> remoteParticipants, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> onCallEncryption( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     String userId, | ||||||
|  |     String deviceId, | ||||||
|  |     Map<String, dynamic> content, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> onCallEncryptionKeyRequest( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     String userId, | ||||||
|  |     String deviceId, | ||||||
|  |     Map<String, dynamic> content, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> setDeviceMuted( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     bool muted, | ||||||
|  |     MediaInputKind kind, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   Future<void> setScreensharingEnabled( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     bool enabled, | ||||||
|  |     String desktopCapturerSourceId, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   List<Map<String, String>>? getCurrentFeeds(); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other); | ||||||
|  |   @override | ||||||
|  |   int get hashCode; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,506 @@ | ||||||
|  | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
|  | import 'dart:typed_data'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/utils/crypto/crypto.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | 
 | ||||||
|  | class LiveKitBackend extends CallBackend { | ||||||
|  |   final String livekitServiceUrl; | ||||||
|  |   final String livekitAlias; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   final bool e2eeEnabled; | ||||||
|  | 
 | ||||||
|  |   LiveKitBackend({ | ||||||
|  |     required this.livekitServiceUrl, | ||||||
|  |     required this.livekitAlias, | ||||||
|  |     super.type = 'livekit', | ||||||
|  |     this.e2eeEnabled = true, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   Timer? _memberLeaveEncKeyRotateDebounceTimer; | ||||||
|  | 
 | ||||||
|  |   /// participant:keyIndex:keyBin | ||||||
|  |   final Map<CallParticipant, Map<int, Uint8List>> _encryptionKeysMap = {}; | ||||||
|  | 
 | ||||||
|  |   final List<Future> _setNewKeyTimeouts = []; | ||||||
|  | 
 | ||||||
|  |   int _indexCounter = 0; | ||||||
|  | 
 | ||||||
|  |   /// used to send the key again incase someone `onCallEncryptionKeyRequest` but don't just send | ||||||
|  |   /// the last one because you also cycle back in your window which means you | ||||||
|  |   /// could potentially end up sharing a past key | ||||||
|  |   int get latestLocalKeyIndex => _latestLocalKeyIndex; | ||||||
|  |   int _latestLocalKeyIndex = 0; | ||||||
|  | 
 | ||||||
|  |   /// the key currently being used by the local cryptor, can possibly not be the latest | ||||||
|  |   /// key, check `latestLocalKeyIndex` for latest key | ||||||
|  |   int get currentLocalKeyIndex => _currentLocalKeyIndex; | ||||||
|  |   int _currentLocalKeyIndex = 0; | ||||||
|  | 
 | ||||||
|  |   Map<int, Uint8List>? _getKeysForParticipant(CallParticipant participant) { | ||||||
|  |     return _encryptionKeysMap[participant]; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// always chooses the next possible index, we cycle after 16 because | ||||||
|  |   /// no real adv with infinite list | ||||||
|  |   int _getNewEncryptionKeyIndex() { | ||||||
|  |     final newIndex = _indexCounter % 16; | ||||||
|  |     _indexCounter++; | ||||||
|  |     return newIndex; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// makes a new e2ee key for local user and sets it with a delay if specified | ||||||
|  |   /// used on first join and when someone leaves | ||||||
|  |   /// | ||||||
|  |   /// also does the sending for you | ||||||
|  |   Future<void> _makeNewSenderKey( | ||||||
|  |       GroupCallSession groupCall, bool delayBeforeUsingKeyOurself) async { | ||||||
|  |     final key = secureRandomBytes(32); | ||||||
|  |     final keyIndex = _getNewEncryptionKeyIndex(); | ||||||
|  |     Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex'); | ||||||
|  | 
 | ||||||
|  |     await _setEncryptionKey( | ||||||
|  |       groupCall, | ||||||
|  |       groupCall.localParticipant!, | ||||||
|  |       keyIndex, | ||||||
|  |       key, | ||||||
|  |       delayBeforeUsingKeyOurself: delayBeforeUsingKeyOurself, | ||||||
|  |       send: true, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// also does the sending for you | ||||||
|  |   Future<void> _ratchetLocalParticipantKey( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> sendTo, | ||||||
|  |   ) async { | ||||||
|  |     final keyProvider = groupCall.voip.delegate.keyProvider; | ||||||
|  | 
 | ||||||
|  |     if (keyProvider == null) { | ||||||
|  |       throw Exception('[VOIP] _ratchetKey called but KeyProvider was null'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final myKeys = _encryptionKeysMap[groupCall.localParticipant]; | ||||||
|  | 
 | ||||||
|  |     if (myKeys == null || myKeys.isEmpty) { | ||||||
|  |       await _makeNewSenderKey(groupCall, false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Uint8List? ratchetedKey; | ||||||
|  | 
 | ||||||
|  |     while (ratchetedKey == null || ratchetedKey.isEmpty) { | ||||||
|  |       Logs().i('[VOIP E2EE] Ignoring empty ratcheted key'); | ||||||
|  |       ratchetedKey = await keyProvider.onRatchetKey( | ||||||
|  |         groupCall.localParticipant!, | ||||||
|  |         latestLocalKeyIndex, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Logs().i( | ||||||
|  |         '[VOIP E2EE] Ratched latest key to $ratchetedKey at idx $latestLocalKeyIndex'); | ||||||
|  | 
 | ||||||
|  |     await _setEncryptionKey( | ||||||
|  |       groupCall, | ||||||
|  |       groupCall.localParticipant!, | ||||||
|  |       latestLocalKeyIndex, | ||||||
|  |       ratchetedKey, | ||||||
|  |       delayBeforeUsingKeyOurself: false, | ||||||
|  |       send: true, | ||||||
|  |       sendTo: sendTo, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// sets incoming keys and also sends the key if it was for the local user | ||||||
|  |   /// if sendTo is null, its sent to all _participants, see `_sendEncryptionKeysEvent` | ||||||
|  |   Future<void> _setEncryptionKey( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     CallParticipant participant, | ||||||
|  |     int encryptionKeyIndex, | ||||||
|  |     Uint8List encryptionKeyBin, { | ||||||
|  |     bool delayBeforeUsingKeyOurself = false, | ||||||
|  |     bool send = false, | ||||||
|  |     List<CallParticipant>? sendTo, | ||||||
|  |   }) async { | ||||||
|  |     final encryptionKeys = | ||||||
|  |         _encryptionKeysMap[participant] ?? <int, Uint8List>{}; | ||||||
|  | 
 | ||||||
|  |     encryptionKeys[encryptionKeyIndex] = encryptionKeyBin; | ||||||
|  |     _encryptionKeysMap[participant] = encryptionKeys; | ||||||
|  |     if (participant.isLocal) { | ||||||
|  |       _latestLocalKeyIndex = encryptionKeyIndex; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (send) { | ||||||
|  |       await _sendEncryptionKeysEvent( | ||||||
|  |         groupCall, | ||||||
|  |         encryptionKeyIndex, | ||||||
|  |         sendTo: sendTo, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (delayBeforeUsingKeyOurself) { | ||||||
|  |       // now wait for the key to propogate and then set it, hopefully users can | ||||||
|  |       // stil decrypt everything | ||||||
|  |       final useKeyTimeout = Future.delayed(CallTimeouts.useKeyDelay, () async { | ||||||
|  |         Logs().i( | ||||||
|  |             '[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin'); | ||||||
|  |         await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey( | ||||||
|  |             participant, encryptionKeyBin, encryptionKeyIndex); | ||||||
|  |         if (participant.isLocal) { | ||||||
|  |           _currentLocalKeyIndex = encryptionKeyIndex; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |       _setNewKeyTimeouts.add(useKeyTimeout); | ||||||
|  |     } else { | ||||||
|  |       Logs().i( | ||||||
|  |           '[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin'); | ||||||
|  |       await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey( | ||||||
|  |           participant, encryptionKeyBin, encryptionKeyIndex); | ||||||
|  |       if (participant.isLocal) { | ||||||
|  |         _currentLocalKeyIndex = encryptionKeyIndex; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// sends the enc key to the devices using todevice, passing a list of | ||||||
|  |   /// sendTo only sends events to them | ||||||
|  |   /// setting keyIndex to null will send the latestKey | ||||||
|  |   Future<void> _sendEncryptionKeysEvent( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     int keyIndex, { | ||||||
|  |     List<CallParticipant>? sendTo, | ||||||
|  |   }) async { | ||||||
|  |     Logs().i('Sending encryption keys event'); | ||||||
|  | 
 | ||||||
|  |     final myKeys = _getKeysForParticipant(groupCall.localParticipant!); | ||||||
|  |     final myLatestKey = myKeys?[keyIndex]; | ||||||
|  | 
 | ||||||
|  |     final sendKeysTo = | ||||||
|  |         sendTo ?? groupCall.participants.where((p) => !p.isLocal); | ||||||
|  | 
 | ||||||
|  |     if (myKeys == null || myLatestKey == null) { | ||||||
|  |       Logs().w( | ||||||
|  |           '[VOIP E2EE] _sendEncryptionKeysEvent Tried to send encryption keys event but no keys found!'); | ||||||
|  |       await _makeNewSenderKey(groupCall, false); | ||||||
|  |       await _sendEncryptionKeysEvent( | ||||||
|  |         groupCall, | ||||||
|  |         keyIndex, | ||||||
|  |         sendTo: sendTo, | ||||||
|  |       ); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       final keyContent = EncryptionKeysEventContent( | ||||||
|  |         [EncryptionKeyEntry(keyIndex, base64Encode(myLatestKey))], | ||||||
|  |         groupCall.groupCallId, | ||||||
|  |       ); | ||||||
|  |       final Map<String, Object> data = { | ||||||
|  |         ...keyContent.toJson(), | ||||||
|  |         // used to find group call in groupCalls when ToDeviceEvent happens, | ||||||
|  |         // plays nicely with backwards compatibility for mesh calls | ||||||
|  |         'conf_id': groupCall.groupCallId, | ||||||
|  |         'device_id': groupCall.client.deviceID!, | ||||||
|  |         'room_id': groupCall.room.id, | ||||||
|  |       }; | ||||||
|  |       await _sendToDeviceEvent( | ||||||
|  |         groupCall, | ||||||
|  |         sendTo ?? sendKeysTo.toList(), | ||||||
|  |         data, | ||||||
|  |         EventTypes.GroupCallMemberEncryptionKeys, | ||||||
|  |       ); | ||||||
|  |     } catch (e, s) { | ||||||
|  |       Logs().e('[VOIP] Failed to send e2ee keys, retrying', e, s); | ||||||
|  |       await _sendEncryptionKeysEvent( | ||||||
|  |         groupCall, | ||||||
|  |         keyIndex, | ||||||
|  |         sendTo: sendTo, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _sendToDeviceEvent( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> remoteParticipants, | ||||||
|  |     Map<String, Object> data, | ||||||
|  |     String eventType, | ||||||
|  |   ) async { | ||||||
|  |     Logs().v( | ||||||
|  |         '[VOIP] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} '); | ||||||
|  |     final txid = | ||||||
|  |         VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId(); | ||||||
|  |     final mustEncrypt = | ||||||
|  |         groupCall.room.encrypted && groupCall.client.encryptionEnabled; | ||||||
|  | 
 | ||||||
|  |     // could just combine the two but do not want to rewrite the enc thingy | ||||||
|  |     // wrappers here again. | ||||||
|  |     final List<DeviceKeys> mustEncryptkeysToSendTo = []; | ||||||
|  |     final Map<String, Map<String, Map<String, Object>>> unencryptedDataToSend = | ||||||
|  |         {}; | ||||||
|  | 
 | ||||||
|  |     for (final participant in remoteParticipants) { | ||||||
|  |       if (participant.deviceId == null) continue; | ||||||
|  |       if (mustEncrypt) { | ||||||
|  |         await groupCall.client.userDeviceKeysLoading; | ||||||
|  |         final deviceKey = groupCall.client.userDeviceKeys[participant.userId] | ||||||
|  |             ?.deviceKeys[participant.deviceId]; | ||||||
|  |         if (deviceKey != null) { | ||||||
|  |           mustEncryptkeysToSendTo.add(deviceKey); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         unencryptedDataToSend.addAll({ | ||||||
|  |           participant.userId: {participant.deviceId!: data} | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // prepped data, now we send | ||||||
|  |     if (mustEncrypt) { | ||||||
|  |       await groupCall.client.sendToDeviceEncrypted( | ||||||
|  |         mustEncryptkeysToSendTo, | ||||||
|  |         eventType, | ||||||
|  |         data, | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       await groupCall.client.sendToDevice( | ||||||
|  |         eventType, | ||||||
|  |         txid, | ||||||
|  |         unencryptedDataToSend, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson() { | ||||||
|  |     return { | ||||||
|  |       'type': type, | ||||||
|  |       'livekit_service_url': livekitServiceUrl, | ||||||
|  |       'livekit_alias': livekitAlias, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> requestEncrytionKey( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> remoteParticipants, | ||||||
|  |   ) async { | ||||||
|  |     final Map<String, Object> data = { | ||||||
|  |       'conf_id': groupCall.groupCallId, | ||||||
|  |       'device_id': groupCall.client.deviceID!, | ||||||
|  |       'room_id': groupCall.room.id, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     await _sendToDeviceEvent( | ||||||
|  |       groupCall, | ||||||
|  |       remoteParticipants, | ||||||
|  |       data, | ||||||
|  |       EventTypes.GroupCallMemberEncryptionKeysRequest, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onCallEncryption( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     String userId, | ||||||
|  |     String deviceId, | ||||||
|  |     Map<String, dynamic> content, | ||||||
|  |   ) async { | ||||||
|  |     if (!e2eeEnabled) { | ||||||
|  |       Logs().w('[VOIP] got sframe key but we do not support e2ee'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     final keyContent = EncryptionKeysEventContent.fromJson(content); | ||||||
|  | 
 | ||||||
|  |     final callId = keyContent.callId; | ||||||
|  | 
 | ||||||
|  |     if (keyContent.keys.isEmpty) { | ||||||
|  |       Logs().w( | ||||||
|  |           '[VOIP E2EE] Received m.call.encryption_keys where keys is empty: callId=$callId'); | ||||||
|  |       return; | ||||||
|  |     } else { | ||||||
|  |       Logs().i( | ||||||
|  |           '[VOIP E2EE]: onCallEncryption, got keys from $userId:$deviceId ${keyContent.toJson()}'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     for (final key in keyContent.keys) { | ||||||
|  |       final encryptionKey = key.key; | ||||||
|  |       final encryptionKeyIndex = key.index; | ||||||
|  |       await _setEncryptionKey( | ||||||
|  |         groupCall, | ||||||
|  |         CallParticipant(groupCall.voip, userId: userId, deviceId: deviceId), | ||||||
|  |         encryptionKeyIndex, | ||||||
|  |         // base64Decode here because we receive base64Encoded version | ||||||
|  |         base64Decode(encryptionKey), | ||||||
|  |         delayBeforeUsingKeyOurself: false, | ||||||
|  |         send: false, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onCallEncryptionKeyRequest( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     String userId, | ||||||
|  |     String deviceId, | ||||||
|  |     Map<String, dynamic> content, | ||||||
|  |   ) async { | ||||||
|  |     if (!e2eeEnabled) { | ||||||
|  |       Logs().w('[VOIP] got sframe key request but we do not support e2ee'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     final mems = groupCall.room.getCallMembershipsForUser(userId); | ||||||
|  |     if (mems | ||||||
|  |         .where( | ||||||
|  |           (mem) => | ||||||
|  |               mem.callId == groupCall.groupCallId && | ||||||
|  |               mem.userId == userId && | ||||||
|  |               mem.deviceId == deviceId && | ||||||
|  |               !mem.isExpired && | ||||||
|  |               // sanity checks | ||||||
|  |               mem.backend.type == groupCall.backend.type && | ||||||
|  |               mem.roomId == groupCall.room.id && | ||||||
|  |               mem.application == groupCall.application, | ||||||
|  |         ) | ||||||
|  |         .isNotEmpty) { | ||||||
|  |       Logs().d( | ||||||
|  |           '[VOIP] onCallEncryptionKeyRequest: request checks out, sending key on index: $latestLocalKeyIndex to $userId:$deviceId'); | ||||||
|  |       await _sendEncryptionKeysEvent( | ||||||
|  |         groupCall, | ||||||
|  |         _latestLocalKeyIndex, | ||||||
|  |         sendTo: [ | ||||||
|  |           CallParticipant( | ||||||
|  |             groupCall.voip, | ||||||
|  |             userId: userId, | ||||||
|  |             deviceId: deviceId, | ||||||
|  |           ) | ||||||
|  |         ], | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onNewParticipant( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> anyJoined, | ||||||
|  |   ) async { | ||||||
|  |     if (!e2eeEnabled) return; | ||||||
|  |     if (groupCall.voip.enableSFUE2EEKeyRatcheting) { | ||||||
|  |       await _ratchetLocalParticipantKey(groupCall, anyJoined); | ||||||
|  |     } else { | ||||||
|  |       await _makeNewSenderKey(groupCall, true); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onLeftParticipant( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     List<CallParticipant> anyLeft, | ||||||
|  |   ) async { | ||||||
|  |     _encryptionKeysMap.removeWhere((key, value) => anyLeft.contains(key)); | ||||||
|  | 
 | ||||||
|  |     // debounce it because people leave at the same time | ||||||
|  |     if (_memberLeaveEncKeyRotateDebounceTimer != null) { | ||||||
|  |       _memberLeaveEncKeyRotateDebounceTimer!.cancel(); | ||||||
|  |     } | ||||||
|  |     _memberLeaveEncKeyRotateDebounceTimer = | ||||||
|  |         Timer(CallTimeouts.makeKeyDelay, () async { | ||||||
|  |       await _makeNewSenderKey(groupCall, true); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> dispose(GroupCallSession groupCall) async { | ||||||
|  |     // only remove our own, to save requesting if we join again, yes the other side | ||||||
|  |     // will send it anyway but welp | ||||||
|  |     _encryptionKeysMap.remove(groupCall.localParticipant!); | ||||||
|  |     _currentLocalKeyIndex = 0; | ||||||
|  |     _latestLocalKeyIndex = 0; | ||||||
|  |     _memberLeaveEncKeyRotateDebounceTimer?.cancel(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<Map<String, String>>? getCurrentFeeds() { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => | ||||||
|  |       identical(this, other) || | ||||||
|  |       other is LiveKitBackend && | ||||||
|  |           type == other.type && | ||||||
|  |           livekitServiceUrl == other.livekitServiceUrl && | ||||||
|  |           livekitAlias == other.livekitAlias; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |       type.hashCode ^ livekitServiceUrl.hashCode ^ livekitAlias.hashCode; | ||||||
|  | 
 | ||||||
|  |   /// get everything else from your livekit sdk in your client | ||||||
|  |   @override | ||||||
|  |   Future<WrappedMediaStream?> initLocalStream(GroupCallSession groupCall, | ||||||
|  |       {WrappedMediaStream? stream}) async { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   CallParticipant? get activeSpeaker => null; | ||||||
|  | 
 | ||||||
|  |   /// these are unimplemented on purpose so that you know you have | ||||||
|  |   /// used the wrong method | ||||||
|  |   @override | ||||||
|  |   bool get isLocalVideoMuted => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool get isMicrophoneMuted => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   WrappedMediaStream? get localScreenshareStream => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   WrappedMediaStream? get localUserMediaStream => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<WrappedMediaStream> get screenShareStreams => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<WrappedMediaStream> get userMediaStreams => | ||||||
|  |       throw UnimplementedError('Use livekit sdk for this'); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setDeviceMuted( | ||||||
|  |       GroupCallSession groupCall, bool muted, MediaInputKind kind) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setScreensharingEnabled(GroupCallSession groupCall, bool enabled, | ||||||
|  |       String desktopCapturerSourceId) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setupP2PCallWithNewMember(GroupCallSession groupCall, | ||||||
|  |       CallParticipant rp, CallMembership mem) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setupP2PCallsWithExistingMembers( | ||||||
|  |       GroupCallSession groupCall) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> updateMediaDeviceForCalls() async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,877 @@ | ||||||
|  | import 'dart:async'; | ||||||
|  | 
 | ||||||
|  | import 'package:collection/collection.dart'; | ||||||
|  | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/utils/cached_stream_controller.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_options.dart'; | ||||||
|  | import 'package:matrix/src/voip/utils/stream_helper.dart'; | ||||||
|  | import 'package:matrix/src/voip/utils/user_media_constraints.dart'; | ||||||
|  | 
 | ||||||
|  | class MeshBackend extends CallBackend { | ||||||
|  |   MeshBackend({ | ||||||
|  |     super.type = 'mesh', | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   final List<CallSession> _callSessions = []; | ||||||
|  | 
 | ||||||
|  |   /// participant:volume | ||||||
|  |   final Map<CallParticipant, double> _audioLevelsMap = {}; | ||||||
|  | 
 | ||||||
|  |   StreamSubscription<CallSession>? _callSubscription; | ||||||
|  | 
 | ||||||
|  |   Timer? _activeSpeakerLoopTimeout; | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<WrappedMediaStream> onStreamAdd = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<WrappedMediaStream> onStreamRemoved = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<GroupCallSession> onGroupCallFeedsChanged = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson() { | ||||||
|  |     return { | ||||||
|  |       'type': type, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   CallParticipant? _activeSpeaker; | ||||||
|  |   WrappedMediaStream? _localUserMediaStream; | ||||||
|  |   WrappedMediaStream? _localScreenshareStream; | ||||||
|  |   final List<WrappedMediaStream> _userMediaStreams = []; | ||||||
|  |   final List<WrappedMediaStream> _screenshareStreams = []; | ||||||
|  | 
 | ||||||
|  |   List<WrappedMediaStream> _getLocalStreams() { | ||||||
|  |     final feeds = <WrappedMediaStream>[]; | ||||||
|  | 
 | ||||||
|  |     if (localUserMediaStream != null) { | ||||||
|  |       feeds.add(localUserMediaStream!); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (localScreenshareStream != null) { | ||||||
|  |       feeds.add(localScreenshareStream!); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return feeds; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<MediaStream> _getUserMedia( | ||||||
|  |       GroupCallSession groupCall, CallType type) async { | ||||||
|  |     final mediaConstraints = { | ||||||
|  |       'audio': UserMediaConstraints.micMediaConstraints, | ||||||
|  |       'video': type == CallType.kVideo | ||||||
|  |           ? UserMediaConstraints.camMediaConstraints | ||||||
|  |           : false, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       return await groupCall.voip.delegate.mediaDevices | ||||||
|  |           .getUserMedia(mediaConstraints); | ||||||
|  |     } catch (e) { | ||||||
|  |       groupCall.setState(GroupCallState.localCallFeedUninitialized); | ||||||
|  |       rethrow; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<MediaStream> _getDisplayMedia(GroupCallSession groupCall) async { | ||||||
|  |     final mediaConstraints = { | ||||||
|  |       'audio': false, | ||||||
|  |       'video': true, | ||||||
|  |     }; | ||||||
|  |     try { | ||||||
|  |       return await groupCall.voip.delegate.mediaDevices | ||||||
|  |           .getDisplayMedia(mediaConstraints); | ||||||
|  |     } catch (e, s) { | ||||||
|  |       Logs().e('[VOIP] _getDisplayMedia failed because,', e, s); | ||||||
|  |       rethrow; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   CallSession? _getCallForParticipant( | ||||||
|  |       GroupCallSession groupCall, CallParticipant participant) { | ||||||
|  |     return _callSessions.singleWhereOrNull((call) => | ||||||
|  |         call.groupCallId == groupCall.groupCallId && | ||||||
|  |         CallParticipant( | ||||||
|  |               groupCall.voip, | ||||||
|  |               userId: call.remoteUserId!, | ||||||
|  |               deviceId: call.remoteDeviceId, | ||||||
|  |             ) == | ||||||
|  |             participant); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _addCall(GroupCallSession groupCall, CallSession call) async { | ||||||
|  |     _callSessions.add(call); | ||||||
|  |     await _initCall(groupCall, call); | ||||||
|  |     groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// init a peer call from group calls. | ||||||
|  |   Future<void> _initCall(GroupCallSession groupCall, CallSession call) async { | ||||||
|  |     if (call.remoteUserId == null) { | ||||||
|  |       throw Exception( | ||||||
|  |           'Cannot init call without proper invitee user and device Id'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     call.onCallStateChanged.stream.listen(((event) async { | ||||||
|  |       await _onCallStateChanged(call, event); | ||||||
|  |     })); | ||||||
|  | 
 | ||||||
|  |     call.onCallReplaced.stream.listen((CallSession newCall) async { | ||||||
|  |       await _replaceCall(groupCall, call, newCall); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     call.onCallStreamsChanged.stream.listen((call) async { | ||||||
|  |       await call.tryRemoveStopedStreams(); | ||||||
|  |       await _onStreamsChanged(groupCall, call); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     call.onCallHangupNotifierForGroupCalls.stream.listen((event) async { | ||||||
|  |       await _onCallHangup(groupCall, call); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     call.onStreamAdd.stream.listen((stream) { | ||||||
|  |       if (!stream.isLocal()) { | ||||||
|  |         onStreamAdd.add(stream); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     call.onStreamRemoved.stream.listen((stream) { | ||||||
|  |       if (!stream.isLocal()) { | ||||||
|  |         onStreamRemoved.add(stream); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _replaceCall( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     CallSession existingCall, | ||||||
|  |     CallSession replacementCall, | ||||||
|  |   ) async { | ||||||
|  |     final existingCallIndex = _callSessions | ||||||
|  |         .indexWhere((element) => element.callId == existingCall.callId); | ||||||
|  | 
 | ||||||
|  |     if (existingCallIndex == -1) { | ||||||
|  |       throw Exception('Couldn\'t find call to replace'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _callSessions.removeAt(existingCallIndex); | ||||||
|  |     _callSessions.add(replacementCall); | ||||||
|  | 
 | ||||||
|  |     await _disposeCall(groupCall, existingCall, CallErrorCode.replaced); | ||||||
|  |     await _initCall(groupCall, replacementCall); | ||||||
|  | 
 | ||||||
|  |     groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Removes a peer call from group calls. | ||||||
|  |   Future<void> _removeCall(GroupCallSession groupCall, CallSession call, | ||||||
|  |       CallErrorCode hangupReason) async { | ||||||
|  |     await _disposeCall(groupCall, call, hangupReason); | ||||||
|  | 
 | ||||||
|  |     _callSessions.removeWhere((element) => call.callId == element.callId); | ||||||
|  | 
 | ||||||
|  |     groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _disposeCall(GroupCallSession groupCall, CallSession call, | ||||||
|  |       CallErrorCode hangupReason) async { | ||||||
|  |     if (call.remoteUserId == null) { | ||||||
|  |       throw Exception( | ||||||
|  |           'Cannot init call without proper invitee user and device Id'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (call.hangupReason == CallErrorCode.replaced) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (call.state != CallState.kEnded) { | ||||||
|  |       // no need to emit individual handleCallEnded on group calls | ||||||
|  |       // also prevents a loop of hangup and onCallHangupNotifierForGroupCalls | ||||||
|  |       await call.hangup(reason: hangupReason, shouldEmit: false); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final usermediaStream = _getUserMediaStreamByParticipantId( | ||||||
|  |       CallParticipant( | ||||||
|  |         groupCall.voip, | ||||||
|  |         userId: call.remoteUserId!, | ||||||
|  |         deviceId: call.remoteDeviceId, | ||||||
|  |       ).id, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if (usermediaStream != null) { | ||||||
|  |       await _removeUserMediaStream(groupCall, usermediaStream); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final screenshareStream = _getScreenshareStreamByParticipantId( | ||||||
|  |       CallParticipant( | ||||||
|  |         groupCall.voip, | ||||||
|  |         userId: call.remoteUserId!, | ||||||
|  |         deviceId: call.remoteDeviceId, | ||||||
|  |       ).id, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if (screenshareStream != null) { | ||||||
|  |       await _removeScreenshareStream(groupCall, screenshareStream); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _onStreamsChanged( | ||||||
|  |       GroupCallSession groupCall, CallSession call) async { | ||||||
|  |     if (call.remoteUserId == null) { | ||||||
|  |       throw Exception( | ||||||
|  |           'Cannot init call without proper invitee user and device Id'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final currentUserMediaStream = _getUserMediaStreamByParticipantId( | ||||||
|  |       CallParticipant( | ||||||
|  |         groupCall.voip, | ||||||
|  |         userId: call.remoteUserId!, | ||||||
|  |         deviceId: call.remoteDeviceId, | ||||||
|  |       ).id, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     final remoteUsermediaStream = call.remoteUserMediaStream; | ||||||
|  |     final remoteStreamChanged = remoteUsermediaStream != currentUserMediaStream; | ||||||
|  | 
 | ||||||
|  |     if (remoteStreamChanged) { | ||||||
|  |       if (currentUserMediaStream == null && remoteUsermediaStream != null) { | ||||||
|  |         await _addUserMediaStream(groupCall, remoteUsermediaStream); | ||||||
|  |       } else if (currentUserMediaStream != null && | ||||||
|  |           remoteUsermediaStream != null) { | ||||||
|  |         await _replaceUserMediaStream( | ||||||
|  |             groupCall, currentUserMediaStream, remoteUsermediaStream); | ||||||
|  |       } else if (currentUserMediaStream != null && | ||||||
|  |           remoteUsermediaStream == null) { | ||||||
|  |         await _removeUserMediaStream(groupCall, currentUserMediaStream); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final currentScreenshareStream = | ||||||
|  |         _getScreenshareStreamByParticipantId(CallParticipant( | ||||||
|  |       groupCall.voip, | ||||||
|  |       userId: call.remoteUserId!, | ||||||
|  |       deviceId: call.remoteDeviceId, | ||||||
|  |     ).id); | ||||||
|  |     final remoteScreensharingStream = call.remoteScreenSharingStream; | ||||||
|  |     final remoteScreenshareStreamChanged = | ||||||
|  |         remoteScreensharingStream != currentScreenshareStream; | ||||||
|  | 
 | ||||||
|  |     if (remoteScreenshareStreamChanged) { | ||||||
|  |       if (currentScreenshareStream == null && | ||||||
|  |           remoteScreensharingStream != null) { | ||||||
|  |         _addScreenshareStream(groupCall, remoteScreensharingStream); | ||||||
|  |       } else if (currentScreenshareStream != null && | ||||||
|  |           remoteScreensharingStream != null) { | ||||||
|  |         await _replaceScreenshareStream( | ||||||
|  |             groupCall, currentScreenshareStream, remoteScreensharingStream); | ||||||
|  |       } else if (currentScreenshareStream != null && | ||||||
|  |           remoteScreensharingStream == null) { | ||||||
|  |         await _removeScreenshareStream(groupCall, currentScreenshareStream); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     onGroupCallFeedsChanged.add(groupCall); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   WrappedMediaStream? _getUserMediaStreamByParticipantId(String participantId) { | ||||||
|  |     final stream = _userMediaStreams | ||||||
|  |         .where((stream) => stream.participant.id == participantId); | ||||||
|  |     if (stream.isNotEmpty) { | ||||||
|  |       return stream.first; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void _onActiveSpeakerLoop(GroupCallSession groupCall) async { | ||||||
|  |     CallParticipant? nextActiveSpeaker; | ||||||
|  |     // idc about screen sharing atm. | ||||||
|  |     final userMediaStreamsCopyList = | ||||||
|  |         List<WrappedMediaStream>.from(_userMediaStreams); | ||||||
|  |     for (final stream in userMediaStreamsCopyList) { | ||||||
|  |       if (stream.participant.isLocal && stream.pc == null) { | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       final List<StatsReport> statsReport = await stream.pc!.getStats(); | ||||||
|  |       statsReport | ||||||
|  |           .removeWhere((element) => !element.values.containsKey('audioLevel')); | ||||||
|  | 
 | ||||||
|  |       // https://www.w3.org/TR/webrtc-stats/#summary | ||||||
|  |       final otherPartyAudioLevel = statsReport | ||||||
|  |           .singleWhereOrNull((element) => | ||||||
|  |               element.type == 'inbound-rtp' && | ||||||
|  |               element.values['kind'] == 'audio') | ||||||
|  |           ?.values['audioLevel']; | ||||||
|  |       if (otherPartyAudioLevel != null) { | ||||||
|  |         _audioLevelsMap[stream.participant] = otherPartyAudioLevel; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // https://www.w3.org/TR/webrtc-stats/#dom-rtcstatstype-media-source | ||||||
|  |       // firefox does not seem to have this though. Works on chrome and android | ||||||
|  |       final ownAudioLevel = statsReport | ||||||
|  |           .singleWhereOrNull((element) => | ||||||
|  |               element.type == 'media-source' && | ||||||
|  |               element.values['kind'] == 'audio') | ||||||
|  |           ?.values['audioLevel']; | ||||||
|  |       if (groupCall.localParticipant != null && | ||||||
|  |           ownAudioLevel != null && | ||||||
|  |           _audioLevelsMap[groupCall.localParticipant] != ownAudioLevel) { | ||||||
|  |         _audioLevelsMap[groupCall.localParticipant!] = ownAudioLevel; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     double maxAudioLevel = double.negativeInfinity; | ||||||
|  |     // TODO: we probably want a threshold here? | ||||||
|  |     _audioLevelsMap.forEach((key, value) { | ||||||
|  |       if (value > maxAudioLevel) { | ||||||
|  |         nextActiveSpeaker = key; | ||||||
|  |         maxAudioLevel = value; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) { | ||||||
|  |       _activeSpeaker = nextActiveSpeaker; | ||||||
|  |       groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); | ||||||
|  |     } | ||||||
|  |     _activeSpeakerLoopTimeout?.cancel(); | ||||||
|  |     _activeSpeakerLoopTimeout = Timer( | ||||||
|  |       CallConstants.activeSpeakerInterval, | ||||||
|  |       () => _onActiveSpeakerLoop(groupCall), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   WrappedMediaStream? _getScreenshareStreamByParticipantId( | ||||||
|  |       String participantId) { | ||||||
|  |     final stream = _screenshareStreams | ||||||
|  |         .where((stream) => stream.participant.id == participantId); | ||||||
|  |     if (stream.isNotEmpty) { | ||||||
|  |       return stream.first; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void _addScreenshareStream( | ||||||
|  |       GroupCallSession groupCall, WrappedMediaStream stream) { | ||||||
|  |     _screenshareStreams.add(stream); | ||||||
|  |     onStreamAdd.add(stream); | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.screenshareStreamsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _replaceScreenshareStream( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     WrappedMediaStream existingStream, | ||||||
|  |     WrappedMediaStream replacementStream, | ||||||
|  |   ) async { | ||||||
|  |     final streamIndex = _screenshareStreams.indexWhere( | ||||||
|  |         (stream) => stream.participant.id == existingStream.participant.id); | ||||||
|  | 
 | ||||||
|  |     if (streamIndex == -1) { | ||||||
|  |       throw Exception('Couldn\'t find screenshare stream to replace'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]); | ||||||
|  | 
 | ||||||
|  |     await existingStream.dispose(); | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.screenshareStreamsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _removeScreenshareStream( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     WrappedMediaStream stream, | ||||||
|  |   ) async { | ||||||
|  |     final streamIndex = _screenshareStreams | ||||||
|  |         .indexWhere((stream) => stream.participant.id == stream.participant.id); | ||||||
|  | 
 | ||||||
|  |     if (streamIndex == -1) { | ||||||
|  |       throw Exception('Couldn\'t find screenshare stream to remove'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _screenshareStreams.removeWhere( | ||||||
|  |         (element) => element.participant.id == stream.participant.id); | ||||||
|  | 
 | ||||||
|  |     onStreamRemoved.add(stream); | ||||||
|  | 
 | ||||||
|  |     if (stream.isLocal()) { | ||||||
|  |       await stopMediaStream(stream.stream); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.screenshareStreamsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _onCallStateChanged(CallSession call, CallState state) async { | ||||||
|  |     final audioMuted = localUserMediaStream?.isAudioMuted() ?? true; | ||||||
|  |     if (call.localUserMediaStream != null && | ||||||
|  |         call.isMicrophoneMuted != audioMuted) { | ||||||
|  |       await call.setMicrophoneMuted(audioMuted); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final videoMuted = localUserMediaStream?.isVideoMuted() ?? true; | ||||||
|  | 
 | ||||||
|  |     if (call.localUserMediaStream != null && | ||||||
|  |         call.isLocalVideoMuted != videoMuted) { | ||||||
|  |       await call.setLocalVideoMuted(videoMuted); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _onCallHangup( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     CallSession call, | ||||||
|  |   ) async { | ||||||
|  |     if (call.hangupReason == CallErrorCode.replaced) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     await _onStreamsChanged(groupCall, call); | ||||||
|  |     await _removeCall(groupCall, call, call.hangupReason!); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _addUserMediaStream( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     WrappedMediaStream stream, | ||||||
|  |   ) async { | ||||||
|  |     _userMediaStreams.add(stream); | ||||||
|  |     onStreamAdd.add(stream); | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.userMediaStreamsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _replaceUserMediaStream( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     WrappedMediaStream existingStream, | ||||||
|  |     WrappedMediaStream replacementStream, | ||||||
|  |   ) async { | ||||||
|  |     final streamIndex = _userMediaStreams.indexWhere( | ||||||
|  |         (stream) => stream.participant.id == existingStream.participant.id); | ||||||
|  | 
 | ||||||
|  |     if (streamIndex == -1) { | ||||||
|  |       throw Exception('Couldn\'t find user media stream to replace'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]); | ||||||
|  | 
 | ||||||
|  |     await existingStream.dispose(); | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.userMediaStreamsChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _removeUserMediaStream( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     WrappedMediaStream stream, | ||||||
|  |   ) async { | ||||||
|  |     final streamIndex = _userMediaStreams.indexWhere( | ||||||
|  |         (element) => element.participant.id == stream.participant.id); | ||||||
|  | 
 | ||||||
|  |     if (streamIndex == -1) { | ||||||
|  |       throw Exception('Couldn\'t find user media stream to remove'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _userMediaStreams.removeWhere( | ||||||
|  |         (element) => element.participant.id == stream.participant.id); | ||||||
|  |     _audioLevelsMap.remove(stream.participant); | ||||||
|  |     onStreamRemoved.add(stream); | ||||||
|  | 
 | ||||||
|  |     if (stream.isLocal()) { | ||||||
|  |       await stopMediaStream(stream.stream); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     groupCall.onGroupCallEvent | ||||||
|  |         .add(GroupCallStateChange.userMediaStreamsChanged); | ||||||
|  | 
 | ||||||
|  |     if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) { | ||||||
|  |       _activeSpeaker = _userMediaStreams[0].participant; | ||||||
|  |       groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool get e2eeEnabled => false; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   CallParticipant? get activeSpeaker => _activeSpeaker; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   WrappedMediaStream? get localUserMediaStream => _localUserMediaStream; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   WrappedMediaStream? get localScreenshareStream => _localScreenshareStream; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<WrappedMediaStream> get userMediaStreams => | ||||||
|  |       List.unmodifiable(_userMediaStreams); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<WrappedMediaStream> get screenShareStreams => | ||||||
|  |       List.unmodifiable(_screenshareStreams); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> updateMediaDeviceForCalls() async { | ||||||
|  |     for (final call in _callSessions) { | ||||||
|  |       await call.updateMediaDeviceForCall(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Initializes the local user media stream. | ||||||
|  |   /// The media stream must be prepared before the group call enters. | ||||||
|  |   /// if you allow the user to configure their camera and such ahead of time, | ||||||
|  |   /// you can pass that `stream` on to this function. | ||||||
|  |   /// This allows you to configure the camera before joining the call without | ||||||
|  |   ///  having to reopen the stream and possibly losing settings. | ||||||
|  |   @override | ||||||
|  |   Future<WrappedMediaStream?> initLocalStream(GroupCallSession groupCall, | ||||||
|  |       {WrappedMediaStream? stream}) async { | ||||||
|  |     if (groupCall.state != GroupCallState.localCallFeedUninitialized) { | ||||||
|  |       throw Exception( | ||||||
|  |           'Cannot initialize local call feed in the ${groupCall.state} state.'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     groupCall.setState(GroupCallState.initializingLocalCallFeed); | ||||||
|  | 
 | ||||||
|  |     WrappedMediaStream localWrappedMediaStream; | ||||||
|  | 
 | ||||||
|  |     if (stream == null) { | ||||||
|  |       MediaStream stream; | ||||||
|  | 
 | ||||||
|  |       try { | ||||||
|  |         stream = await _getUserMedia(groupCall, CallType.kVideo); | ||||||
|  |       } catch (error) { | ||||||
|  |         groupCall.setState(GroupCallState.localCallFeedUninitialized); | ||||||
|  |         rethrow; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       localWrappedMediaStream = WrappedMediaStream( | ||||||
|  |         stream: stream, | ||||||
|  |         participant: groupCall.localParticipant!, | ||||||
|  |         room: groupCall.room, | ||||||
|  |         client: groupCall.client, | ||||||
|  |         purpose: SDPStreamMetadataPurpose.Usermedia, | ||||||
|  |         audioMuted: stream.getAudioTracks().isEmpty, | ||||||
|  |         videoMuted: stream.getVideoTracks().isEmpty, | ||||||
|  |         isGroupCall: true, | ||||||
|  |         voip: groupCall.voip, | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       localWrappedMediaStream = stream; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _localUserMediaStream = localWrappedMediaStream; | ||||||
|  |     await _addUserMediaStream(groupCall, localWrappedMediaStream); | ||||||
|  | 
 | ||||||
|  |     groupCall.setState(GroupCallState.localCallFeedInitialized); | ||||||
|  | 
 | ||||||
|  |     _activeSpeaker = null; | ||||||
|  | 
 | ||||||
|  |     return localWrappedMediaStream; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setDeviceMuted( | ||||||
|  |       GroupCallSession groupCall, bool muted, MediaInputKind kind) async { | ||||||
|  |     if (!await hasMediaDevice(groupCall.voip.delegate, kind)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (localUserMediaStream != null) { | ||||||
|  |       switch (kind) { | ||||||
|  |         case MediaInputKind.audioinput: | ||||||
|  |           localUserMediaStream!.setAudioMuted(muted); | ||||||
|  |           setTracksEnabled( | ||||||
|  |               localUserMediaStream!.stream!.getAudioTracks(), !muted); | ||||||
|  |           for (final call in _callSessions) { | ||||||
|  |             await call.setMicrophoneMuted(muted); | ||||||
|  |           } | ||||||
|  |           break; | ||||||
|  |         case MediaInputKind.videoinput: | ||||||
|  |           localUserMediaStream!.setVideoMuted(muted); | ||||||
|  |           setTracksEnabled( | ||||||
|  |               localUserMediaStream!.stream!.getVideoTracks(), !muted); | ||||||
|  |           for (final call in _callSessions) { | ||||||
|  |             await call.setLocalVideoMuted(muted); | ||||||
|  |           } | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     groupCall.onGroupCallEvent.add(GroupCallStateChange.localMuteStateChanged); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _onIncomingCall( | ||||||
|  |       GroupCallSession groupCall, CallSession newCall) async { | ||||||
|  |     // The incoming calls may be for another room, which we will ignore. | ||||||
|  |     if (newCall.room.id != groupCall.room.id) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (newCall.state != CallState.kRinging) { | ||||||
|  |       Logs().w('Incoming call no longer in ringing state. Ignoring.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (newCall.groupCallId == null || | ||||||
|  |         newCall.groupCallId != groupCall.groupCallId) { | ||||||
|  |       Logs().v( | ||||||
|  |           'Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn\'t match the current group call'); | ||||||
|  |       await newCall.reject(); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final existingCall = _getCallForParticipant( | ||||||
|  |       groupCall, | ||||||
|  |       CallParticipant( | ||||||
|  |         groupCall.voip, | ||||||
|  |         userId: newCall.remoteUserId!, | ||||||
|  |         deviceId: newCall.remoteDeviceId, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if (existingCall != null && existingCall.callId == newCall.callId) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Logs().v( | ||||||
|  |         'GroupCallSession: incoming call from: ${newCall.remoteUserId}${newCall.remoteDeviceId}${newCall.remotePartyId}'); | ||||||
|  | 
 | ||||||
|  |     // Check if the user calling has an existing call and use this call instead. | ||||||
|  |     if (existingCall != null) { | ||||||
|  |       await _replaceCall(groupCall, existingCall, newCall); | ||||||
|  |     } else { | ||||||
|  |       await _addCall(groupCall, newCall); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await newCall.answerWithStreams(_getLocalStreams()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setScreensharingEnabled( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     bool enabled, | ||||||
|  |     String desktopCapturerSourceId, | ||||||
|  |   ) async { | ||||||
|  |     if (enabled == (localScreenshareStream != null)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (enabled) { | ||||||
|  |       try { | ||||||
|  |         Logs().v('Asking for screensharing permissions...'); | ||||||
|  |         final stream = await _getDisplayMedia(groupCall); | ||||||
|  |         for (final track in stream.getTracks()) { | ||||||
|  |           // screen sharing should only have 1 video track anyway, so this only | ||||||
|  |           // fires once | ||||||
|  |           track.onEnded = () async { | ||||||
|  |             await setScreensharingEnabled(groupCall, false, ''); | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |         Logs().v( | ||||||
|  |             'Screensharing permissions granted. Setting screensharing enabled on all calls'); | ||||||
|  |         _localScreenshareStream = WrappedMediaStream( | ||||||
|  |           stream: stream, | ||||||
|  |           participant: groupCall.localParticipant!, | ||||||
|  |           room: groupCall.room, | ||||||
|  |           client: groupCall.client, | ||||||
|  |           purpose: SDPStreamMetadataPurpose.Screenshare, | ||||||
|  |           audioMuted: stream.getAudioTracks().isEmpty, | ||||||
|  |           videoMuted: stream.getVideoTracks().isEmpty, | ||||||
|  |           isGroupCall: true, | ||||||
|  |           voip: groupCall.voip, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         _addScreenshareStream(groupCall, localScreenshareStream!); | ||||||
|  | 
 | ||||||
|  |         groupCall.onGroupCallEvent | ||||||
|  |             .add(GroupCallStateChange.localScreenshareStateChanged); | ||||||
|  |         for (final call in _callSessions) { | ||||||
|  |           await call.addLocalStream( | ||||||
|  |               await localScreenshareStream!.stream!.clone(), | ||||||
|  |               localScreenshareStream!.purpose); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await groupCall.sendMemberStateEvent(); | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } catch (e, s) { | ||||||
|  |         Logs().e('[VOIP] Enabling screensharing error', e, s); | ||||||
|  |         groupCall.onGroupCallEvent.add(GroupCallStateChange.error); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       for (final call in _callSessions) { | ||||||
|  |         await call.removeLocalStream(call.localScreenSharingStream!); | ||||||
|  |       } | ||||||
|  |       await stopMediaStream(localScreenshareStream?.stream); | ||||||
|  |       await _removeScreenshareStream(groupCall, localScreenshareStream!); | ||||||
|  |       _localScreenshareStream = null; | ||||||
|  | 
 | ||||||
|  |       await groupCall.sendMemberStateEvent(); | ||||||
|  | 
 | ||||||
|  |       groupCall.onGroupCallEvent | ||||||
|  |           .add(GroupCallStateChange.localMuteStateChanged); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> dispose(GroupCallSession groupCall) async { | ||||||
|  |     if (localUserMediaStream != null) { | ||||||
|  |       await _removeUserMediaStream(groupCall, localUserMediaStream!); | ||||||
|  |       _localUserMediaStream = null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (localScreenshareStream != null) { | ||||||
|  |       await stopMediaStream(localScreenshareStream!.stream); | ||||||
|  |       await _removeScreenshareStream(groupCall, localScreenshareStream!); | ||||||
|  |       _localScreenshareStream = null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // removeCall removes it from `_callSessions` later. | ||||||
|  |     final callsCopy = _callSessions.toList(); | ||||||
|  | 
 | ||||||
|  |     for (final call in callsCopy) { | ||||||
|  |       await _removeCall(groupCall, call, CallErrorCode.userHangup); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _activeSpeaker = null; | ||||||
|  |     _activeSpeakerLoopTimeout?.cancel(); | ||||||
|  |     await _callSubscription?.cancel(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool get isLocalVideoMuted { | ||||||
|  |     if (localUserMediaStream != null) { | ||||||
|  |       return localUserMediaStream!.isVideoMuted(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool get isMicrophoneMuted { | ||||||
|  |     if (localUserMediaStream != null) { | ||||||
|  |       return localUserMediaStream!.isAudioMuted(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setupP2PCallsWithExistingMembers( | ||||||
|  |       GroupCallSession groupCall) async { | ||||||
|  |     for (final call in _callSessions) { | ||||||
|  |       await _onIncomingCall(groupCall, call); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     _callSubscription = groupCall.voip.onIncomingCall.stream.listen( | ||||||
|  |       (newCall) => _onIncomingCall(groupCall, newCall), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     _onActiveSpeakerLoop(groupCall); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> setupP2PCallWithNewMember( | ||||||
|  |     GroupCallSession groupCall, | ||||||
|  |     CallParticipant rp, | ||||||
|  |     CallMembership mem, | ||||||
|  |   ) async { | ||||||
|  |     final existingCall = _getCallForParticipant(groupCall, rp); | ||||||
|  |     if (existingCall != null) { | ||||||
|  |       if (existingCall.remoteSessionId != mem.membershipId) { | ||||||
|  |         await existingCall.hangup(reason: CallErrorCode.unknownError); | ||||||
|  |       } else { | ||||||
|  |         Logs().e( | ||||||
|  |             '[VOIP] onMemberStateChanged Not updating _participants list, already have a ongoing call with ${rp.id}'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Only initiate a call with a participant who has a id that is lexicographically | ||||||
|  |     // less than your own. Otherwise, that user will call you. | ||||||
|  |     if (groupCall.localParticipant!.id.compareTo(rp.id) > 0) { | ||||||
|  |       Logs().i('[VOIP] Waiting for ${rp.id} to send call invite.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final opts = CallOptions( | ||||||
|  |       callId: genCallID(), | ||||||
|  |       room: groupCall.room, | ||||||
|  |       voip: groupCall.voip, | ||||||
|  |       dir: CallDirection.kOutgoing, | ||||||
|  |       localPartyId: groupCall.voip.currentSessionId, | ||||||
|  |       groupCallId: groupCall.groupCallId, | ||||||
|  |       type: CallType.kVideo, | ||||||
|  |       iceServers: await groupCall.voip.getIceServers(), | ||||||
|  |     ); | ||||||
|  |     final newCall = groupCall.voip.createNewCall(opts); | ||||||
|  | 
 | ||||||
|  |     /// both invitee userId and deviceId are set here because there can be | ||||||
|  |     /// multiple devices from same user in a call, so we specifiy who the | ||||||
|  |     /// invite is for | ||||||
|  |     /// | ||||||
|  |     /// MOVE TO CREATENEWCALL? | ||||||
|  |     newCall.remoteUserId = mem.userId; | ||||||
|  |     newCall.remoteDeviceId = mem.deviceId; | ||||||
|  |     // party id set to when answered | ||||||
|  |     newCall.remoteSessionId = mem.membershipId; | ||||||
|  | 
 | ||||||
|  |     await newCall.placeCallWithStreams(_getLocalStreams(), | ||||||
|  |         requestScreenSharing: mem.feeds?.any((element) => | ||||||
|  |                 element['purpose'] == SDPStreamMetadataPurpose.Screenshare) ?? | ||||||
|  |             false); | ||||||
|  | 
 | ||||||
|  |     await _addCall(groupCall, newCall); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   List<Map<String, String>>? getCurrentFeeds() { | ||||||
|  |     return _getLocalStreams() | ||||||
|  |         .map((feed) => ({ | ||||||
|  |               'purpose': feed.purpose, | ||||||
|  |             })) | ||||||
|  |         .toList(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => | ||||||
|  |       identical(this, other) || other is MeshBackend && type == other.type; | ||||||
|  |   @override | ||||||
|  |   int get hashCode => type.hashCode; | ||||||
|  | 
 | ||||||
|  |   /// get everything is livekit specific mesh calls shouldn't be affected by these | ||||||
|  |   @override | ||||||
|  |   Future<void> onCallEncryption(GroupCallSession groupCall, String userId, | ||||||
|  |       String deviceId, Map<String, dynamic> content) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onCallEncryptionKeyRequest(GroupCallSession groupCall, | ||||||
|  |       String userId, String deviceId, Map<String, dynamic> content) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onLeftParticipant( | ||||||
|  |       GroupCallSession groupCall, List<CallParticipant> anyLeft) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> onNewParticipant( | ||||||
|  |       GroupCallSession groupCall, List<CallParticipant> anyJoined) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> requestEncrytionKey(GroupCallSession groupCall, | ||||||
|  |       List<CallParticipant> remoteParticipants) async { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | } | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -0,0 +1,272 @@ | ||||||
|  | /* | ||||||
|  |  *   Famedly Matrix SDK | ||||||
|  |  *   Copyright (C) 2021 Famedly GmbH | ||||||
|  |  * | ||||||
|  |  *   This program is free software: you can redistribute it and/or modify | ||||||
|  |  *   it under the terms of the GNU Affero General License as | ||||||
|  |  *   published by the Free Software Foundation, either version 3 of the | ||||||
|  |  *   License, or (at your option) any later version. | ||||||
|  |  * | ||||||
|  |  *   This program is distributed in the hope that it will be useful, | ||||||
|  |  *   but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||||
|  |  *   GNU Affero General License for more details. | ||||||
|  |  * | ||||||
|  |  *   You should have received a copy of the GNU Affero General License | ||||||
|  |  *   along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import 'dart:async'; | ||||||
|  | import 'dart:core'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/utils/cached_stream_controller.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/voip_id.dart'; | ||||||
|  | import 'package:matrix/src/voip/utils/stream_helper.dart'; | ||||||
|  | 
 | ||||||
|  | /// Holds methods for managing a group call. This class is also responsible for | ||||||
|  | /// holding and managing the individual `CallSession`s in a group call. | ||||||
|  | class GroupCallSession { | ||||||
|  |   // Config | ||||||
|  |   final Client client; | ||||||
|  |   final VoIP voip; | ||||||
|  |   final Room room; | ||||||
|  | 
 | ||||||
|  |   /// is a list of backend to allow passing multiple backend in the future | ||||||
|  |   /// we use the first backend everywhere as of now | ||||||
|  |   final CallBackend backend; | ||||||
|  | 
 | ||||||
|  |   /// something like normal calls or thirdroom | ||||||
|  |   final String? application; | ||||||
|  | 
 | ||||||
|  |   /// either room scoped or user scoped calls | ||||||
|  |   final String? scope; | ||||||
|  | 
 | ||||||
|  |   GroupCallState state = GroupCallState.localCallFeedUninitialized; | ||||||
|  | 
 | ||||||
|  |   CallParticipant? get localParticipant => voip.localParticipant; | ||||||
|  | 
 | ||||||
|  |   List<CallParticipant> get participants => List.unmodifiable(_participants); | ||||||
|  |   final List<CallParticipant> _participants = []; | ||||||
|  | 
 | ||||||
|  |   String groupCallId; | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<GroupCallState> onGroupCallState = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<GroupCallStateChange> onGroupCallEvent = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   Timer? _resendMemberStateEventTimer; | ||||||
|  | 
 | ||||||
|  |   factory GroupCallSession.withAutoGenId( | ||||||
|  |     Room room, | ||||||
|  |     VoIP voip, | ||||||
|  |     CallBackend backend, | ||||||
|  |     String? application, | ||||||
|  |     String? scope, | ||||||
|  |     String? groupCallId, | ||||||
|  |   ) { | ||||||
|  |     return GroupCallSession( | ||||||
|  |       client: room.client, | ||||||
|  |       room: room, | ||||||
|  |       voip: voip, | ||||||
|  |       backend: backend, | ||||||
|  |       application: application ?? 'm.call', | ||||||
|  |       scope: scope ?? 'm.room', | ||||||
|  |       groupCallId: groupCallId ?? genCallID(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   GroupCallSession({ | ||||||
|  |     required this.client, | ||||||
|  |     required this.room, | ||||||
|  |     required this.voip, | ||||||
|  |     required this.backend, | ||||||
|  |     required this.groupCallId, | ||||||
|  |     required this.application, | ||||||
|  |     required this.scope, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String get avatarName => | ||||||
|  |       _getUser().calcDisplayname(mxidLocalPartFallback: false); | ||||||
|  | 
 | ||||||
|  |   String? get displayName => _getUser().displayName; | ||||||
|  | 
 | ||||||
|  |   User _getUser() { | ||||||
|  |     return room.unsafeGetUserFromMemoryOrFallback(client.userID!); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void setState(GroupCallState newState) { | ||||||
|  |     state = newState; | ||||||
|  |     onGroupCallState.add(newState); | ||||||
|  |     onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   bool hasLocalParticipant() { | ||||||
|  |     return _participants.contains(localParticipant); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// enter the group call. | ||||||
|  |   Future<void> enter({WrappedMediaStream? stream}) async { | ||||||
|  |     if (!(state == GroupCallState.localCallFeedUninitialized || | ||||||
|  |         state == GroupCallState.localCallFeedInitialized)) { | ||||||
|  |       throw Exception('Cannot enter call in the $state state'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (state == GroupCallState.localCallFeedUninitialized) { | ||||||
|  |       await backend.initLocalStream(this, stream: stream); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await sendMemberStateEvent(); | ||||||
|  | 
 | ||||||
|  |     setState(GroupCallState.entered); | ||||||
|  | 
 | ||||||
|  |     Logs().v('Entered group call $groupCallId'); | ||||||
|  | 
 | ||||||
|  |     // Set up _participants for the members currently in the call. | ||||||
|  |     // Other members will be picked up by the RoomState.members event. | ||||||
|  |     await onMemberStateChanged(); | ||||||
|  | 
 | ||||||
|  |     await backend.setupP2PCallsWithExistingMembers(this); | ||||||
|  | 
 | ||||||
|  |     voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId); | ||||||
|  | 
 | ||||||
|  |     await voip.delegate.handleNewGroupCall(this); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> leave() async { | ||||||
|  |     await removeMemberStateEvent(); | ||||||
|  |     await backend.dispose(this); | ||||||
|  |     setState(GroupCallState.localCallFeedUninitialized); | ||||||
|  |     voip.currentGroupCID = null; | ||||||
|  |     _participants.clear(); | ||||||
|  |     voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId)); | ||||||
|  |     await voip.delegate.handleGroupCallEnded(this); | ||||||
|  |     _resendMemberStateEventTimer?.cancel(); | ||||||
|  |     setState(GroupCallState.ended); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> sendMemberStateEvent() async { | ||||||
|  |     await room.updateFamedlyCallMemberStateEvent( | ||||||
|  |       CallMembership( | ||||||
|  |         userId: client.userID!, | ||||||
|  |         roomId: room.id, | ||||||
|  |         callId: groupCallId, | ||||||
|  |         application: application, | ||||||
|  |         scope: scope, | ||||||
|  |         backend: backend, | ||||||
|  |         deviceId: client.deviceID!, | ||||||
|  |         expiresTs: DateTime.now() | ||||||
|  |             .add(CallTimeouts.expireTsBumpDuration) | ||||||
|  |             .millisecondsSinceEpoch, | ||||||
|  |         membershipId: voip.currentSessionId, | ||||||
|  |         feeds: backend.getCurrentFeeds(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if (_resendMemberStateEventTimer != null) { | ||||||
|  |       _resendMemberStateEventTimer!.cancel(); | ||||||
|  |     } | ||||||
|  |     _resendMemberStateEventTimer = Timer.periodic( | ||||||
|  |         CallTimeouts.updateExpireTsTimerDuration, ((timer) async { | ||||||
|  |       Logs().d('sendMemberStateEvent updating member event with timer'); | ||||||
|  |       if (state != GroupCallState.ended || | ||||||
|  |           state != GroupCallState.localCallFeedUninitialized) { | ||||||
|  |         await sendMemberStateEvent(); | ||||||
|  |       } else { | ||||||
|  |         Logs().d( | ||||||
|  |             '[VOIP] deteceted groupCall in state $state, removing state event'); | ||||||
|  |         await removeMemberStateEvent(); | ||||||
|  |       } | ||||||
|  |     })); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> removeMemberStateEvent() { | ||||||
|  |     if (_resendMemberStateEventTimer != null) { | ||||||
|  |       Logs().d('resend member event timer cancelled'); | ||||||
|  |       _resendMemberStateEventTimer!.cancel(); | ||||||
|  |       _resendMemberStateEventTimer = null; | ||||||
|  |     } | ||||||
|  |     return room.removeFamedlyCallMemberEvent( | ||||||
|  |       groupCallId, | ||||||
|  |       client.deviceID!, | ||||||
|  |       application: application, | ||||||
|  |       scope: scope, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// compltetely rebuilds the local _participants list | ||||||
|  |   Future<void> onMemberStateChanged() async { | ||||||
|  |     if (state != GroupCallState.entered) { | ||||||
|  |       Logs().d( | ||||||
|  |           '[VOIP] early return onMemberStateChanged, group call state is not Entered. Actual state: ${state.toString()} '); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // The member events may be received for another room, which we will ignore. | ||||||
|  |     final mems = | ||||||
|  |         room.getCallMembershipsFromRoom().values.expand((element) => element); | ||||||
|  |     final memsForCurrentGroupCall = mems.where((element) { | ||||||
|  |       return element.callId == groupCallId && | ||||||
|  |           !element.isExpired && | ||||||
|  |           element.application == application && | ||||||
|  |           element.scope == scope && | ||||||
|  |           element.roomId == room.id; // sanity checks | ||||||
|  |     }).toList(); | ||||||
|  | 
 | ||||||
|  |     final ignoredMems = | ||||||
|  |         mems.where((element) => !memsForCurrentGroupCall.contains(element)); | ||||||
|  | 
 | ||||||
|  |     for (final mem in ignoredMems) { | ||||||
|  |       Logs().w( | ||||||
|  |           '[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     final List<CallParticipant> newP = []; | ||||||
|  | 
 | ||||||
|  |     for (final mem in memsForCurrentGroupCall) { | ||||||
|  |       final rp = CallParticipant( | ||||||
|  |         voip, | ||||||
|  |         userId: mem.userId, | ||||||
|  |         deviceId: mem.deviceId, | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       newP.add(rp); | ||||||
|  | 
 | ||||||
|  |       if (rp.isLocal) continue; | ||||||
|  | 
 | ||||||
|  |       if (state != GroupCallState.entered) { | ||||||
|  |         Logs().w( | ||||||
|  |             '[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update'); | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       await backend.setupP2PCallWithNewMember(this, rp, mem); | ||||||
|  |     } | ||||||
|  |     final newPcopy = List<CallParticipant>.from(newP); | ||||||
|  |     final oldPcopy = List<CallParticipant>.from(_participants); | ||||||
|  |     final anyJoined = newPcopy.where((element) => !oldPcopy.contains(element)); | ||||||
|  |     final anyLeft = oldPcopy.where((element) => !newPcopy.contains(element)); | ||||||
|  | 
 | ||||||
|  |     if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) { | ||||||
|  |       if (anyJoined.isNotEmpty) { | ||||||
|  |         Logs().d('anyJoined: ${anyJoined.map((e) => e.id).toString()}'); | ||||||
|  |         _participants.addAll(anyJoined); | ||||||
|  |         await backend.onNewParticipant(this, anyJoined.toList()); | ||||||
|  |       } | ||||||
|  |       if (anyLeft.isNotEmpty) { | ||||||
|  |         Logs().d('anyLeft: ${anyLeft.map((e) => e.id).toString()}'); | ||||||
|  |         for (final leftp in anyLeft) { | ||||||
|  |           _participants.remove(leftp); | ||||||
|  |         } | ||||||
|  |         await backend.onLeftParticipant(this, anyLeft.toList()); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       onGroupCallEvent.add(GroupCallStateChange.participantsChanged); | ||||||
|  |       Logs().d( | ||||||
|  |           '[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -60,12 +60,12 @@ class CallReplaces { | ||||||
|         target_user: CallReplacesTarget.fromJson(json['target_user']), |         target_user: CallReplacesTarget.fromJson(json['target_user']), | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|   Map<String, dynamic> toJson() => { |   Map<String, Object> toJson() => { | ||||||
|         if (replacement_id != null) 'replacement_id': replacement_id, |         if (replacement_id != null) 'replacement_id': replacement_id!, | ||||||
|         if (target_user != null) 'target_user': target_user!.toJson(), |         if (target_user != null) 'target_user': target_user!.toJson(), | ||||||
|         if (create_call != null) 'create_call': create_call, |         if (create_call != null) 'create_call': create_call!, | ||||||
|         if (await_call != null) 'await_call': await_call, |         if (await_call != null) 'await_call': await_call!, | ||||||
|         if (target_room != null) 'target_room': target_room, |         if (target_room != null) 'target_room': target_room!, | ||||||
|       }; |       }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -0,0 +1,124 @@ | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | class FamedlyCallMemberEvent { | ||||||
|  |   final List<CallMembership> memberships; | ||||||
|  | 
 | ||||||
|  |   FamedlyCallMemberEvent({required this.memberships}); | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     return {'memberships': memberships.map((e) => e.toJson()).toList()}; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   factory FamedlyCallMemberEvent.fromJson(Event event) { | ||||||
|  |     final List<CallMembership> callMemberships = []; | ||||||
|  |     final memberships = event.content.tryGetList('memberships'); | ||||||
|  |     if (memberships != null && memberships.isNotEmpty) { | ||||||
|  |       for (final mem in memberships) { | ||||||
|  |         if (isValidMemEvent(mem)) { | ||||||
|  |           final callMem = | ||||||
|  |               CallMembership.fromJson(mem, event.senderId, event.room.id); | ||||||
|  |           if (callMem != null) callMemberships.add(callMem); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return FamedlyCallMemberEvent(memberships: callMemberships); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class CallMembership { | ||||||
|  |   final String userId; | ||||||
|  |   final String callId; | ||||||
|  |   final String? application; | ||||||
|  |   final String? scope; | ||||||
|  |   final CallBackend backend; | ||||||
|  |   final String deviceId; | ||||||
|  |   final int expiresTs; | ||||||
|  |   final String membershipId; | ||||||
|  |   final List? feeds; | ||||||
|  | 
 | ||||||
|  |   final String roomId; | ||||||
|  | 
 | ||||||
|  |   CallMembership({ | ||||||
|  |     required this.userId, | ||||||
|  |     required this.callId, | ||||||
|  |     required this.backend, | ||||||
|  |     required this.deviceId, | ||||||
|  |     required this.expiresTs, | ||||||
|  |     required this.roomId, | ||||||
|  |     required this.membershipId, | ||||||
|  |     this.application = 'm.call', | ||||||
|  |     this.scope = 'm.room', | ||||||
|  |     this.feeds, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     return { | ||||||
|  |       'call_id': callId, | ||||||
|  |       'application': application, | ||||||
|  |       'scope': scope, | ||||||
|  |       'foci_active': [backend.toJson()], | ||||||
|  |       'device_id': deviceId, | ||||||
|  |       'expires_ts': expiresTs, | ||||||
|  |       'expires': 7200000, // element compatibiltiy remove asap | ||||||
|  |       'membershipID': membershipId, // sessionId | ||||||
|  |       if (feeds != null) 'feeds': feeds, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static CallMembership? fromJson(Map json, String userId, String roomId) { | ||||||
|  |     try { | ||||||
|  |       return CallMembership( | ||||||
|  |         userId: userId, | ||||||
|  |         roomId: roomId, | ||||||
|  |         callId: json['call_id'], | ||||||
|  |         application: json['application'], | ||||||
|  |         scope: json['scope'], | ||||||
|  |         backend: (json['foci_active'] as List) | ||||||
|  |             .map((e) => CallBackend.fromJson(e)) | ||||||
|  |             .first, | ||||||
|  |         deviceId: json['device_id'], | ||||||
|  |         expiresTs: json['expires_ts'], | ||||||
|  |         membershipId: | ||||||
|  |             json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID', | ||||||
|  |         feeds: json['feeds'], | ||||||
|  |       ); | ||||||
|  |     } catch (e, s) { | ||||||
|  |       Logs().e('[VOIP] call membership parsing failed. $json', e, s); | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(other) => | ||||||
|  |       identical(this, other) || | ||||||
|  |       other is CallMembership && | ||||||
|  |           runtimeType == other.runtimeType && | ||||||
|  |           userId == other.userId && | ||||||
|  |           roomId == other.roomId && | ||||||
|  |           callId == other.callId && | ||||||
|  |           application == other.application && | ||||||
|  |           scope == other.scope && | ||||||
|  |           backend.type == other.backend.type && | ||||||
|  |           deviceId == other.deviceId && | ||||||
|  |           membershipId == other.membershipId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |       userId.hashCode ^ | ||||||
|  |       roomId.hashCode ^ | ||||||
|  |       callId.hashCode ^ | ||||||
|  |       application.hashCode ^ | ||||||
|  |       scope.hashCode ^ | ||||||
|  |       backend.type.hashCode ^ | ||||||
|  |       deviceId.hashCode ^ | ||||||
|  |       membershipId.hashCode; | ||||||
|  | 
 | ||||||
|  |   // with a buffer of 1 minute just incase we were slow to process a | ||||||
|  |   // call event, if the device is actually dead it should | ||||||
|  |   // get removed pretty soon | ||||||
|  |   bool get isExpired => | ||||||
|  |       expiresTs < | ||||||
|  |       DateTime.now() | ||||||
|  |           .subtract(CallTimeouts.expireTsBumpDuration) | ||||||
|  |           .millisecondsSinceEpoch; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,26 @@ | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | /// Initialization parameters of the call session. | ||||||
|  | class CallOptions { | ||||||
|  |   final String callId; | ||||||
|  |   final CallType type; | ||||||
|  |   final CallDirection dir; | ||||||
|  | 
 | ||||||
|  |   /// client.deviceID | ||||||
|  |   final String localPartyId; | ||||||
|  |   final VoIP voip; | ||||||
|  |   final Room room; | ||||||
|  |   final List<Map<String, dynamic>> iceServers; | ||||||
|  |   final String? groupCallId; | ||||||
|  | 
 | ||||||
|  |   CallOptions({ | ||||||
|  |     required this.callId, | ||||||
|  |     required this.type, | ||||||
|  |     required this.dir, | ||||||
|  |     required this.localPartyId, | ||||||
|  |     required this.voip, | ||||||
|  |     required this.room, | ||||||
|  |     required this.iceServers, | ||||||
|  |     this.groupCallId, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | class CallParticipant { | ||||||
|  |   final VoIP voip; | ||||||
|  |   final String userId; | ||||||
|  |   final String? deviceId; | ||||||
|  | 
 | ||||||
|  |   CallParticipant( | ||||||
|  |     this.voip, { | ||||||
|  |     required this.userId, | ||||||
|  |     this.deviceId, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   bool get isLocal => | ||||||
|  |       userId == voip.client.userID && deviceId == voip.client.deviceID; | ||||||
|  | 
 | ||||||
|  |   String get id { | ||||||
|  |     String pid = userId; | ||||||
|  |     if (deviceId != null) { | ||||||
|  |       pid += ':$deviceId'; | ||||||
|  |     } | ||||||
|  |     return pid; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return id; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => | ||||||
|  |       identical(this, other) || | ||||||
|  |       other is CallParticipant && | ||||||
|  |           userId == other.userId && | ||||||
|  |           deviceId == other.deviceId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => userId.hashCode ^ deviceId.hashCode; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,55 @@ | ||||||
|  | import 'dart:typed_data'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | enum E2EEKeyMode { | ||||||
|  |   kNone, | ||||||
|  |   kSharedKey, | ||||||
|  |   kPerParticipant, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | abstract class EncryptionKeyProvider { | ||||||
|  |   Future<void> onSetEncryptionKey( | ||||||
|  |       CallParticipant participant, Uint8List key, int index); | ||||||
|  | 
 | ||||||
|  |   Future<Uint8List> onRatchetKey(CallParticipant participant, int index); | ||||||
|  | 
 | ||||||
|  |   Future<Uint8List> onExportKey(CallParticipant participant, int index); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class EncryptionKeyEntry { | ||||||
|  |   final int index; | ||||||
|  |   final String key; | ||||||
|  |   EncryptionKeyEntry(this.index, this.key); | ||||||
|  | 
 | ||||||
|  |   factory EncryptionKeyEntry.fromJson(Map<String, dynamic> json) => | ||||||
|  |       EncryptionKeyEntry( | ||||||
|  |         json['index'] as int, | ||||||
|  |         json['key'] as String, | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() => { | ||||||
|  |         'index': index, | ||||||
|  |         'key': key, | ||||||
|  |       }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class EncryptionKeysEventContent { | ||||||
|  |   // Get the participant info from todevice message params | ||||||
|  |   final List<EncryptionKeyEntry> keys; | ||||||
|  |   final String callId; | ||||||
|  |   EncryptionKeysEventContent(this.keys, this.callId); | ||||||
|  | 
 | ||||||
|  |   factory EncryptionKeysEventContent.fromJson(Map<String, dynamic> json) => | ||||||
|  |       EncryptionKeysEventContent( | ||||||
|  |           (json['keys'] as List<dynamic>) | ||||||
|  |               .map( | ||||||
|  |                   (e) => EncryptionKeyEntry.fromJson(e as Map<String, dynamic>)) | ||||||
|  |               .toList(), | ||||||
|  |           json['call_id'] as String); | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() => { | ||||||
|  |         'keys': keys.map((e) => e.toJson()).toList(), | ||||||
|  |         'call_id': callId, | ||||||
|  |       }; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | class VoipId { | ||||||
|  |   final String roomId; | ||||||
|  |   final String callId; | ||||||
|  | 
 | ||||||
|  |   String get id => '$roomId:$callId'; | ||||||
|  | 
 | ||||||
|  |   factory VoipId.fromId(String id) { | ||||||
|  |     final int lastIndex = id.lastIndexOf(':'); | ||||||
|  |     return VoipId( | ||||||
|  |       roomId: id.substring(0, lastIndex), | ||||||
|  |       callId: id.substring(lastIndex + 1), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   VoipId({required this.roomId, required this.callId}); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => | ||||||
|  |       identical(this, other) || | ||||||
|  |       other is VoipId && roomId == other.roomId && callId == other.callId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => roomId.hashCode ^ callId.hashCode; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,25 @@ | ||||||
|  | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | /// Delegate WebRTC basic functionality. | ||||||
|  | abstract class WebRTCDelegate { | ||||||
|  |   MediaDevices get mediaDevices; | ||||||
|  |   Future<RTCPeerConnection> createPeerConnection( | ||||||
|  |       Map<String, dynamic> configuration, | ||||||
|  |       [Map<String, dynamic> constraints = const {}]); | ||||||
|  |   Future<void> playRingtone(); | ||||||
|  |   Future<void> stopRingtone(); | ||||||
|  |   Future<void> handleNewCall(CallSession session); | ||||||
|  |   Future<void> handleCallEnded(CallSession session); | ||||||
|  |   Future<void> handleMissedCall(CallSession session); | ||||||
|  |   Future<void> handleNewGroupCall(GroupCallSession groupCall); | ||||||
|  |   Future<void> handleGroupCallEnded(GroupCallSession groupCall); | ||||||
|  |   bool get isWeb; | ||||||
|  | 
 | ||||||
|  |   /// This should be set to false if any calls in the client are in kConnected | ||||||
|  |   /// state. If another room tries to call you during a connected call this fires | ||||||
|  |   /// a handleMissedCall | ||||||
|  |   bool get canHandleNewCall; | ||||||
|  |   EncryptionKeyProvider? get keyProvider; | ||||||
|  | } | ||||||
|  | @ -1,47 +0,0 @@ | ||||||
| import 'dart:async'; |  | ||||||
| 
 |  | ||||||
| import 'package:random_string/random_string.dart'; |  | ||||||
| import 'package:webrtc_interface/webrtc_interface.dart'; |  | ||||||
| 
 |  | ||||||
| import 'package:matrix/matrix.dart'; |  | ||||||
| 
 |  | ||||||
| Future<void> stopMediaStream(MediaStream? stream) async { |  | ||||||
|   if (stream != null) { |  | ||||||
|     for (final track in stream.getTracks()) { |  | ||||||
|       try { |  | ||||||
|         await track.stop(); |  | ||||||
|       } catch (e, s) { |  | ||||||
|         Logs().e('[VOIP] stopping track ${track.id} failed', e, s); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     try { |  | ||||||
|       await stream.dispose(); |  | ||||||
|     } catch (e, s) { |  | ||||||
|       Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| void setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) { |  | ||||||
|   for (final element in tracks) { |  | ||||||
|     element.enabled = enabled; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| Future<bool> hasAudioDevice() async { |  | ||||||
|   //TODO(duan): implement this, check if there is any audio device |  | ||||||
|   return true; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| Future<bool> hasVideoDevice() async { |  | ||||||
|   //TODO(duan): implement this, check if there is any video device |  | ||||||
|   return true; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| String roomAliasFromRoomName(String roomName) { |  | ||||||
|   return roomName.trim().replaceAll('-', '').toLowerCase(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| String genCallID() { |  | ||||||
|   return '${DateTime.now().millisecondsSinceEpoch}${randomAlphaNumeric(16)}'; |  | ||||||
| } |  | ||||||
|  | @ -12,7 +12,7 @@ class ConnectionTester { | ||||||
|   TurnServerCredentials? _turnServerCredentials; |   TurnServerCredentials? _turnServerCredentials; | ||||||
| 
 | 
 | ||||||
|   Future<bool> verifyTurnServer() async { |   Future<bool> verifyTurnServer() async { | ||||||
|     final iceServers = await getIceSevers(); |     final iceServers = await getIceServers(); | ||||||
|     final configuration = <String, dynamic>{ |     final configuration = <String, dynamic>{ | ||||||
|       'iceServers': iceServers, |       'iceServers': iceServers, | ||||||
|       'sdpSemantics': 'unified-plan', |       'sdpSemantics': 'unified-plan', | ||||||
|  | @ -95,7 +95,7 @@ class ConnectionTester { | ||||||
|     return iterations; |     return iterations; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   Future<List<Map<String, dynamic>>> getIceSevers() async { |   Future<List<Map<String, dynamic>>> getIceServers() async { | ||||||
|     if (_turnServerCredentials == null) { |     if (_turnServerCredentials == null) { | ||||||
|       try { |       try { | ||||||
|         _turnServerCredentials = await client.getTurnServer(); |         _turnServerCredentials = await client.getTurnServer(); | ||||||
|  | @ -0,0 +1,167 @@ | ||||||
|  | import 'package:collection/collection.dart'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | 
 | ||||||
|  | extension FamedlyCallMemberEventsExtension on Room { | ||||||
|  |   /// a map of every users famedly call event, holds the memberships list | ||||||
|  |   /// returns sorted according to originTs (oldest to newest) | ||||||
|  |   Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents() { | ||||||
|  |     final Map<String, FamedlyCallMemberEvent> mappedEvents = {}; | ||||||
|  |     final famedlyCallMemberStates = | ||||||
|  |         states.tryGetMap<String, Event>(EventTypes.GroupCallMember); | ||||||
|  | 
 | ||||||
|  |     if (famedlyCallMemberStates == null) return {}; | ||||||
|  |     final sortedEvents = famedlyCallMemberStates.values | ||||||
|  |         .sorted((a, b) => a.originServerTs.compareTo(b.originServerTs)); | ||||||
|  | 
 | ||||||
|  |     for (final element in sortedEvents) { | ||||||
|  |       mappedEvents | ||||||
|  |           .addAll({element.senderId: FamedlyCallMemberEvent.fromJson(element)}); | ||||||
|  |     } | ||||||
|  |     return mappedEvents; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// extracts memberships list form a famedly call event and maps it to a userid | ||||||
|  |   /// returns sorted (oldest to newest) | ||||||
|  |   Map<String, List<CallMembership>> getCallMembershipsFromRoom() { | ||||||
|  |     final parsedMemberEvents = getFamedlyCallEvents(); | ||||||
|  |     final Map<String, List<CallMembership>> memberships = {}; | ||||||
|  |     for (final element in parsedMemberEvents.entries) { | ||||||
|  |       memberships.addAll({element.key: element.value.memberships}); | ||||||
|  |     } | ||||||
|  |     return memberships; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// returns a list of memberships in the room for `user` | ||||||
|  |   List<CallMembership> getCallMembershipsForUser(String userId) { | ||||||
|  |     final parsedMemberEvents = getCallMembershipsFromRoom(); | ||||||
|  |     final mem = parsedMemberEvents.tryGet<List<CallMembership>>(userId); | ||||||
|  |     return mem ?? []; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// returns the user count (not sessions, yet) for the group call with id: `groupCallId`. | ||||||
|  |   /// returns 0 if group call not found | ||||||
|  |   int groupCallParticipantCount(String groupCallId) { | ||||||
|  |     int participantCount = 0; | ||||||
|  |     // userid:membership | ||||||
|  |     final memberships = getCallMembershipsFromRoom(); | ||||||
|  | 
 | ||||||
|  |     memberships.forEach((key, value) { | ||||||
|  |       for (final membership in value) { | ||||||
|  |         if (membership.callId == groupCallId && !membership.isExpired) { | ||||||
|  |           participantCount++; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return participantCount; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   bool get hasActiveGroupCall { | ||||||
|  |     if (activeGroupCallIds.isNotEmpty) { | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// list of active group call ids | ||||||
|  |   List<String> get activeGroupCallIds { | ||||||
|  |     final Set<String> ids = {}; | ||||||
|  |     final memberships = getCallMembershipsFromRoom(); | ||||||
|  | 
 | ||||||
|  |     memberships.forEach((key, value) { | ||||||
|  |       for (final mem in value) { | ||||||
|  |         if (!mem.isExpired) ids.add(mem.callId); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     return ids.toList(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// passing no `CallMembership` removes it from the state event. | ||||||
|  |   Future<void> updateFamedlyCallMemberStateEvent( | ||||||
|  |       CallMembership callMembership) async { | ||||||
|  |     final ownMemberships = getCallMembershipsForUser(client.userID!); | ||||||
|  | 
 | ||||||
|  |     // do not bother removing other deviceId expired events because we have no | ||||||
|  |     // ownership over them | ||||||
|  |     ownMemberships | ||||||
|  |         .removeWhere((element) => client.deviceID! == element.deviceId); | ||||||
|  | 
 | ||||||
|  |     ownMemberships.removeWhere((e) => e == callMembership); | ||||||
|  | 
 | ||||||
|  |     ownMemberships.add(callMembership); | ||||||
|  | 
 | ||||||
|  |     final newContent = { | ||||||
|  |       'memberships': List.from(ownMemberships.map((e) => e.toJson())) | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     await setFamedlyCallMemberEvent(newContent); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> removeFamedlyCallMemberEvent( | ||||||
|  |     String groupCallId, | ||||||
|  |     String deviceId, { | ||||||
|  |     String? application = 'm.call', | ||||||
|  |     String? scope = 'm.room', | ||||||
|  |   }) async { | ||||||
|  |     final ownMemberships = getCallMembershipsForUser(client.userID!); | ||||||
|  | 
 | ||||||
|  |     ownMemberships.removeWhere((mem) => | ||||||
|  |         mem.callId == groupCallId && | ||||||
|  |         mem.deviceId == deviceId && | ||||||
|  |         mem.application == application && | ||||||
|  |         mem.scope == scope); | ||||||
|  | 
 | ||||||
|  |     final newContent = { | ||||||
|  |       'memberships': List.from(ownMemberships.map((e) => e.toJson())) | ||||||
|  |     }; | ||||||
|  |     await setFamedlyCallMemberEvent(newContent); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> setFamedlyCallMemberEvent(Map<String, List> newContent) async { | ||||||
|  |     if (groupCallsEnabledForEveryone) { | ||||||
|  |       await client.setRoomStateWithKey( | ||||||
|  |         id, | ||||||
|  |         EventTypes.GroupCallMember, | ||||||
|  |         client.userID!, | ||||||
|  |         newContent, | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       Logs().w( | ||||||
|  |           '[VOIP] cannot send ${EventTypes.GroupCallMember} events in room: $id, fix your PLs'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// returns a list of memberships from a famedly call matrix event | ||||||
|  |   List<CallMembership> getCallMembershipsFromEvent(MatrixEvent event) { | ||||||
|  |     if (event.roomId != id) return []; | ||||||
|  |     return getCallMembershipsFromEventContent( | ||||||
|  |         event.content, event.senderId, event.roomId!); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// returns a list of memberships from a famedly call matrix event | ||||||
|  |   List<CallMembership> getCallMembershipsFromEventContent( | ||||||
|  |       Map<String, Object?> content, String senderId, String roomId) { | ||||||
|  |     final mems = content.tryGetList<Map>('memberships'); | ||||||
|  |     final callMems = <CallMembership>[]; | ||||||
|  |     for (final m in mems ?? []) { | ||||||
|  |       final mem = CallMembership.fromJson(m, senderId, roomId); | ||||||
|  |       if (mem != null) callMems.add(mem); | ||||||
|  |     } | ||||||
|  |     return callMems; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | bool isValidMemEvent(Map<String, Object?> event) { | ||||||
|  |   if (event['call_id'] is String && | ||||||
|  |       event['device_id'] is String && | ||||||
|  |       event['expires_ts'] is num && | ||||||
|  |       event['foci_active'] is List) { | ||||||
|  |     return true; | ||||||
|  |   } else { | ||||||
|  |     Logs() | ||||||
|  |         .w('[VOIP] FamedlyCallMemberEvent ignoring unclean membership $event'); | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
|  | 
 | ||||||
|  | extension RTCIceCandidateExt on RTCIceCandidate { | ||||||
|  |   bool get isValid => | ||||||
|  |       sdpMLineIndex != null && | ||||||
|  |       sdpMid != null && | ||||||
|  |       candidate != null && | ||||||
|  |       candidate!.isNotEmpty; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,65 @@ | ||||||
|  | import 'package:collection/collection.dart'; | ||||||
|  | import 'package:random_string/random_string.dart'; | ||||||
|  | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | Future<void> stopMediaStream(MediaStream? stream) async { | ||||||
|  |   if (stream != null) { | ||||||
|  |     for (final track in stream.getTracks()) { | ||||||
|  |       try { | ||||||
|  |         await track.stop(); | ||||||
|  |       } catch (e, s) { | ||||||
|  |         Logs().e('[VOIP] stopping track ${track.id} failed', e, s); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       await stream.dispose(); | ||||||
|  |     } catch (e, s) { | ||||||
|  |       Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | void setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) { | ||||||
|  |   for (final element in tracks) { | ||||||
|  |     element.enabled = enabled; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | Future<bool> hasMediaDevice( | ||||||
|  |     WebRTCDelegate delegate, MediaInputKind mediaInputKind) async { | ||||||
|  |   final devices = await delegate.mediaDevices.enumerateDevices(); | ||||||
|  |   return devices | ||||||
|  |       .where((device) => device.kind == mediaInputKind.name) | ||||||
|  |       .isNotEmpty; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | Future<void> updateMediaDevice( | ||||||
|  |   WebRTCDelegate delegate, | ||||||
|  |   MediaKind kind, | ||||||
|  |   List<RTCRtpSender> userRtpSenders, [ | ||||||
|  |   MediaStreamTrack? track, | ||||||
|  | ]) async { | ||||||
|  |   final sender = userRtpSenders | ||||||
|  |       .firstWhereOrNull((element) => element.track!.kind == kind.name); | ||||||
|  |   await sender?.track?.stop(); | ||||||
|  |   if (track != null) { | ||||||
|  |     await sender?.replaceTrack(track); | ||||||
|  |   } else { | ||||||
|  |     final stream = await delegate.mediaDevices.getUserMedia({kind.name: true}); | ||||||
|  |     MediaStreamTrack? track; | ||||||
|  |     if (kind == MediaKind.audio) { | ||||||
|  |       track = stream.getAudioTracks().firstOrNull; | ||||||
|  |     } else if (kind == MediaKind.video) { | ||||||
|  |       track = stream.getVideoTracks().firstOrNull; | ||||||
|  |     } | ||||||
|  |     if (track != null) { | ||||||
|  |       await sender?.replaceTrack(track); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | String genCallID() { | ||||||
|  |   return '${DateTime.now().millisecondsSinceEpoch}${randomAlphaNumeric(16)}'; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,187 @@ | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | 
 | ||||||
|  | enum EncryptionKeyTypes { remote, local } | ||||||
|  | 
 | ||||||
|  | // Call state | ||||||
|  | enum CallState { | ||||||
|  |   /// The call is inilalized but not yet started | ||||||
|  |   kFledgling, | ||||||
|  | 
 | ||||||
|  |   /// The first time an invite is sent, the local has createdOffer | ||||||
|  |   kInviteSent, | ||||||
|  | 
 | ||||||
|  |   /// getUserMedia or getDisplayMedia has been called, | ||||||
|  |   /// but MediaStream has not yet been returned | ||||||
|  |   kWaitLocalMedia, | ||||||
|  | 
 | ||||||
|  |   /// The local has createdOffer | ||||||
|  |   kCreateOffer, | ||||||
|  | 
 | ||||||
|  |   /// Received a remote offer message and created a local Answer | ||||||
|  |   kCreateAnswer, | ||||||
|  | 
 | ||||||
|  |   /// Answer sdp is set, but ice is not connected | ||||||
|  |   kConnecting, | ||||||
|  | 
 | ||||||
|  |   /// WebRTC media stream is connected | ||||||
|  |   kConnected, | ||||||
|  | 
 | ||||||
|  |   /// The call was received, but no processing has been done yet. | ||||||
|  |   kRinging, | ||||||
|  | 
 | ||||||
|  |   /// Ending a call | ||||||
|  |   kEnding, | ||||||
|  | 
 | ||||||
|  |   /// End of call | ||||||
|  |   kEnded, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | enum CallErrorCode { | ||||||
|  |   /// The user chose to end the call | ||||||
|  |   userHangup('user_hangup'), | ||||||
|  | 
 | ||||||
|  |   /// An error code when the local client failed to create an offer. | ||||||
|  |   localOfferFailed('local_offer_failed'), | ||||||
|  | 
 | ||||||
|  |   /// An error code when there is no local mic/camera to use. This may be because | ||||||
|  |   /// the hardware isn't plugged in, or the user has explicitly denied access. | ||||||
|  |   userMediaFailed('user_media_failed'), | ||||||
|  | 
 | ||||||
|  |   /// Error code used when a call event failed to send | ||||||
|  |   /// because unknown devices were present in the room | ||||||
|  |   unknownDevice('unknown_device'), | ||||||
|  | 
 | ||||||
|  |   /// An answer could not be created | ||||||
|  |   createAnswer('create_answer'), | ||||||
|  | 
 | ||||||
|  |   /// The session description from the other side could not be set | ||||||
|  | 
 | ||||||
|  |   setRemoteDescription('set_remote_description'), | ||||||
|  | 
 | ||||||
|  |   /// The session description from this side could not be set | ||||||
|  |   setLocalDescription('set_local_description'), | ||||||
|  | 
 | ||||||
|  |   /// A different device answered the call | ||||||
|  |   answeredElsewhere('answered_elsewhere'), | ||||||
|  | 
 | ||||||
|  |   /// No media connection could be established to the other party | ||||||
|  |   iceFailed('ice_failed'), | ||||||
|  | 
 | ||||||
|  |   /// The invite timed out whilst waiting for an answer | ||||||
|  |   inviteTimeout('invite_timeout'), | ||||||
|  | 
 | ||||||
|  |   /// The call was replaced by another call | ||||||
|  |   replaced('replaced'), | ||||||
|  | 
 | ||||||
|  |   /// Signalling for the call could not be sent (other than the initial invite) | ||||||
|  |   iceTimeout('ice_timeout'), | ||||||
|  | 
 | ||||||
|  |   /// The remote party is busy | ||||||
|  |   userBusy('user_busy'), | ||||||
|  | 
 | ||||||
|  |   /// We transferred the call off to somewhere else | ||||||
|  |   transferred('transferred'), | ||||||
|  | 
 | ||||||
|  |   /// Some other failure occurred that meant the client was unable to continue | ||||||
|  |   /// the call rather than the user choosing to end it. | ||||||
|  |   unknownError('unknown_error'); | ||||||
|  | 
 | ||||||
|  |   final String reason; | ||||||
|  | 
 | ||||||
|  |   const CallErrorCode(this.reason); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class CallError extends Error { | ||||||
|  |   final CallErrorCode code; | ||||||
|  |   final String msg; | ||||||
|  |   final dynamic err; | ||||||
|  |   CallError(this.code, this.msg, this.err); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return '[$code] $msg, err: ${err.toString()}'; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | enum CallStateChange { | ||||||
|  |   /// The call was hangup by the local|remote user. | ||||||
|  |   kHangup, | ||||||
|  | 
 | ||||||
|  |   /// The call state has changed | ||||||
|  |   kState, | ||||||
|  | 
 | ||||||
|  |   /// The call got some error. | ||||||
|  |   kError, | ||||||
|  | 
 | ||||||
|  |   /// Call transfer | ||||||
|  |   kReplaced, | ||||||
|  | 
 | ||||||
|  |   /// The value of isLocalOnHold() has changed | ||||||
|  |   kLocalHoldUnhold, | ||||||
|  | 
 | ||||||
|  |   /// The value of isRemoteOnHold() has changed | ||||||
|  |   kRemoteHoldUnhold, | ||||||
|  | 
 | ||||||
|  |   /// Feeds have changed | ||||||
|  |   kFeedsChanged, | ||||||
|  | 
 | ||||||
|  |   /// For sip calls. support in the future. | ||||||
|  |   kAssertedIdentityChanged, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | enum CallType { kVoice, kVideo } | ||||||
|  | 
 | ||||||
|  | enum CallDirection { kIncoming, kOutgoing } | ||||||
|  | 
 | ||||||
|  | enum CallParty { kLocal, kRemote } | ||||||
|  | 
 | ||||||
|  | enum MediaInputKind { videoinput, audioinput } | ||||||
|  | 
 | ||||||
|  | enum MediaKind { video, audio } | ||||||
|  | 
 | ||||||
|  | enum GroupCallErrorCode { | ||||||
|  |   /// An error code when there is no local mic/camera to use. This may be because | ||||||
|  |   /// the hardware isn't plugged in, or the user has explicitly denied access. | ||||||
|  |   userMediaFailed('user_media_failed'), | ||||||
|  | 
 | ||||||
|  |   /// Some other failure occurred that meant the client was unable to continue | ||||||
|  |   /// the call rather than the user choosing to end it. | ||||||
|  |   unknownError('unknownError'); | ||||||
|  | 
 | ||||||
|  |   final String reason; | ||||||
|  | 
 | ||||||
|  |   const GroupCallErrorCode(this.reason); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class GroupCallError extends Error { | ||||||
|  |   final GroupCallErrorCode code; | ||||||
|  |   final String msg; | ||||||
|  |   final dynamic err; | ||||||
|  |   GroupCallError(this.code, this.msg, this.err); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return 'Group Call Error: [$code] $msg, err: ${err.toString()}'; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | enum GroupCallStateChange { | ||||||
|  |   groupCallStateChanged, | ||||||
|  |   activeSpeakerChanged, | ||||||
|  |   callsChanged, | ||||||
|  |   userMediaStreamsChanged, | ||||||
|  |   screenshareStreamsChanged, | ||||||
|  |   localScreenshareStateChanged, | ||||||
|  |   localMuteStateChanged, | ||||||
|  |   participantsChanged, | ||||||
|  |   error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | enum GroupCallState { | ||||||
|  |   localCallFeedUninitialized, | ||||||
|  |   initializingLocalCallFeed, | ||||||
|  |   localCallFeedInitialized, | ||||||
|  |   entering, | ||||||
|  |   entered, | ||||||
|  |   ended | ||||||
|  | } | ||||||
|  | @ -0,0 +1,62 @@ | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | 
 | ||||||
|  | /// https://github.com/matrix-org/matrix-doc/pull/2746 | ||||||
|  | /// version 1 | ||||||
|  | const String voipProtoVersion = '1'; | ||||||
|  | 
 | ||||||
|  | class CallTimeouts { | ||||||
|  |   /// The default life time for call events, in millisecond. | ||||||
|  |   static const defaultCallEventLifetime = Duration(seconds: 10); | ||||||
|  | 
 | ||||||
|  |   /// The length of time a call can be ringing for. | ||||||
|  |   static const callInviteLifetime = Duration(seconds: 60); | ||||||
|  | 
 | ||||||
|  |   /// The delay for ice gathering. | ||||||
|  |   static const iceGatheringDelay = Duration(milliseconds: 200); | ||||||
|  | 
 | ||||||
|  |   /// Delay before createOffer. | ||||||
|  |   static const delayBeforeOffer = Duration(milliseconds: 100); | ||||||
|  | 
 | ||||||
|  |   /// How often to update the expiresTs | ||||||
|  |   static const updateExpireTsTimerDuration = Duration(minutes: 2); | ||||||
|  | 
 | ||||||
|  |   /// the expiresTs bump | ||||||
|  |   static const expireTsBumpDuration = Duration(minutes: 6); | ||||||
|  | 
 | ||||||
|  |   /// Update the active speaker value | ||||||
|  |   static const activeSpeakerInterval = Duration(seconds: 5); | ||||||
|  | 
 | ||||||
|  |   // source: element call? | ||||||
|  |   /// A delay after a member leaves before we create and publish a new key, because people | ||||||
|  |   /// tend to leave calls at the same time | ||||||
|  |   static const makeKeyDelay = Duration(seconds: 2); | ||||||
|  | 
 | ||||||
|  |   /// The delay between creating and sending a new key and starting to encrypt with it. This gives others | ||||||
|  |   /// a chance to receive the new key to minimise the chance they don't get media they can't decrypt. | ||||||
|  |   /// The total time between a member leaving and the call switching to new keys is therefore | ||||||
|  |   /// makeKeyDelay + useKeyDelay | ||||||
|  |   static const useKeyDelay = Duration(seconds: 4); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class CallConstants { | ||||||
|  |   static final callEventsRegxp = RegExp( | ||||||
|  |       r'm.call.|org.matrix.call.|org.matrix.msc3401.call.|com.famedly.call.'); | ||||||
|  | 
 | ||||||
|  |   static const callEndedEventTypes = { | ||||||
|  |     EventTypes.CallAnswer, | ||||||
|  |     EventTypes.CallHangup, | ||||||
|  |     EventTypes.CallReject, | ||||||
|  |     EventTypes.CallReplaces, | ||||||
|  |   }; | ||||||
|  |   static const omitWhenCallEndedTypes = { | ||||||
|  |     EventTypes.CallInvite, | ||||||
|  |     EventTypes.CallCandidates, | ||||||
|  |     EventTypes.CallNegotiate, | ||||||
|  |     EventTypes.CallSDPStreamMetadataChanged, | ||||||
|  |     EventTypes.CallSDPStreamMetadataChangedPrefix, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   static const updateExpireTsTimerDuration = Duration(seconds: 15); | ||||||
|  |   static const expireTsBumpDuration = Duration(seconds: 45); | ||||||
|  |   static const activeSpeakerInterval = Duration(seconds: 5); | ||||||
|  | } | ||||||
|  | @ -0,0 +1,100 @@ | ||||||
|  | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
|  | 
 | ||||||
|  | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/utils/cached_stream_controller.dart'; | ||||||
|  | import 'package:matrix/src/voip/utils/stream_helper.dart'; | ||||||
|  | 
 | ||||||
|  | /// Wrapped MediaStream, used to adapt Widget to display | ||||||
|  | class WrappedMediaStream { | ||||||
|  |   MediaStream? stream; | ||||||
|  |   final CallParticipant participant; | ||||||
|  |   final Room room; | ||||||
|  |   final VoIP voip; | ||||||
|  | 
 | ||||||
|  |   /// Current stream type, usermedia or screen-sharing | ||||||
|  |   String purpose; | ||||||
|  |   bool audioMuted; | ||||||
|  |   bool videoMuted; | ||||||
|  |   final Client client; | ||||||
|  |   final bool isGroupCall; | ||||||
|  |   final RTCPeerConnection? pc; | ||||||
|  | 
 | ||||||
|  |   /// for debug | ||||||
|  |   String get title => | ||||||
|  |       '${client.userID!}:${client.deviceID!} $displayName:$purpose:a[$audioMuted]:v[$videoMuted]'; | ||||||
|  |   bool stopped = false; | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<WrappedMediaStream> onMuteStateChanged = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   final CachedStreamController<MediaStream> onStreamChanged = | ||||||
|  |       CachedStreamController(); | ||||||
|  | 
 | ||||||
|  |   WrappedMediaStream({ | ||||||
|  |     this.stream, | ||||||
|  |     this.pc, | ||||||
|  |     required this.room, | ||||||
|  |     required this.participant, | ||||||
|  |     required this.purpose, | ||||||
|  |     required this.client, | ||||||
|  |     required this.audioMuted, | ||||||
|  |     required this.videoMuted, | ||||||
|  |     required this.isGroupCall, | ||||||
|  |     required this.voip, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String get id => '${stream?.id}: $title'; | ||||||
|  | 
 | ||||||
|  |   Future<void> dispose() async { | ||||||
|  |     // AOT it | ||||||
|  |     const isWeb = bool.fromEnvironment('dart.library.js_util'); | ||||||
|  | 
 | ||||||
|  |     // libwebrtc does not provide a way to clone MediaStreams. So stopping the | ||||||
|  |     // local stream here would break calls with all other participants if anyone | ||||||
|  |     // leaves. The local stream is manually disposed when user leaves. On web | ||||||
|  |     // streams are actually cloned. | ||||||
|  |     if (!isGroupCall || isWeb) { | ||||||
|  |       await stopMediaStream(stream); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     stream = null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Uri? get avatarUrl => getUser().avatarUrl; | ||||||
|  | 
 | ||||||
|  |   String get avatarName => | ||||||
|  |       getUser().calcDisplayname(mxidLocalPartFallback: false); | ||||||
|  | 
 | ||||||
|  |   String? get displayName => getUser().displayName; | ||||||
|  | 
 | ||||||
|  |   User getUser() { | ||||||
|  |     return room.unsafeGetUserFromMemoryOrFallback(participant.userId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   bool isLocal() { | ||||||
|  |     return participant == voip.localParticipant; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   bool isAudioMuted() { | ||||||
|  |     return (stream != null && stream!.getAudioTracks().isEmpty) || audioMuted; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   bool isVideoMuted() { | ||||||
|  |     return (stream != null && stream!.getVideoTracks().isEmpty) || videoMuted; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void setNewStream(MediaStream newStream) { | ||||||
|  |     stream = newStream; | ||||||
|  |     onStreamChanged.add(stream!); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void setAudioMuted(bool muted) { | ||||||
|  |     audioMuted = muted; | ||||||
|  |     onMuteStateChanged.add(this); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void setVideoMuted(bool muted) { | ||||||
|  |     videoMuted = muted; | ||||||
|  |     onMuteStateChanged.add(this); | ||||||
|  |   } | ||||||
|  | } | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -1,101 +0,0 @@ | ||||||
| import 'package:collection/collection.dart'; |  | ||||||
| 
 |  | ||||||
| import 'package:matrix/matrix.dart'; |  | ||||||
| 
 |  | ||||||
| extension GroupCallUtils on Room { |  | ||||||
|   /// returns the user count (not sessions, yet) for the group call with id: `groupCallId`. |  | ||||||
|   /// returns 0 if group call not found |  | ||||||
|   int? groupCallParticipantCount(String groupCallId) { |  | ||||||
|     int participantCount = 0; |  | ||||||
|     final groupCallMemberStates = |  | ||||||
|         states.tryGetMap<String, Event>(EventTypes.GroupCallMemberPrefix); |  | ||||||
|     if (groupCallMemberStates != null) { |  | ||||||
|       groupCallMemberStates.forEach((userId, memberStateEvent) { |  | ||||||
|         if (!callMemberStateForIdIsExpired(memberStateEvent, groupCallId)) { |  | ||||||
|           participantCount++; |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|     return participantCount; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   bool get hasActiveGroupCall { |  | ||||||
|     if (activeGroupCallEvents.isNotEmpty) { |  | ||||||
|       return true; |  | ||||||
|     } |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /// list of active group calls |  | ||||||
|   List<Event> get activeGroupCallEvents { |  | ||||||
|     final groupCallStates = |  | ||||||
|         states.tryGetMap<String, Event>(EventTypes.GroupCallPrefix); |  | ||||||
|     if (groupCallStates != null) { |  | ||||||
|       groupCallStates.values |  | ||||||
|           .toList() |  | ||||||
|           .sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); |  | ||||||
|       return groupCallStates.values |  | ||||||
|           .where((element) => |  | ||||||
|               !element.content.containsKey('m.terminated') && |  | ||||||
|               callMemberStateIsExpired(element)) |  | ||||||
|           .toList(); |  | ||||||
|     } |  | ||||||
|     return []; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   static const staleCallCheckerDuration = Duration(seconds: 30); |  | ||||||
| 
 |  | ||||||
|   /// checks if a member event has any existing non-expired callId |  | ||||||
|   bool callMemberStateIsExpired(MatrixEvent event) { |  | ||||||
|     final callMemberState = IGroupCallRoomMemberState.fromJson(event); |  | ||||||
|     final calls = callMemberState.calls; |  | ||||||
|     return calls |  | ||||||
|         .where((call) => call.devices.any((d) => |  | ||||||
|             (d.expires_ts ?? 0) + |  | ||||||
|                 staleCallCheckerDuration |  | ||||||
|                     .inMilliseconds > // buffer for sync glare |  | ||||||
|             DateTime.now().millisecondsSinceEpoch)) |  | ||||||
|         .isEmpty; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /// checks if the member event has `groupCallId` unexpired, if not it checks if |  | ||||||
|   /// the whole event is expired or not |  | ||||||
|   bool callMemberStateForIdIsExpired( |  | ||||||
|       MatrixEvent groupCallMemberStateEvent, String groupCallId) { |  | ||||||
|     final callMemberState = |  | ||||||
|         IGroupCallRoomMemberState.fromJson(groupCallMemberStateEvent); |  | ||||||
|     final calls = callMemberState.calls; |  | ||||||
|     if (calls.isNotEmpty) { |  | ||||||
|       final call = |  | ||||||
|           calls.singleWhereOrNull((call) => call.call_id == groupCallId); |  | ||||||
|       if (call != null) { |  | ||||||
|         return call.devices.where((device) => device.expires_ts != null).every( |  | ||||||
|             (device) => |  | ||||||
|                 (device.expires_ts ?? 0) + |  | ||||||
|                     staleCallCheckerDuration |  | ||||||
|                         .inMilliseconds < // buffer for sync glare |  | ||||||
|                 DateTime.now().millisecondsSinceEpoch); |  | ||||||
|       } else { |  | ||||||
|         Logs().d( |  | ||||||
|             '[VOIP] Did not find $groupCallId in member events, probably sync glare'); |  | ||||||
|         return false; |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       // Last 30 seconds to get yourself together. |  | ||||||
|       // This saves us from accidentally killing calls which were just created and |  | ||||||
|       // whose state event we haven't recieved yet in sync. |  | ||||||
|       // (option 2 was local echo member state events, but reverting them if anything |  | ||||||
|       // fails sounds pain) |  | ||||||
|       final expiredfr = groupCallMemberStateEvent.originServerTs |  | ||||||
|               .add(staleCallCheckerDuration) |  | ||||||
|               .millisecondsSinceEpoch < |  | ||||||
|           DateTime.now().millisecondsSinceEpoch; |  | ||||||
| 
 |  | ||||||
|       if (!expiredfr) { |  | ||||||
|         Logs().d( |  | ||||||
|             '[VOIP] Last 30 seconds for state event from ${groupCallMemberStateEvent.senderId}'); |  | ||||||
|       } |  | ||||||
|       return expiredfr; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  | @ -32,7 +32,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 | ||||||
|   webrtc_interface: ^1.0.13 |   webrtc_interface: ^1.2.0 | ||||||
| 
 | 
 | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   build_runner: ^2.4.8 |   build_runner: ^2.4.8 | ||||||
|  | @ -43,7 +43,4 @@ dev_dependencies: | ||||||
|   lints: ^3.0.0 |   lints: ^3.0.0 | ||||||
|   sqflite_common_ffi: 2.3.2+1 # v2.3.3 doesn't support dart v3.2.x |   sqflite_common_ffi: 2.3.2+1 # v2.3.3 doesn't support dart v3.2.x | ||||||
|   test: ^1.15.7 |   test: ^1.15.7 | ||||||
|   #flutter_test: {sdk: flutter} | 
 | ||||||
| #dependency_overrides: |  | ||||||
| #  matrix_api_lite: |  | ||||||
| #    path: ../matrix_api_lite |  | ||||||
|  |  | ||||||
|  | @ -2,6 +2,9 @@ import 'package:test/test.dart'; | ||||||
| import 'package:webrtc_interface/webrtc_interface.dart'; | import 'package:webrtc_interface/webrtc_interface.dart'; | ||||||
| 
 | 
 | ||||||
| import 'package:matrix/matrix.dart'; | import 'package:matrix/matrix.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_membership.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/call_options.dart'; | ||||||
|  | import 'package:matrix/src/voip/models/voip_id.dart'; | ||||||
| import 'fake_client.dart'; | import 'fake_client.dart'; | ||||||
| import 'webrtc_stub.dart'; | import 'webrtc_stub.dart'; | ||||||
| 
 | 
 | ||||||
|  | @ -13,25 +16,35 @@ void main() { | ||||||
|     Logs().level = Level.info; |     Logs().level = Level.info; | ||||||
|     setUp(() async { |     setUp(() async { | ||||||
|       matrix = await getClient(); |       matrix = await getClient(); | ||||||
|  | 
 | ||||||
|       voip = VoIP(matrix, MockWebRTCDelegate()); |       voip = VoIP(matrix, MockWebRTCDelegate()); | ||||||
|       VoIP.customTxid = '1234'; |       VoIP.customTxid = '1234'; | ||||||
|       final id = '!calls:example.com'; |       final id = '!calls:example.com'; | ||||||
| 
 |  | ||||||
|       room = matrix.getRoomById(id)!; |       room = matrix.getRoomById(id)!; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('Test call methods', () async { |     test('Test call methods', () async { | ||||||
|       final call = CallSession(CallOptions()..room = room); |       final call = CallSession( | ||||||
|       await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp', |         CallOptions( | ||||||
|  |           callId: '1234', | ||||||
|  |           type: CallType.kVoice, | ||||||
|  |           dir: CallDirection.kOutgoing, | ||||||
|  |           localPartyId: '4567', | ||||||
|  |           voip: voip, | ||||||
|  |           room: room, | ||||||
|  |           iceServers: [], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       await call.sendInviteToCall(room, '1234', 1234, '4567', 'sdp', | ||||||
|           txid: '1234'); |           txid: '1234'); | ||||||
|       await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); |       await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); | ||||||
|       await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); |       await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); | ||||||
|       await call.sendSelectCallAnswer(room, '1234', '4567', '6789', |       await call.sendSelectCallAnswer(room, '1234', '4567', '6789', | ||||||
|           txid: '1234'); |           txid: '1234'); | ||||||
|       await call.sendCallReject(room, '1234', '4567', 'busy', txid: '1234'); |       await call.sendCallReject(room, '1234', '4567', txid: '1234'); | ||||||
|       await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', |       await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', | ||||||
|           txid: '1234'); |           txid: '1234'); | ||||||
|       await call.sendHangupCall(room, '1234', '4567', 'user_hangup', |       await call.sendHangupCall(room, '1234', '4567', 'userHangup', | ||||||
|           txid: '1234'); |           txid: '1234'); | ||||||
|       await call.sendAssertedIdentity( |       await call.sendAssertedIdentity( | ||||||
|           room, |           room, | ||||||
|  | @ -152,12 +165,14 @@ void main() { | ||||||
|               ) |               ) | ||||||
|             ])) |             ])) | ||||||
|           }))); |           }))); | ||||||
|       while (voip.currentCID != 'originTsValidCall') { |       while (voip.currentCID != | ||||||
|  |           VoipId(roomId: room.id, callId: 'originTsValidCall')) { | ||||||
|         // call invite looks valid, call should be created now :D |         // call invite looks valid, call should be created now :D | ||||||
|         await Future.delayed(Duration(milliseconds: 50)); |         await Future.delayed(Duration(milliseconds: 50)); | ||||||
|         Logs().d('Waiting for currentCID to update'); |         Logs().d('Waiting for currentCID to update'); | ||||||
|       } |       } | ||||||
|       expect(voip.currentCID, 'originTsValidCall'); |       expect(voip.currentCID, | ||||||
|  |           VoipId(roomId: room.id, callId: 'originTsValidCall')); | ||||||
|       final call = voip.calls[voip.currentCID]!; |       final call = voip.calls[voip.currentCID]!; | ||||||
|       expect(call.state, CallState.kRinging); |       expect(call.state, CallState.kRinging); | ||||||
|       await call.answer(txid: '1234'); |       await call.answer(txid: '1234'); | ||||||
|  | @ -219,7 +234,7 @@ void main() { | ||||||
| 
 | 
 | ||||||
|       expect(call.state, CallState.kConnected); |       expect(call.state, CallState.kConnected); | ||||||
| 
 | 
 | ||||||
|       await call.hangup(); |       await call.hangup(reason: CallErrorCode.userHangup); | ||||||
|       expect(call.state, CallState.kEnded); |       expect(call.state, CallState.kEnded); | ||||||
|       expect(voip.currentCID, null); |       expect(voip.currentCID, null); | ||||||
|     }); |     }); | ||||||
|  | @ -277,12 +292,14 @@ void main() { | ||||||
|               ) |               ) | ||||||
|             ])) |             ])) | ||||||
|           }))); |           }))); | ||||||
|       while (voip.currentCID != 'answer_elseWhere') { |       while (voip.currentCID != | ||||||
|  |           VoipId(roomId: room.id, callId: 'answer_elseWhere')) { | ||||||
|         // call invite looks valid, call should be created now :D |         // call invite looks valid, call should be created now :D | ||||||
|         await Future.delayed(Duration(milliseconds: 50)); |         await Future.delayed(Duration(milliseconds: 50)); | ||||||
|         Logs().d('Waiting for currentCID to update'); |         Logs().d('Waiting for currentCID to update'); | ||||||
|       } |       } | ||||||
|       expect(voip.currentCID, 'answer_elseWhere'); |       expect( | ||||||
|  |           voip.currentCID, VoipId(roomId: room.id, callId: 'answer_elseWhere')); | ||||||
|       final call = voip.calls[voip.currentCID]!; |       final call = voip.calls[voip.currentCID]!; | ||||||
|       expect(call.state, CallState.kRinging); |       expect(call.state, CallState.kRinging); | ||||||
| 
 | 
 | ||||||
|  | @ -368,12 +385,13 @@ void main() { | ||||||
|               ) |               ) | ||||||
|             ])) |             ])) | ||||||
|           }))); |           }))); | ||||||
|       while (voip.currentCID != 'reject_call') { |       while ( | ||||||
|  |           voip.currentCID != VoipId(roomId: room.id, callId: 'reject_call')) { | ||||||
|         // call invite looks valid, call should be created now :D |         // call invite looks valid, call should be created now :D | ||||||
|         await Future.delayed(Duration(milliseconds: 50)); |         await Future.delayed(Duration(milliseconds: 50)); | ||||||
|         Logs().d('Waiting for currentCID to update'); |         Logs().d('Waiting for currentCID to update'); | ||||||
|       } |       } | ||||||
|       expect(voip.currentCID, 'reject_call'); |       expect(voip.currentCID, VoipId(roomId: room.id, callId: 'reject_call')); | ||||||
|       final call = voip.calls[voip.currentCID]!; |       final call = voip.calls[voip.currentCID]!; | ||||||
|       expect(call.state, CallState.kRinging); |       expect(call.state, CallState.kRinging); | ||||||
| 
 | 
 | ||||||
|  | @ -386,7 +404,11 @@ void main() { | ||||||
| 
 | 
 | ||||||
|     test('Glare after invite was sent', () async { |     test('Glare after invite was sent', () async { | ||||||
|       expect(voip.currentCID, null); |       expect(voip.currentCID, null); | ||||||
|       final firstCall = await voip.inviteToCall(room.id, CallType.kVoice); |       final firstCall = await voip.inviteToCall( | ||||||
|  |         room, | ||||||
|  |         CallType.kVoice, | ||||||
|  |         userId: '@alice:testing.com', | ||||||
|  |       ); | ||||||
|       await firstCall.pc!.onRenegotiationNeeded!.call(); |       await firstCall.pc!.onRenegotiationNeeded!.call(); | ||||||
|       expect(firstCall.state, CallState.kInviteSent); |       expect(firstCall.state, CallState.kInviteSent); | ||||||
|       // KABOOM YOU JUST GLARED |       // KABOOM YOU JUST GLARED | ||||||
|  | @ -412,12 +434,17 @@ void main() { | ||||||
|             ])) |             ])) | ||||||
|           }))); |           }))); | ||||||
|       await Future.delayed(Duration(seconds: 3)); |       await Future.delayed(Duration(seconds: 3)); | ||||||
|       expect(voip.currentCID, firstCall.callId); |       expect( | ||||||
|       await firstCall.hangup(); |           voip.currentCID, VoipId(roomId: room.id, callId: firstCall.callId)); | ||||||
|  |       await firstCall.hangup(reason: CallErrorCode.userBusy); | ||||||
|     }); |     }); | ||||||
|     test('Glare before invite was sent', () async { |     test('Glare before invite was sent', () async { | ||||||
|       expect(voip.currentCID, null); |       expect(voip.currentCID, null); | ||||||
|       final firstCall = await voip.inviteToCall(room.id, CallType.kVoice); |       final firstCall = await voip.inviteToCall( | ||||||
|  |         room, | ||||||
|  |         CallType.kVoice, | ||||||
|  |         userId: '@alice:testing.com', | ||||||
|  |       ); | ||||||
|       expect(firstCall.state, CallState.kCreateOffer); |       expect(firstCall.state, CallState.kCreateOffer); | ||||||
|       // KABOOM YOU JUST GLARED, but this tiem you were still preparing your call |       // KABOOM YOU JUST GLARED, but this tiem you were still preparing your call | ||||||
|       // so just cancel that instead |       // so just cancel that instead | ||||||
|  | @ -443,7 +470,306 @@ void main() { | ||||||
|             ])) |             ])) | ||||||
|           }))); |           }))); | ||||||
|       await Future.delayed(Duration(seconds: 3)); |       await Future.delayed(Duration(seconds: 3)); | ||||||
|       expect(voip.currentCID, 'zzzz_glare_2nd_call'); |       expect(voip.currentCID, | ||||||
|  |           VoipId(roomId: room.id, callId: 'zzzz_glare_2nd_call')); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('getFamedlyCallEvents sort order', () { | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           content: { | ||||||
|  |             'memberships': [ | ||||||
|  |               CallMembership( | ||||||
|  |                 userId: '@test1:example.com', | ||||||
|  |                 callId: '1111', | ||||||
|  |                 backend: MeshBackend(), | ||||||
|  |                 deviceId: '1111', | ||||||
|  |                 expiresTs: DateTime.now() | ||||||
|  |                     .add(Duration(hours: 12)) | ||||||
|  |                     .millisecondsSinceEpoch, | ||||||
|  |                 roomId: room.id, | ||||||
|  |                 membershipId: voip.currentSessionId, | ||||||
|  |               ).toJson(), | ||||||
|  |             ] | ||||||
|  |           }, | ||||||
|  |           type: EventTypes.GroupCallMember, | ||||||
|  |           eventId: 'asdfasdf', | ||||||
|  |           senderId: '@test1:example.com', | ||||||
|  |           originServerTs: DateTime.now().add(Duration(hours: 12)), | ||||||
|  |           room: room, | ||||||
|  |           stateKey: '@test1:example.com', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           content: { | ||||||
|  |             'memberships': [ | ||||||
|  |               CallMembership( | ||||||
|  |                 userId: '@test2:example.com', | ||||||
|  |                 callId: '1111', | ||||||
|  |                 backend: MeshBackend(), | ||||||
|  |                 deviceId: '1111', | ||||||
|  |                 expiresTs: DateTime.now().millisecondsSinceEpoch, | ||||||
|  |                 roomId: room.id, | ||||||
|  |                 membershipId: voip.currentSessionId, | ||||||
|  |               ).toJson(), | ||||||
|  |             ] | ||||||
|  |           }, | ||||||
|  |           type: EventTypes.GroupCallMember, | ||||||
|  |           eventId: 'asdfasdf', | ||||||
|  |           senderId: '@test2:example.com', | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           room: room, | ||||||
|  |           stateKey: '@test2:example.com', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           content: { | ||||||
|  |             'memberships': [ | ||||||
|  |               CallMembership( | ||||||
|  |                 userId: '@test2.0:example.com', | ||||||
|  |                 callId: '1111', | ||||||
|  |                 backend: MeshBackend(), | ||||||
|  |                 deviceId: '1111', | ||||||
|  |                 expiresTs: DateTime.now().millisecondsSinceEpoch, | ||||||
|  |                 roomId: room.id, | ||||||
|  |                 membershipId: voip.currentSessionId, | ||||||
|  |               ).toJson(), | ||||||
|  |             ] | ||||||
|  |           }, | ||||||
|  |           type: EventTypes.GroupCallMember, | ||||||
|  |           eventId: 'asdfasdf', | ||||||
|  |           senderId: '@test2.0:example.com', | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           room: room, | ||||||
|  |           stateKey: '@test2.0:example.com', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           content: { | ||||||
|  |             'memberships': [ | ||||||
|  |               CallMembership( | ||||||
|  |                 userId: '@test3:example.com', | ||||||
|  |                 callId: '1111', | ||||||
|  |                 backend: MeshBackend(), | ||||||
|  |                 deviceId: '1111', | ||||||
|  |                 expiresTs: DateTime.now() | ||||||
|  |                     .subtract(Duration(hours: 1)) | ||||||
|  |                     .millisecondsSinceEpoch, | ||||||
|  |                 roomId: room.id, | ||||||
|  |                 membershipId: voip.currentSessionId, | ||||||
|  |               ).toJson(), | ||||||
|  |             ] | ||||||
|  |           }, | ||||||
|  |           type: EventTypes.GroupCallMember, | ||||||
|  |           eventId: 'asdfasdf', | ||||||
|  |           senderId: '@test3:example.com', | ||||||
|  |           originServerTs: DateTime.now().subtract(Duration(hours: 1)), | ||||||
|  |           room: room, | ||||||
|  |           stateKey: '@test3:example.com', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       expect(room.getFamedlyCallEvents().entries.elementAt(0).key, | ||||||
|  |           '@test3:example.com'); | ||||||
|  |       expect(room.getFamedlyCallEvents().entries.elementAt(1).key, | ||||||
|  |           '@test2:example.com'); | ||||||
|  |       expect(room.getFamedlyCallEvents().entries.elementAt(2).key, | ||||||
|  |           '@test2.0:example.com'); | ||||||
|  |       expect(room.getFamedlyCallEvents().entries.elementAt(3).key, | ||||||
|  |           '@test1:example.com'); | ||||||
|  |       expect(room.getCallMembershipsFromRoom().entries.elementAt(0).key, | ||||||
|  |           '@test3:example.com'); | ||||||
|  |       expect(room.getCallMembershipsFromRoom().entries.elementAt(1).key, | ||||||
|  |           '@test2:example.com'); | ||||||
|  |       expect(room.getCallMembershipsFromRoom().entries.elementAt(2).key, | ||||||
|  |           '@test2.0:example.com'); | ||||||
|  |       expect(room.getCallMembershipsFromRoom().entries.elementAt(3).key, | ||||||
|  |           '@test1:example.com'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('Enabling group calls', () async { | ||||||
|  |       // users default is 0 and so group calls not enabled | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           senderId: '@test:example.com', | ||||||
|  |           type: 'm.room.power_levels', | ||||||
|  |           room: room, | ||||||
|  |           eventId: '123a', | ||||||
|  |           content: { | ||||||
|  |             'events': {EventTypes.GroupCallMember: 100}, | ||||||
|  |             'state_default': 50, | ||||||
|  |             'users_default': 0 | ||||||
|  |           }, | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           stateKey: '', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       expect(room.canJoinGroupCall, false); | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, false); | ||||||
|  | 
 | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           senderId: '@test:example.com', | ||||||
|  |           type: 'm.room.power_levels', | ||||||
|  |           room: room, | ||||||
|  |           eventId: '123a', | ||||||
|  |           content: { | ||||||
|  |             'events': {EventTypes.GroupCallMember: 27}, | ||||||
|  |             'state_default': 50, | ||||||
|  |             'users_default': 49 | ||||||
|  |           }, | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           stateKey: '', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       expect(room.canJoinGroupCall, true); | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, true); | ||||||
|  | 
 | ||||||
|  |       // state_default 50 and user_default 0, use enableGroupCall | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |             senderId: '@test:example.com', | ||||||
|  |             type: 'm.room.power_levels', | ||||||
|  |             room: room, | ||||||
|  |             eventId: '123', | ||||||
|  |             content: { | ||||||
|  |               'state_default': 50, | ||||||
|  |               'users': {'@test:fakeServer.notExisting': 100}, | ||||||
|  |               'users_default': 0 | ||||||
|  |             }, | ||||||
|  |             originServerTs: DateTime.now(), | ||||||
|  |             stateKey: ''), | ||||||
|  |       ); | ||||||
|  |       expect(room.canJoinGroupCall, true); // because admin | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, false); | ||||||
|  |       await room.enableGroupCalls(); | ||||||
|  |       expect(room.canJoinGroupCall, true); | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, true); | ||||||
|  | 
 | ||||||
|  |       // state_default 50 and user_default unspecified, use enableGroupCall | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           senderId: '@test:example.com', | ||||||
|  |           type: 'm.room.power_levels', | ||||||
|  |           room: room, | ||||||
|  |           eventId: '123', | ||||||
|  |           content: { | ||||||
|  |             'state_default': 50, | ||||||
|  |             'users': {'@test:fakeServer.notExisting': 100}, | ||||||
|  |           }, | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           stateKey: '', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       expect(room.canJoinGroupCall, true); // because admin | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, false); | ||||||
|  |       await room.enableGroupCalls(); | ||||||
|  |       expect(room.canJoinGroupCall, true); | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, true); | ||||||
|  | 
 | ||||||
|  |       // state_default is 0 so users should be able to send state events | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |           senderId: '@test:example.com', | ||||||
|  |           type: 'm.room.power_levels', | ||||||
|  |           room: room, | ||||||
|  |           eventId: '123', | ||||||
|  |           content: { | ||||||
|  |             'state_default': 0, | ||||||
|  |             'users': {'@test:fakeServer.notExisting': 100}, | ||||||
|  |           }, | ||||||
|  |           originServerTs: DateTime.now(), | ||||||
|  |           stateKey: '', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       expect(room.canJoinGroupCall, true); | ||||||
|  |       expect(room.groupCallsEnabledForEveryone, true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('group call participants count', () { | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |             senderId: '@test1:example.com', | ||||||
|  |             type: EventTypes.GroupCallMember, | ||||||
|  |             room: room, | ||||||
|  |             eventId: '1234177', | ||||||
|  |             content: { | ||||||
|  |               'memberships': [ | ||||||
|  |                 CallMembership( | ||||||
|  |                   userId: '@test1:example.com', | ||||||
|  |                   callId: 'participants_count', | ||||||
|  |                   backend: MeshBackend(), | ||||||
|  |                   deviceId: '1111', | ||||||
|  |                   expiresTs: DateTime.now() | ||||||
|  |                       .subtract(Duration(hours: 1)) | ||||||
|  |                       .millisecondsSinceEpoch, | ||||||
|  |                   roomId: room.id, | ||||||
|  |                   membershipId: voip.currentSessionId, | ||||||
|  |                 ).toJson(), | ||||||
|  |               ] | ||||||
|  |             }, | ||||||
|  |             originServerTs: DateTime.now(), | ||||||
|  |             stateKey: '@test1:example.com'), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       expect(room.groupCallParticipantCount('participants_count'), 0); | ||||||
|  |       expect(room.hasActiveGroupCall, false); | ||||||
|  | 
 | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |             senderId: '@test2:example.com', | ||||||
|  |             type: EventTypes.GroupCallMember, | ||||||
|  |             room: room, | ||||||
|  |             eventId: '1234177', | ||||||
|  |             content: { | ||||||
|  |               'memberships': [ | ||||||
|  |                 CallMembership( | ||||||
|  |                   userId: '@test2:example.com', | ||||||
|  |                   callId: 'participants_count', | ||||||
|  |                   backend: MeshBackend(), | ||||||
|  |                   deviceId: '1111', | ||||||
|  |                   expiresTs: DateTime.now() | ||||||
|  |                       .add(Duration(hours: 1)) | ||||||
|  |                       .millisecondsSinceEpoch, | ||||||
|  |                   roomId: room.id, | ||||||
|  |                   membershipId: voip.currentSessionId, | ||||||
|  |                 ).toJson(), | ||||||
|  |               ] | ||||||
|  |             }, | ||||||
|  |             originServerTs: DateTime.now(), | ||||||
|  |             stateKey: '@test2:example.com'), | ||||||
|  |       ); | ||||||
|  |       expect(room.groupCallParticipantCount('participants_count'), 1); | ||||||
|  |       expect(room.hasActiveGroupCall, true); | ||||||
|  | 
 | ||||||
|  |       room.setState( | ||||||
|  |         Event( | ||||||
|  |             senderId: '@test3:example.com', | ||||||
|  |             type: EventTypes.GroupCallMember, | ||||||
|  |             room: room, | ||||||
|  |             eventId: '1231234124123', | ||||||
|  |             content: { | ||||||
|  |               'memberships': [ | ||||||
|  |                 CallMembership( | ||||||
|  |                   userId: '@test3:example.com', | ||||||
|  |                   callId: 'participants_count', | ||||||
|  |                   backend: MeshBackend(), | ||||||
|  |                   deviceId: '1111', | ||||||
|  |                   expiresTs: DateTime.now().millisecondsSinceEpoch, | ||||||
|  |                   roomId: room.id, | ||||||
|  |                   membershipId: voip.currentSessionId, | ||||||
|  |                 ).toJson(), | ||||||
|  |               ] | ||||||
|  |             }, | ||||||
|  |             originServerTs: DateTime.now(), | ||||||
|  |             stateKey: '@test3:example.com'), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       expect(room.groupCallParticipantCount('participants_count'), 2); | ||||||
|  |       expect(room.hasActiveGroupCall, true); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2581,6 +2581,10 @@ class FakeMatrixApi extends BaseClient { | ||||||
|           (var reqI) => { |           (var reqI) => { | ||||||
|                 'event_id': '42', |                 'event_id': '42', | ||||||
|               }, |               }, | ||||||
|  |       '/client/v3/rooms/!calls%3Aexample.com/state/m.room.power_levels': | ||||||
|  |           (var reqI) => { | ||||||
|  |                 'event_id': '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) => {}, | ||||||
|  | @ -2600,11 +2604,7 @@ class FakeMatrixApi extends BaseClient { | ||||||
|           }, |           }, | ||||||
|       '/client/unstable/org.matrix.msc3814.v1/dehydrated_device': (var _) => { |       '/client/unstable/org.matrix.msc3814.v1/dehydrated_device': (var _) => { | ||||||
|             'device_id': 'DEHYDDEV', |             'device_id': 'DEHYDDEV', | ||||||
|           }, |           } | ||||||
|       '/client/v3/rooms/${Uri.encodeComponent("!localpart:server.abc")}/state/${Uri.encodeComponent("org.matrix.msc3401.call")}/${Uri.encodeComponent("1675856324414gzczMtfzTk0DKgEw")}': |  | ||||||
|           (var req) => { |  | ||||||
|                 'event_id': 'groupCall', |  | ||||||
|               }, |  | ||||||
|     }, |     }, | ||||||
|     'DELETE': { |     'DELETE': { | ||||||
|       '/unknown/token': (var req) => {'errcode': 'M_UNKNOWN_TOKEN'}, |       '/unknown/token': (var req) => {'errcode': 'M_UNKNOWN_TOKEN'}, | ||||||
|  |  | ||||||
|  | @ -647,179 +647,10 @@ void main() { | ||||||
|       expect(room.canChangeStateEvent('m.room.power_levels'), false); |       expect(room.canChangeStateEvent('m.room.power_levels'), false); | ||||||
|       expect(room.canChangeStateEvent('m.room.member'), false); |       expect(room.canChangeStateEvent('m.room.member'), false); | ||||||
|       expect(room.canSendEvent('m.room.message'), true); |       expect(room.canSendEvent('m.room.message'), true); | ||||||
|       final resp = await room.setPower('@test:fakeServer.notExisting', 90); |       final resp = await room.setPower('@test:fakeServer.notExisting', 0); | ||||||
|       expect(resp, '42'); |       expect(resp, '42'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('Enabling group calls', () async { |  | ||||||
|       expect(room.groupCallsEnabled, false); |  | ||||||
| 
 |  | ||||||
|       // users default is 0 and so group calls not enabled |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123a', |  | ||||||
|           content: { |  | ||||||
|             'events': {EventTypes.GroupCallMemberPrefix: 100}, |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users_default': 0 |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, false); |  | ||||||
| 
 |  | ||||||
|       // one of the group call permissions is unspecified in events override |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123a', |  | ||||||
|           content: { |  | ||||||
|             'events': {EventTypes.GroupCallMemberPrefix: 27}, |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users_default': 49 |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, false); |  | ||||||
| 
 |  | ||||||
|       // only override one of the group calls permission, other one still less |  | ||||||
|       // than users_default and state_default |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123a', |  | ||||||
|           content: { |  | ||||||
|             'events': { |  | ||||||
|               EventTypes.GroupCallMemberPrefix: 27, |  | ||||||
|               EventTypes.GroupCallPrefix: 0 |  | ||||||
|             }, |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users_default': 2 |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, false); |  | ||||||
|       expect(room.canJoinGroupCall, false); |  | ||||||
|       expect(room.canCreateGroupCall, false); |  | ||||||
| 
 |  | ||||||
|       // state_default 50 and user_default 26, but override evnents present |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123a', |  | ||||||
|           content: { |  | ||||||
|             'events': { |  | ||||||
|               EventTypes.GroupCallMemberPrefix: 25, |  | ||||||
|               EventTypes.GroupCallPrefix: 25 |  | ||||||
|             }, |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users_default': 26 |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, true); |  | ||||||
|       expect(room.canJoinGroupCall, true); |  | ||||||
|       expect(room.canCreateGroupCall, true); |  | ||||||
| 
 |  | ||||||
|       // state_default 50 and user_default 0, use enableGroupCall |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|             senderId: '@test:example.com', |  | ||||||
|             type: 'm.room.power_levels', |  | ||||||
|             room: room, |  | ||||||
|             eventId: '123', |  | ||||||
|             content: { |  | ||||||
|               'state_default': 50, |  | ||||||
|               'users': {'@test:fakeServer.notExisting': 100}, |  | ||||||
|               'users_default': 0 |  | ||||||
|             }, |  | ||||||
|             originServerTs: DateTime.now(), |  | ||||||
|             stateKey: ''), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, false); |  | ||||||
|       expect(room.canJoinGroupCall, false); |  | ||||||
|       expect(room.canCreateGroupCall, false); |  | ||||||
|       await room.enableGroupCalls(); |  | ||||||
|       expect(room.groupCallsEnabled, true); |  | ||||||
| 
 |  | ||||||
|       // state_default 50 and user_default unspecified, use enableGroupCall |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123', |  | ||||||
|           content: { |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users': {'@test:fakeServer.notExisting': 100}, |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       await room.enableGroupCalls(); |  | ||||||
|       expect(room.groupCallsEnabled, true); |  | ||||||
|       expect(room.canJoinGroupCall, true); |  | ||||||
|       expect(room.canCreateGroupCall, true); |  | ||||||
| 
 |  | ||||||
|       // state_default is 0 so users should be able to send state events |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123', |  | ||||||
|           content: { |  | ||||||
|             'state_default': 0, |  | ||||||
|             'users': {'@test:fakeServer.notExisting': 100}, |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       expect(room.groupCallsEnabled, true); |  | ||||||
|       expect(room.canJoinGroupCall, true); |  | ||||||
|       expect(room.canCreateGroupCall, true); |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|           senderId: '@test:example.com', |  | ||||||
|           type: 'm.room.power_levels', |  | ||||||
|           room: room, |  | ||||||
|           eventId: '123abc', |  | ||||||
|           content: { |  | ||||||
|             'ban': 50, |  | ||||||
|             'events': {'m.room.name': 0, 'm.room.power_levels': 100}, |  | ||||||
|             'events_default': 0, |  | ||||||
|             'invite': 50, |  | ||||||
|             'kick': 50, |  | ||||||
|             'notifications': {'room': 20}, |  | ||||||
|             'redact': 50, |  | ||||||
|             'state_default': 50, |  | ||||||
|             'users': {}, |  | ||||||
|             'users_default': 0 |  | ||||||
|           }, |  | ||||||
|           originServerTs: DateTime.now(), |  | ||||||
|           stateKey: '', |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     test('invite', () async { |     test('invite', () async { | ||||||
|       await room.invite('Testname'); |       await room.invite('Testname'); | ||||||
|     }); |     }); | ||||||
|  | @ -1375,7 +1206,7 @@ void main() { | ||||||
|       expect(room.isSpace, true); |       expect(room.isSpace, true); | ||||||
| 
 | 
 | ||||||
|       expect(room.spaceParents.isEmpty, true); |       expect(room.spaceParents.isEmpty, true); | ||||||
|       room.states[EventTypes.spaceParent] = { |       room.states[EventTypes.SpaceParent] = { | ||||||
|         '!1234:example.invalid': Event.fromJson( |         '!1234:example.invalid': Event.fromJson( | ||||||
|           { |           { | ||||||
|             'content': { |             'content': { | ||||||
|  | @ -1385,7 +1216,7 @@ void main() { | ||||||
|             'origin_server_ts': 1432735824653, |             'origin_server_ts': 1432735824653, | ||||||
|             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', |             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', | ||||||
|             'sender': '@example:example.org', |             'sender': '@example:example.org', | ||||||
|             'type': EventTypes.spaceParent, |             'type': EventTypes.SpaceParent, | ||||||
|             'unsigned': {'age': 1234}, |             'unsigned': {'age': 1234}, | ||||||
|             'state_key': '!1234:example.invalid', |             'state_key': '!1234:example.invalid', | ||||||
|           }, |           }, | ||||||
|  | @ -1395,7 +1226,7 @@ void main() { | ||||||
|       expect(room.spaceParents.length, 1); |       expect(room.spaceParents.length, 1); | ||||||
| 
 | 
 | ||||||
|       expect(room.spaceChildren.isEmpty, true); |       expect(room.spaceChildren.isEmpty, true); | ||||||
|       room.states[EventTypes.spaceChild] = { |       room.states[EventTypes.SpaceChild] = { | ||||||
|         '!b:example.invalid': Event.fromJson( |         '!b:example.invalid': Event.fromJson( | ||||||
|           { |           { | ||||||
|             'content': { |             'content': { | ||||||
|  | @ -1406,7 +1237,7 @@ void main() { | ||||||
|             'origin_server_ts': 1432735824653, |             'origin_server_ts': 1432735824653, | ||||||
|             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', |             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', | ||||||
|             'sender': '@example:example.org', |             'sender': '@example:example.org', | ||||||
|             'type': EventTypes.spaceChild, |             'type': EventTypes.SpaceChild, | ||||||
|             'unsigned': {'age': 1234}, |             'unsigned': {'age': 1234}, | ||||||
|             'state_key': '!b:example.invalid', |             'state_key': '!b:example.invalid', | ||||||
|           }, |           }, | ||||||
|  | @ -1422,7 +1253,7 @@ void main() { | ||||||
|             'origin_server_ts': 1432735824653, |             'origin_server_ts': 1432735824653, | ||||||
|             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', |             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', | ||||||
|             'sender': '@example:example.org', |             'sender': '@example:example.org', | ||||||
|             'type': EventTypes.spaceChild, |             'type': EventTypes.SpaceChild, | ||||||
|             'unsigned': {'age': 1234}, |             'unsigned': {'age': 1234}, | ||||||
|             'state_key': '!c:example.invalid', |             'state_key': '!c:example.invalid', | ||||||
|           }, |           }, | ||||||
|  | @ -1437,7 +1268,7 @@ void main() { | ||||||
|             'origin_server_ts': 1432735824653, |             'origin_server_ts': 1432735824653, | ||||||
|             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', |             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', | ||||||
|             'sender': '@example:example.org', |             'sender': '@example:example.org', | ||||||
|             'type': EventTypes.spaceChild, |             'type': EventTypes.SpaceChild, | ||||||
|             'unsigned': {'age': 1234}, |             'unsigned': {'age': 1234}, | ||||||
|             'state_key': '!noorder:example.invalid', |             'state_key': '!noorder:example.invalid', | ||||||
|           }, |           }, | ||||||
|  | @ -1453,7 +1284,7 @@ void main() { | ||||||
|             'origin_server_ts': 1432735824653, |             'origin_server_ts': 1432735824653, | ||||||
|             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', |             'room_id': '!jEsUZKDJdhlrceRyVU:example.org', | ||||||
|             'sender': '@example:example.org', |             'sender': '@example:example.org', | ||||||
|             'type': EventTypes.spaceChild, |             'type': EventTypes.SpaceChild, | ||||||
|             'unsigned': {'age': 1234}, |             'unsigned': {'age': 1234}, | ||||||
|             'state_key': '!a:example.invalid', |             'state_key': '!a:example.invalid', | ||||||
|           }, |           }, | ||||||
|  | @ -1485,7 +1316,6 @@ void main() { | ||||||
|     test('inviteLink', () async { |     test('inviteLink', () async { | ||||||
|       // ensure we don't rerequest members |       // ensure we don't rerequest members | ||||||
|       room.summary.mJoinedMemberCount = 4; |       room.summary.mJoinedMemberCount = 4; | ||||||
| 
 |  | ||||||
|       var matrixToLink = await room.matrixToInviteLink(); |       var matrixToLink = await room.matrixToInviteLink(); | ||||||
|       expect(matrixToLink.toString(), |       expect(matrixToLink.toString(), | ||||||
|           'https://matrix.to/#/%23testalias%3Aexample.com'); |           'https://matrix.to/#/%23testalias%3Aexample.com'); | ||||||
|  | @ -1504,181 +1334,6 @@ void main() { | ||||||
|           'https://matrix.to/#/!localpart%3Aserver.abc?via=example.com&via=test.abc&via=example.org'); |           'https://matrix.to/#/!localpart%3Aserver.abc?via=example.com&via=test.abc&via=example.org'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('callMemberStateIsExpired', () { |  | ||||||
|       expect( |  | ||||||
|           room.callMemberStateForIdIsExpired( |  | ||||||
|               Event( |  | ||||||
|                   senderId: '@test:example.com', |  | ||||||
|                   type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|                   room: room, |  | ||||||
|                   eventId: '1231234124', |  | ||||||
|                   content: { |  | ||||||
|                     'm.calls': [ |  | ||||||
|                       { |  | ||||||
|                         'm.call_id': '1674811248673789288k7d60n5976', |  | ||||||
|                         'm.devices': [ |  | ||||||
|                           { |  | ||||||
|                             'device_id': 'ZEEGCGPTGI', |  | ||||||
|                             'session_id': 'cbAtVZdLBnJq', |  | ||||||
|                             'm.expires_ts': 1674813039415, |  | ||||||
|                             'feeds': [ |  | ||||||
|                               {'purpose': 'm.usermedia'} |  | ||||||
|                             ] |  | ||||||
|                           } |  | ||||||
|                         ] |  | ||||||
|                       }, |  | ||||||
|                     ], |  | ||||||
|                   }, |  | ||||||
|                   originServerTs: DateTime.now(), |  | ||||||
|                   stateKey: ''), |  | ||||||
|               '1674811248673789288k7d60n5976'), |  | ||||||
|           true); |  | ||||||
|       expect( |  | ||||||
|           room.callMemberStateForIdIsExpired( |  | ||||||
|               Event( |  | ||||||
|                   senderId: '@test:example.com', |  | ||||||
|                   type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|                   room: room, |  | ||||||
|                   eventId: '1231234124', |  | ||||||
|                   content: { |  | ||||||
|                     'm.calls': [ |  | ||||||
|                       { |  | ||||||
|                         'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ', |  | ||||||
|                         'm.devices': [ |  | ||||||
|                           { |  | ||||||
|                             'device_id': 'ZEEGCGPTGI', |  | ||||||
|                             'session_id': 'fhovqxwcasdfr', |  | ||||||
|                             'expires_ts': DateTime.now() |  | ||||||
|                                 .add(Duration(minutes: 1)) |  | ||||||
|                                 .millisecondsSinceEpoch, |  | ||||||
|                             'feeds': [ |  | ||||||
|                               {'purpose': 'm.usermedia'} |  | ||||||
|                             ] |  | ||||||
|                           } |  | ||||||
|                         ] |  | ||||||
|                       } |  | ||||||
|                     ], |  | ||||||
|                   }, |  | ||||||
|                   originServerTs: DateTime.now(), |  | ||||||
|                   stateKey: ''), |  | ||||||
|               '1674811256006mfqnmsAbzqxjYtWZ'), |  | ||||||
|           false); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     test('group call participants count', () { |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|             senderId: '@test:example.com', |  | ||||||
|             type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|             room: room, |  | ||||||
|             eventId: '1234177', |  | ||||||
|             content: { |  | ||||||
|               'm.calls': [ |  | ||||||
|                 { |  | ||||||
|                   'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ', |  | ||||||
|                   'm.devices': [ |  | ||||||
|                     { |  | ||||||
|                       'device_id': 'ZEEGCGPTGI', |  | ||||||
|                       'session_id': 'fhovqxwcasdfr', |  | ||||||
|                       'expires_ts': DateTime.now() |  | ||||||
|                           .add(Duration(minutes: 1)) |  | ||||||
|                           .millisecondsSinceEpoch, |  | ||||||
|                       'feeds': [ |  | ||||||
|                         {'purpose': 'm.usermedia'} |  | ||||||
|                       ] |  | ||||||
|                     }, |  | ||||||
|                   ] |  | ||||||
|                 } |  | ||||||
|               ], |  | ||||||
|             }, |  | ||||||
|             originServerTs: DateTime.now(), |  | ||||||
|             stateKey: '@test:example.com'), |  | ||||||
|       ); |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|             senderId: '@test0:example.com', |  | ||||||
|             type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|             room: room, |  | ||||||
|             eventId: '1234177', |  | ||||||
|             content: { |  | ||||||
|               'm.calls': [ |  | ||||||
|                 { |  | ||||||
|                   'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ', |  | ||||||
|                   'm.devices': [ |  | ||||||
|                     { |  | ||||||
|                       'device_id': 'ZEEGCGPTGI', |  | ||||||
|                       'session_id': 'fhovqxwcasdfr', |  | ||||||
|                       'expires_ts': DateTime.now() |  | ||||||
|                           .add(Duration(minutes: 2)) |  | ||||||
|                           .millisecondsSinceEpoch, |  | ||||||
|                       'feeds': [ |  | ||||||
|                         {'purpose': 'm.usermedia'} |  | ||||||
|                       ] |  | ||||||
|                     }, |  | ||||||
|                   ] |  | ||||||
|                 } |  | ||||||
|               ], |  | ||||||
|             }, |  | ||||||
|             originServerTs: DateTime.now(), |  | ||||||
|             stateKey: '@test0:example.com'), |  | ||||||
|       ); |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|             senderId: '@test2:example.com', |  | ||||||
|             type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|             room: room, |  | ||||||
|             eventId: '1231234124123', |  | ||||||
|             content: { |  | ||||||
|               'm.calls': [ |  | ||||||
|                 { |  | ||||||
|                   'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ', |  | ||||||
|                   'm.devices': [ |  | ||||||
|                     { |  | ||||||
|                       'device_id': 'ZEEGCGPTGI', |  | ||||||
|                       'session_id': 'fhovqxwcasdfr', |  | ||||||
|                       'feeds': [ |  | ||||||
|                         {'purpose': 'm.usermedia'} |  | ||||||
|                       ] |  | ||||||
|                     }, |  | ||||||
|                   ] |  | ||||||
|                 } |  | ||||||
|               ], |  | ||||||
|             }, |  | ||||||
|             originServerTs: DateTime.now(), |  | ||||||
|             stateKey: '@test2:example.com'), |  | ||||||
|       ); |  | ||||||
|       room.setState( |  | ||||||
|         Event( |  | ||||||
|             senderId: '@test3:example.com', |  | ||||||
|             type: EventTypes.GroupCallMemberPrefix, |  | ||||||
|             room: room, |  | ||||||
|             eventId: '123123412445', |  | ||||||
|             content: { |  | ||||||
|               'm.calls': [ |  | ||||||
|                 { |  | ||||||
|                   'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ', |  | ||||||
|                   'm.devices': [ |  | ||||||
|                     { |  | ||||||
|                       'device_id': 'ZEEGCGPTGI', |  | ||||||
|                       'session_id': 'fhovqxwcasdfr', |  | ||||||
|                       'expires_ts': DateTime.now() |  | ||||||
|                           .subtract(Duration(minutes: 1)) |  | ||||||
|                           .millisecondsSinceEpoch, |  | ||||||
|                       'feeds': [ |  | ||||||
|                         {'purpose': 'm.usermedia'} |  | ||||||
|                       ] |  | ||||||
|                     }, |  | ||||||
|                   ] |  | ||||||
|                 } |  | ||||||
|               ], |  | ||||||
|             }, |  | ||||||
|             originServerTs: DateTime.now(), |  | ||||||
|             stateKey: '@test3:example.com'), |  | ||||||
|       ); |  | ||||||
|       expect( |  | ||||||
|           room.groupCallParticipantCount('1674811256006mfqnmsAbzqxjYtWZ'), 2); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     test('EventTooLarge on exceeding max PDU size', () async { |     test('EventTooLarge on exceeding max PDU size', () async { | ||||||
|       try { |       try { | ||||||
|         await room.sendTextEvent(''' |         await room.sendTextEvent(''' | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ import 'package:matrix/matrix.dart'; | ||||||
| 
 | 
 | ||||||
| class MockWebRTCDelegate implements WebRTCDelegate { | class MockWebRTCDelegate implements WebRTCDelegate { | ||||||
|   @override |   @override | ||||||
|   // TODO: implement canHandleNewCall |  | ||||||
|   bool get canHandleNewCall => true; |   bool get canHandleNewCall => true; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|  | @ -16,18 +15,13 @@ class MockWebRTCDelegate implements WebRTCDelegate { | ||||||
|   ]) async => |   ]) async => | ||||||
|       MockRTCPeerConnection(); |       MockRTCPeerConnection(); | ||||||
| 
 | 
 | ||||||
|   @override |  | ||||||
|   VideoRenderer createRenderer() { |  | ||||||
|     return MockVideoRenderer(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @override |   @override | ||||||
|   Future<void> handleCallEnded(CallSession session) async { |   Future<void> handleCallEnded(CallSession session) async { | ||||||
|     Logs().i('handleCallEnded called in MockWebRTCDelegate'); |     Logs().i('handleCallEnded called in MockWebRTCDelegate'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<void> handleGroupCallEnded(GroupCall groupCall) async { |   Future<void> handleGroupCallEnded(GroupCallSession groupCall) async { | ||||||
|     Logs().i('handleGroupCallEnded called in MockWebRTCDelegate'); |     Logs().i('handleGroupCallEnded called in MockWebRTCDelegate'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -42,7 +36,7 @@ class MockWebRTCDelegate implements WebRTCDelegate { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<void> handleNewGroupCall(GroupCall groupCall) async { |   Future<void> handleNewGroupCall(GroupCallSession groupCall) async { | ||||||
|     Logs().i('handleNewGroupCall called in MockWebRTCDelegate'); |     Logs().i('handleNewGroupCall called in MockWebRTCDelegate'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -61,6 +55,27 @@ class MockWebRTCDelegate implements WebRTCDelegate { | ||||||
|   Future<void> stopRingtone() async { |   Future<void> stopRingtone() async { | ||||||
|     Logs().i('stopRingtone called in MockWebRTCDelegate'); |     Logs().i('stopRingtone called in MockWebRTCDelegate'); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   EncryptionKeyProvider? get keyProvider => MockEncryptionKeyProvider(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class MockEncryptionKeyProvider implements EncryptionKeyProvider { | ||||||
|  |   @override | ||||||
|  |   Future<void> onSetEncryptionKey( | ||||||
|  |       CallParticipant participant, Uint8List key, int index) async { | ||||||
|  |     Logs().i('Mock onSetEncryptionKey called for ${participant.id} '); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<Uint8List> onExportKey(CallParticipant participant, int index) { | ||||||
|  |     throw UnimplementedError(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<Uint8List> onRatchetKey(CallParticipant participant, int index) { | ||||||
|  |     throw UnimplementedError(); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| class MockMediaDevices implements MediaDevices { | class MockMediaDevices implements MediaDevices { | ||||||
|  | @ -69,25 +84,21 @@ class MockMediaDevices implements MediaDevices { | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<List<MediaDeviceInfo>> enumerateDevices() { |   Future<List<MediaDeviceInfo>> enumerateDevices() { | ||||||
|     // TODO: implement enumerateDevices |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<MediaStream> getDisplayMedia(Map<String, dynamic> mediaConstraints) { |   Future<MediaStream> getDisplayMedia(Map<String, dynamic> mediaConstraints) { | ||||||
|     // TODO: implement getDisplayMedia |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<List> getSources() { |   Future<List> getSources() { | ||||||
|     // TODO: implement getSources |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   MediaTrackSupportedConstraints getSupportedConstraints() { |   MediaTrackSupportedConstraints getSupportedConstraints() { | ||||||
|     // TODO: implement getSupportedConstraints |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -99,7 +110,6 @@ class MockMediaDevices implements MediaDevices { | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) { |   Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) { | ||||||
|     // TODO: implement selectAudioOutput |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | @ -347,15 +357,12 @@ class MockRTCPeerConnection implements RTCPeerConnection { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement receivers |  | ||||||
|   Future<List<RTCRtpReceiver>> get receivers => throw UnimplementedError(); |   Future<List<RTCRtpReceiver>> get receivers => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement senders |  | ||||||
|   Future<List<RTCRtpSender>> get senders => throw UnimplementedError(); |   Future<List<RTCRtpSender>> get senders => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement transceivers |  | ||||||
|   Future<List<RTCRtpTransceiver>> get transceivers => |   Future<List<RTCRtpTransceiver>> get transceivers => | ||||||
|       throw UnimplementedError(); |       throw UnimplementedError(); | ||||||
| } | } | ||||||
|  | @ -415,65 +422,53 @@ class MockRTCRtpTransceiver implements RTCRtpTransceiver { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement stoped |  | ||||||
|   bool get stoped => throw UnimplementedError(); |   bool get stoped => throw UnimplementedError(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| class MockRTCRtpSender implements RTCRtpSender { | class MockRTCRtpSender implements RTCRtpSender { | ||||||
|   @override |   @override | ||||||
|   Future<void> dispose() { |   Future<void> dispose() { | ||||||
|     // TODO: implement dispose |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement dtmfSender |  | ||||||
|   RTCDTMFSender get dtmfSender => throw UnimplementedError(); |   RTCDTMFSender get dtmfSender => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<List<StatsReport>> getStats() { |   Future<List<StatsReport>> getStats() { | ||||||
|     // TODO: implement getStats |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement ownsTrack |  | ||||||
|   bool get ownsTrack => throw UnimplementedError(); |   bool get ownsTrack => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement parameters |  | ||||||
|   RTCRtpParameters get parameters => throw UnimplementedError(); |   RTCRtpParameters get parameters => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<void> replaceTrack(MediaStreamTrack? track) { |   Future<void> replaceTrack(MediaStreamTrack? track) { | ||||||
|     // TODO: implement replaceTrack |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement senderId |  | ||||||
|   String get senderId => throw UnimplementedError(); |   String get senderId => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<bool> setParameters(RTCRtpParameters parameters) { |   Future<bool> setParameters(RTCRtpParameters parameters) { | ||||||
|     // TODO: implement setParameters |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<void> setStreams(List<MediaStream> streams) { |   Future<void> setStreams(List<MediaStream> streams) { | ||||||
|     // TODO: implement setStreams |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<void> setTrack(MediaStreamTrack? track, {bool takeOwnership = true}) { |   Future<void> setTrack(MediaStreamTrack? track, {bool takeOwnership = true}) { | ||||||
|     // TODO: implement setTrack |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement track |  | ||||||
|   MediaStreamTrack? get track => throw UnimplementedError(); |   MediaStreamTrack? get track => throw UnimplementedError(); | ||||||
|   // Mock implementation for RTCRtpSender |   // Mock implementation for RTCRtpSender | ||||||
| } | } | ||||||
|  | @ -485,20 +480,16 @@ class MockRTCRtpReceiver implements RTCRtpReceiver { | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Future<List<StatsReport>> getStats() { |   Future<List<StatsReport>> getStats() { | ||||||
|     // TODO: implement getStats |  | ||||||
|     throw UnimplementedError(); |     throw UnimplementedError(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement parameters |  | ||||||
|   RTCRtpParameters get parameters => throw UnimplementedError(); |   RTCRtpParameters get parameters => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement receiverId |  | ||||||
|   String get receiverId => throw UnimplementedError(); |   String get receiverId => throw UnimplementedError(); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   // TODO: implement track |  | ||||||
|   MediaStreamTrack? get track => throw UnimplementedError(); |   MediaStreamTrack? get track => throw UnimplementedError(); | ||||||
|   // Mock implementation for RTCRtpReceiver |   // Mock implementation for RTCRtpReceiver | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue