From e2efa3e75809e3b063c05508f8da9d09eab2bcb2 Mon Sep 17 00:00:00 2001 From: Duan Weiwei Date: Mon, 13 Jun 2022 15:26:25 +0000 Subject: [PATCH] Support group call. --- lib/matrix.dart | 8 +- lib/src/client.dart | 36 +- lib/src/voip/README.md | 175 +++ lib/src/{voip.dart => voip/call.dart} | 774 +++++----- lib/src/voip/group_call.dart | 1242 +++++++++++++++++ .../voip/images/famedly-1v1-call.drawio.png | Bin 0 -> 157352 bytes lib/src/voip/utils.dart | 32 + lib/src/voip/voip.dart | 690 +++++++++ lib/src/{ => voip}/voip_content.dart | 0 test/fake_voip_delegate.dart | 52 - test/room_test.dart | 24 +- 11 files changed, 2505 insertions(+), 528 deletions(-) create mode 100644 lib/src/voip/README.md rename lib/src/{voip.dart => voip/call.dart} (74%) create mode 100644 lib/src/voip/group_call.dart create mode 100644 lib/src/voip/images/famedly-1v1-call.drawio.png create mode 100644 lib/src/voip/utils.dart create mode 100644 lib/src/voip/voip.dart rename lib/src/{ => voip}/voip_content.dart (100%) delete mode 100644 test/fake_voip_delegate.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index 98f6b3c9..6ddd30fb 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -29,8 +29,11 @@ export 'src/database/hive_collections_database.dart'; export 'src/event.dart'; export 'src/presence.dart'; export 'src/event_status.dart'; -export 'src/voip.dart'; -export 'src/voip_content.dart'; +export 'src/voip/call.dart'; +export 'src/voip/group_call.dart'; +export 'src/voip/voip.dart'; +export 'src/voip/voip_content.dart'; +export 'src/voip/utils.dart'; export 'src/room.dart'; export 'src/timeline.dart'; export 'src/user.dart'; @@ -50,7 +53,6 @@ export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; export 'src/utils/uia_request.dart'; export 'src/utils/uri_extension.dart'; -export 'src/voip_content.dart'; export 'msc_extensions/extension_recent_emoji/recent_emoji.dart'; export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 3a253bbd..4aafc6cb 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -27,6 +27,7 @@ import 'package:matrix/src/utils/sync_update_item_count.dart'; import 'package:mime/mime.dart'; import 'package:olm/olm.dart' as olm; import 'package:collection/collection.dart' show IterableExtension; +import 'package:random_string/random_string.dart'; import '../encryption.dart'; import '../matrix.dart'; @@ -221,6 +222,11 @@ class Client extends MatrixApi { String? get deviceName => _deviceName; String? _deviceName; + // for group calls + // A unique identifier used for resolving duplicate group call sessions from a given device. When the session_id field changes from an incoming m.call.member event, any existing calls from this device in this call should be terminated. The id is generated once per client load. + String? get groupCallSessionId => _groupCallSessionId; + String? _groupCallSessionId; + /// Returns the current login state. LoginState get loginState => __loginState; LoginState __loginState; @@ -613,6 +619,7 @@ class Client extends MatrixApi { List? initialState, Visibility? visibility, bool waitForSync = true, + bool groupCall = false, }) async { enableEncryption ??= encryptionEnabled && preset != CreateRoomPreset.publicChat; @@ -628,12 +635,18 @@ class Client extends MatrixApi { } } final roomId = await createRoom( - invite: invite, - preset: preset, - name: groupName, - initialState: initialState, - visibility: visibility, - ); + invite: invite, + preset: preset, + name: groupName, + initialState: initialState, + visibility: visibility, + powerLevelContentOverride: groupCall + ? { + 'events': { + 'org.matrix.msc3401.call.member': 0, + }, + } + : null); if (waitForSync) { if (getRoomById(roomId) == null) { @@ -1026,6 +1039,11 @@ class Client extends MatrixApi { final StreamController onUiaRequest = StreamController.broadcast(); + final StreamController onGroupCallRequest = + StreamController.broadcast(); + + final StreamController onGroupMember = StreamController.broadcast(); + /// How long should the app wait until it retrys the synchronisation after /// an error? int syncErrorTimeoutSec = 3; @@ -1258,6 +1276,8 @@ class Client extends MatrixApi { ); } + _groupCallSessionId = randomAlpha(12); + String? olmAccount; String? accessToken; String? _userID; @@ -1844,6 +1864,10 @@ class Client extends MatrixApi { EventTypes.CallSDPStreamMetadataChangedPrefix) { onSDPStreamMetadataChangedReceived .add(Event.fromJson(rawUnencryptedEvent, room)); + // TODO(duan): Only used (org.matrix.msc3401.call) during the current test, + // need to add GroupCallPrefix in matrix_api_lite + } else if (rawUnencryptedEvent['type'] == EventTypes.GroupCallPrefix) { + onGroupCallRequest.add(Event.fromJson(rawUnencryptedEvent, room)); } } } diff --git a/lib/src/voip/README.md b/lib/src/voip/README.md new file mode 100644 index 00000000..505e56c7 --- /dev/null +++ b/lib/src/voip/README.md @@ -0,0 +1,175 @@ +# VOIP for Matrix SDK + +1:1 and group calls + +## Overview + +`VoIP` is a module that provides a simple API for making 1:1 and group calls. + +`CallSession` objects are created by calling `inviteToCall` and `onCallInvite`. + +`GroupCall` objects are created by calling `createGroupCall`. + +## 1:1 calls + +### 1. Basic call flow + +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 + +![1v1 call](images/famedly-1v1-call.drawio.png) + +### 2.Implement the event handlers + +The code here is to adapt to the difference between `flutter app` and `dart web app` and prevent importing `flutter` dependencies in `dart` app. + +We need to import `dart_webrtc` or `flutter_webrtc`, and map the platform-specific API `(mediaDevices, createPeerConnection, createRenderer)` +implementations to the corresponding packages. + +In addition, we can respond to the call to start and end in this delegate, start or turn off the incoming call ringing + +``` dart +// for dart app +import 'package:dart_webrtc/dart_webrtc.dart' as webrtc_impl; +// for flutter app +// import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl; + +class MyVoipApp implements WebRTCDelegate { + @override + MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices; + @override + Future createPeerConnection( + Map configuration, + [Map constraints = const {}]) => + webrtc_impl.createPeerConnection(configuration, constraints); + @override + VideoRenderer createRenderer() => RTCVideoRenderer(); + + @override + void playRingtone(){ + // play ringtone + } + void stopRingtone() { + // stop ringtone + } + + void handleNewCall(CallSession session) { + // handle new call incoming or outgoing + switch(session.direction) { + case CallDirection.kIncoming: + // show incoming call window + break; + case CallDirection.kOutgoing: + // show outgoing call window + break; + } + } + + void handleCallEnded(CallSession session) { + // handle call ended by local or remote + } +} +``` + +### 3.Start a outgoing call + +When the delegate is set we can initiate a new outgoing call. +We need to use the matrix roomId to initiate the call, the initial call can be +`CallType.kVoice` or `CallType.kVideo`. + +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. + +```dart +final voip = VoIP(client, MyVoipApp()); + +/// Create a new call +final newCall = await voip.inviteToCall(roomId, CallType.kVideo); + +newCall.onCallStateChanged.stream.listen((state) { + /// handle call state change event, + /// You can change UI state here, such as Ringing, + /// Connecting, Connected, Disconnected, etc. +}); + +/// Then you can pop up the incoming call window at MyVoipApp.handleNewCall. +class MyVoipApp implements WebRTCDelegate { +... + void handleNewCall(CallSession session) { + switch(session.direction) { + case CallDirection.kOutgoing: + // show outgoing call window + break; + } + } +... + +/// end the call by local +newCall.hangup(); +``` + +### 4.Answer a incoming call + +When a new incoming call comes in, handleNewCall will be called, and the answering interface can pop up at this time, and use `onCallStateChanged` to listen to the call state. + +The incoming call window need display `answer` and `reject` buttons, by calling `newCall.answer();` or `newCall.reject();` to decide whether to connect the call. + +```dart +... + void handleNewCall(CallSession newCall) { + switch(newCall.direction) { + case CallDirection.kIncoming: + /// show incoming call window + newCall.onCallStateChanged.stream.listen((state) { + /// handle call state change event + }); + break; + } + } +... + +/// Answer the call +newCall.answer(); +// or reject the call +newCall.reject(); +``` + +### 5.Render media stream + +The basic process of rendering a video stream is as follow code. + +```dart +class RemoteVideoView extends Widget { +VideoElement get videoElement => renderer.element; + +RTCVideoRenderer get renderer => remoteStream.renderer as RTCVideoRenderer; + +final WrappedMediaStream remoteStream; + +RemoteVideoView(this.remoteStream){ + renderer.srcObject = remoteStream.mediaStream; +} +... + @override + Element build() { + return divElement( + children: [ + ... + videoElement, + ... + ]); +} +... + +} +``` + +Usually there are four media streams in a 1v1 call, which are + +* `localUserMediaStream` +* `localScreenSharingStream` +* `remoteUserMediaStream` +* `remoteScreenSharingStream` + +They can be get by the methods of `CallSession`. the `newCall.onCallStreamsChanged` event is fired when these streams are added or removed. +When the media stream changes, we can change the UI display according to the priority. +`remoteScreenSharingStream` always needs to be displayed first, followed by `remoteUserMediaStream` \ No newline at end of file diff --git a/lib/src/voip.dart b/lib/src/voip/call.dart similarity index 74% rename from lib/src/voip.dart rename to lib/src/voip/call.dart index 97340cbd..ab9ad840 100644 --- a/lib/src/voip.dart +++ b/lib/src/voip/call.dart @@ -1,31 +1,32 @@ +/* + * 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 Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + import 'dart:async'; import 'dart:core'; import 'package:webrtc_interface/webrtc_interface.dart'; -import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; -import '../matrix.dart'; +import '../../matrix.dart'; /// https://github.com/matrix-org/matrix-doc/pull/2746 /// version 1 const String voipProtoVersion = '1'; -/// Delegate WebRTC basic functionality. -abstract class WebRTCDelegate { - MediaDevices get mediaDevices; - Future createPeerConnection( - Map configuration, - [Map constraints = const {}]); - VideoRenderer createRenderer(); - void playRingtone(); - void stopRingtone(); - void handleNewCall(CallSession session); - void handleCallEnded(CallSession session); - - bool get isBackgroud; - bool get isWeb; -} - /// The default life time for call events, in millisecond. const lifetimeMs = 10 * 1000; @@ -45,11 +46,15 @@ class WrappedMediaStream { final Client client; VideoRenderer renderer; final bool isWeb; + final bool isGroupCall; /// for debug String get title => '$displayName:$purpose:a[$audioMuted]:v[$videoMuted]'; bool stopped = false; - void Function(bool audioMuted, bool videoMuted)? onMuteStateChanged; + + final StreamController onMuteStateChanged = + StreamController.broadcast(); + void Function(MediaStream stream)? onNewStream; WrappedMediaStream( @@ -61,7 +66,8 @@ class WrappedMediaStream { required this.client, required this.audioMuted, required this.videoMuted, - required this.isWeb}); + required this.isWeb, + required this.isGroupCall}); /// Initialize the video renderer Future initialize() async { @@ -75,7 +81,7 @@ class WrappedMediaStream { Future dispose() async { renderer.srcObject = null; - if (isLocal() && stream != null) { + if (isLocal() && !isGroupCall && stream != null) { if (isWeb) { stream!.getTracks().forEach((element) { element.stop(); @@ -86,6 +92,8 @@ class WrappedMediaStream { } } + Uri? get avatarUrl => getUser().avatarUrl; + String get avatarName => getUser().calcDisplayname(mxidLocalPartFallback: false); @@ -117,16 +125,12 @@ class WrappedMediaStream { void setAudioMuted(bool muted) { audioMuted = muted; - if (onMuteStateChanged != null) { - onMuteStateChanged?.call(audioMuted, videoMuted); - } + onMuteStateChanged.add(this); } void setVideoMuted(bool muted) { videoMuted = muted; - if (onMuteStateChanged != null) { - onMuteStateChanged?.call(audioMuted, videoMuted); - } + onMuteStateChanged.add(this); } } @@ -264,6 +268,7 @@ enum CallParty { kLocal, kRemote } /// Initialization parameters of the call session. class CallOptions { late String callId; + String? groupCallId; late CallType type; late CallDirection dir; late String localPartyId; @@ -279,6 +284,7 @@ class CallSession { CallType get type => opts.type; Room get room => opts.room; VoIP get voip => opts.voip; + String? get groupCallId => opts.groupCallId; String get callId => opts.callId; String get localPartyId => opts.localPartyId; String? get displayName => room.displayname; @@ -300,12 +306,40 @@ class CallSession { bool makingOffer = false; bool ignoreOffer = false; String facingMode = 'user'; + bool get answeredByUs => _answeredByUs; Client get client => opts.room.client; String? remotePartyId; - late User remoteUser; + String? opponentDeviceId; + String? opponentSessionId; + String? invitee; + User? remoteUser; late CallParty hangupParty; - late String hangupReason; + String? hangupReason; late CallError lastError; + CallSession? successor; + bool waitForLocalAVStream = false; + int toDeviceSeq = 0; + + final StreamController onCallStreamsChanged = + StreamController.broadcast(); + + final StreamController onCallReplaced = + StreamController.broadcast(); + + final StreamController onCallHangup = + StreamController.broadcast(); + + final StreamController onCallStateChanged = + StreamController.broadcast(); + + final StreamController onCallEventChanged = + StreamController.broadcast(); + + final StreamController onStreamAdd = + StreamController.broadcast(); + + final StreamController onStreamRemoved = + StreamController.broadcast(); SDPStreamMetadata? remoteSDPStreamMetadata; List usermediaSenders = []; @@ -352,12 +386,6 @@ class CallSession { return null; } - final _callStateController = - StreamController.broadcast(sync: true); - Stream get onCallStateChanged => _callStateController.stream; - final _callEventController = - StreamController.broadcast(sync: true); - Stream get onCallEventChanged => _callEventController.stream; Timer? inviteTimer; Timer? ringingTimer; @@ -366,7 +394,7 @@ class CallSession { setCallState(CallState.kCreateOffer); final stream = await _getUserMedia(type); if (stream != null) { - _addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia); + addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia); } } @@ -376,7 +404,7 @@ class CallSession { final stream = await _getUserMedia(type); if (stream != null) { - _addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia); + addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia); } if (metadata != null) { @@ -399,6 +427,96 @@ class CallSession { }); } + void answerWithStreams(List callFeeds) { + if (inviteOrAnswerSent) return; + Logs().d('nswering call $callId'); + gotCallFeedsForAnswer(callFeeds); + } + + void replacedBy(CallSession newCall) { + if (state == CallState.kWaitLocalMedia) { + Logs().v('Telling new call to wait for local media'); + newCall.waitForLocalAVStream = true; + } else if (state == CallState.kCreateOffer || + state == CallState.kInviteSent) { + Logs().v('Handing local stream to new call'); + newCall.gotCallFeedsForAnswer(getLocalStreams); + } + successor = newCall; + onCallReplaced.add(newCall); + hangup(CallErrorCode.Replaced, true); + } + + Future sendAnswer(RTCSessionDescription answer) async { + final callCapabilities = CallCapabilities() + ..dtmf = false + ..transferee = false; + + final metadata = SDPStreamMetadata({ + localUserMediaStream!.stream!.id: SDPStreamPurpose( + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: localUserMediaStream!.stream!.getAudioTracks().isEmpty, + video_muted: localUserMediaStream!.stream!.getVideoTracks().isEmpty) + }); + + final res = await sendAnswerCall(room, callId, answer.sdp!, localPartyId, + type: answer.type!, capabilities: callCapabilities, metadata: metadata); + Logs().v('[VOIP] answer res => $res'); + } + + Future gotCallFeedsForAnswer(List callFeeds) async { + if (state == CallState.kEnded) return; + + waitForLocalAVStream = false; + + callFeeds.forEach((element) { + // Safari can't send a MediaStream to multiple sources, so clone it + addLocalStream(element.stream!.clone(), element.purpose); + }); + + answer(); + } + + Future placeCallWithStreams(List callFeeds, + [bool requestScreenshareFeed = false]) async { + opts.dir = CallDirection.kOutgoing; + + voip.calls[callId] = this; + + // create the peer connection now so it can be gathering candidates while we get user + // media (assuming a candidate pool size is configured) + await _preparePeerConnection(); + gotCallFeedsForInvite(callFeeds, requestScreenshareFeed); + } + + void gotCallFeedsForInvite(List callFeeds, + [bool requestScreenshareFeed = false]) { + if (successor != null) { + successor!.gotCallFeedsForAnswer(callFeeds); + return; + } + if (state == CallState.kEnded) { + cleanUp(); + return; + } + + callFeeds.forEach((element) { + addLocalStream(element.stream!.clone(), element.purpose); + }); + + if (requestScreenshareFeed) { + pc!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: + RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly)); + } + + setCallState(CallState.kCreateOffer); + + Logs().d('gotUserMediaForInvite'); + // Now we wait for the negotiationneeded event + } + void initWithHangup() { setCallState(CallState.kEnded); } @@ -414,6 +532,10 @@ class CallSession { await pc!.setRemoteDescription(answer); remoteCandidates.forEach((candidate) => pc!.addCandidate(candidate)); } + + /// Send select_answer event. + await sendSelectCallAnswer( + opts.room, callId, lifetimeMs, localPartyId, remotePartyId!); } void onNegotiateReceived( @@ -442,8 +564,8 @@ class CallSession { await pc!.setRemoteDescription(description); if (description.type == 'offer') { final answer = await pc!.createAnswer({}); - await voip.sendCallNegotiate( - opts.room, callId, lifetimeMs, localPartyId, answer.sdp!, + await sendCallNegotiate( + room, callId, lifetimeMs, localPartyId, answer.sdp!, type: answer.type!); await pc!.setLocalDescription(answer); } @@ -547,7 +669,7 @@ class CallSession { setScreensharingEnabled(false); }; }); - _addLocalStream(stream, SDPStreamMetadataPurpose.Screenshare); + addLocalStream(stream, SDPStreamMetadataPurpose.Screenshare); return true; } catch (err) { fireCallEvent(CallEvent.kError); @@ -569,7 +691,7 @@ class CallSession { } } - void _addLocalStream(MediaStream stream, String purpose, + void addLocalStream(MediaStream stream, String purpose, {bool addToPeerConnection = true}) async { final existingStream = getLocalStreams.where((element) => element.purpose == purpose); @@ -577,19 +699,19 @@ class CallSession { existingStream.first.setNewStream(stream); } else { final newStream = WrappedMediaStream( - renderer: voip.delegate.createRenderer(), - userId: client.userID!, - room: opts.room, - stream: stream, - purpose: purpose, - client: client, - audioMuted: stream.getAudioTracks().isEmpty, - videoMuted: stream.getVideoTracks().isEmpty, - isWeb: voip.delegate.isWeb, - ); + renderer: voip.delegate.createRenderer(), + userId: client.userID!, + room: opts.room, + stream: stream, + purpose: purpose, + client: client, + audioMuted: stream.getAudioTracks().isEmpty, + videoMuted: stream.getVideoTracks().isEmpty, + isWeb: voip.delegate.isWeb, + isGroupCall: groupCallId != null); await newStream.initialize(); streams.add(newStream); - fireCallEvent(CallEvent.kFeedsChanged); + onStreamAdd.add(newStream); } if (addToPeerConnection) { @@ -604,7 +726,6 @@ class CallSession { usermediaSenders.add(await pc!.addTrack(track, stream)); }); } - fireCallEvent(CallEvent.kFeedsChanged); } if (purpose == SDPStreamMetadataPurpose.Usermedia) { @@ -614,10 +735,12 @@ class CallSession { audioTrack.enableSpeakerphone(speakerOn); } } + + fireCallEvent(CallEvent.kFeedsChanged); } void _addRemoteStream(MediaStream stream) async { - //const userId = this.getOpponentMember().userId; + //final userId = remoteUser.id; final metadata = remoteSDPStreamMetadata!.sdpStreamMetadatas[stream.id]; if (metadata == null) { Logs().i( @@ -637,26 +760,74 @@ class CallSession { existingStream.first.setNewStream(stream); } else { final newStream = WrappedMediaStream( - renderer: voip.delegate.createRenderer(), - userId: remoteUser.id, - room: opts.room, - stream: stream, - purpose: purpose, - client: client, - audioMuted: audioMuted, - videoMuted: videoMuted, - isWeb: voip.delegate.isWeb, - ); + renderer: voip.delegate.createRenderer(), + userId: remoteUser!.id, + room: opts.room, + stream: stream, + purpose: purpose, + client: client, + audioMuted: audioMuted, + videoMuted: videoMuted, + isWeb: voip.delegate.isWeb, + isGroupCall: groupCallId != null); await newStream.initialize(); streams.add(newStream); + onStreamAdd.add(newStream); } fireCallEvent(CallEvent.kFeedsChanged); Logs().i('Pushed remote stream (id="${stream.id}", purpose=$purpose)'); } + void deleteAllStreams() { + streams.forEach((stream) async { + if (stream.isLocal() || groupCallId == null) { + await stream.dispose(); + } + }); + streams.clear(); + fireCallEvent(CallEvent.kFeedsChanged); + } + + void deleteFeedByStream(MediaStream stream) { + final index = + streams.indexWhere((element) => element.stream!.id == stream.id); + if (index == -1) { + Logs().w('Didn\'t find the feed with stream id ${stream.id} to delete'); + return; + } + final wstream = streams.elementAt(index); + onStreamRemoved.add(wstream); + deleteStream(wstream); + } + + void deleteStream(WrappedMediaStream stream) { + stream.dispose(); + streams.removeAt(streams.indexOf(stream)); + fireCallEvent(CallEvent.kFeedsChanged); + } + + void removeLocalStream(WrappedMediaStream callFeed) { + final senderArray = callFeed.purpose == SDPStreamMetadataPurpose.Usermedia + ? usermediaSenders + : screensharingSenders; + + senderArray.forEach((element) async { + await pc!.removeTrack(element); + }); + + if (callFeed.purpose == SDPStreamMetadataPurpose.Screenshare) { + stopMediaStream(callFeed.stream); + } + + // Empty the array + senderArray.removeRange(0, senderArray.length); + onStreamRemoved.add(callFeed); + deleteStream(callFeed); + } + void setCallState(CallState newState) { state = newState; - _callStateController.add(newState); + onCallStateChanged.add(newState); fireCallEvent(CallEvent.kState); } @@ -732,8 +903,7 @@ class CallSession { video_muted: localUserMediaStream!.stream!.getVideoTracks().isEmpty) }); - final res = await voip.sendAnswerCall( - opts.room, callId, answer.sdp!, localPartyId, + final res = await sendAnswerCall(room, callId, answer.sdp!, localPartyId, type: answer.type!, capabilities: callCapabilities, metadata: metadata); @@ -756,7 +926,7 @@ class CallSession { } Logs().d('[VOIP] Rejecting call: $callId'); terminate(CallParty.kLocal, CallErrorCode.UserHangup, true); - voip.sendCallReject(opts.room, callId, lifetimeMs, localPartyId); + sendCallReject(room, callId, lifetimeMs, localPartyId); } void hangup([String? reason, bool suppressEvent = true]) async { @@ -767,8 +937,8 @@ class CallSession { CallParty.kLocal, reason ?? CallErrorCode.UserHangup, !suppressEvent); try { - final res = await voip.sendHangupCall( - opts.room, callId, localPartyId, 'userHangup'); + final res = + await sendHangupCall(room, callId, localPartyId, 'userHangup'); Logs().v('[VOIP] hangup res => $res'); } catch (e) { Logs().v('[VOIP] hangup error => ${e.toString()}'); @@ -786,7 +956,7 @@ class CallSession { Logs().e('Unable to find a track to send DTMF on'); } - void terminate(CallParty party, String hangupReason, bool shouldEmit) async { + void terminate(CallParty party, String reason, bool shouldEmit) async { if (state == CallState.kEnded) { return; } @@ -798,12 +968,15 @@ class CallSession { ringingTimer = null; hangupParty = party; - hangupReason = hangupReason; + hangupReason = reason; setCallState(CallState.kEnded); voip.currentCID = null; voip.calls.remove(callId); cleanUp(); + + onCallHangup.add(this); + voip.delegate.handleCallEnded(this); if (shouldEmit) { fireCallEvent(CallEvent.kHangup); @@ -848,8 +1021,8 @@ class CallSession { ..transferee = false; final metadata = _getLocalSDPStreamMetadata(); if (state == CallState.kCreateOffer) { - await voip.sendInviteToCall( - opts.room, callId, lifetimeMs, localPartyId, null, offer.sdp!, + await sendInviteToCall( + room, callId, lifetimeMs, localPartyId, null, offer.sdp!, capabilities: callCapabilities, metadata: metadata); inviteOrAnswerSent = true; setCallState(CallState.kInviteSent); @@ -862,8 +1035,8 @@ class CallSession { inviteTimer = null; }); } else { - await voip.sendCallNegotiate( - opts.room, callId, lifetimeMs, localPartyId, offer.sdp!, + await sendCallNegotiate( + room, callId, lifetimeMs, localPartyId, offer.sdp!, type: offer.type!, capabilities: callCapabilities, metadata: metadata); @@ -926,7 +1099,7 @@ class CallSession { } } - void onAnsweredElsewhere(String msg) { + void onAnsweredElsewhere() { Logs().d('Call ID $callId answered elsewhere'); terminate(CallParty.kRemote, CallErrorCode.AnsweredElsewhere, true); } @@ -955,8 +1128,8 @@ class CallSession { _setTracksEnabled(localUserMediaStream?.stream!.getVideoTracks() ?? [], !vidShouldBeMuted); - await opts.voip.sendSDPStreamMetadataChanged( - opts.room, callId, localPartyId, _getLocalSDPStreamMetadata()); + await sendSDPStreamMetadataChanged( + room, callId, localPartyId, _getLocalSDPStreamMetadata()); } void _setTracksEnabled(List tracks, bool enabled) { @@ -1034,11 +1207,20 @@ class CallSession { if (event.streams.isNotEmpty) { final stream = event.streams[0]; _addRemoteStream(stream); + stream.getVideoTracks().forEach((track) { + track.onEnded = () { + _removeStream(stream); + }; + }); } }; return pc; } + void createDataChannel(String label, RTCDataChannelInit dataChannelDict) { + pc?.createDataChannel(label, dataChannelDict); + } + void tryRemoveStopedStreams() { final removedStreams = {}; streams.forEach((stream) { @@ -1063,6 +1245,7 @@ class CallSession { } final wpstream = it.first; streams.removeWhere((element) => element.stream!.id == stream.id); + onStreamRemoved.add(wpstream); fireCallEvent(CallEvent.kFeedsChanged); await wpstream.dispose(); } @@ -1085,8 +1268,8 @@ class CallSession { localCandidates.forEach((element) { candidates.add(element.toMap()); }); - final res = await voip.sendCallCandidates( - opts.room, callId, localPartyId, candidates); + final res = + await sendCallCandidates(opts.room, callId, localPartyId, candidates); Logs().v('[VOIP] sendCallCandidates res => $res'); } catch (e) { Logs().v('[VOIP] sendCallCandidates e => ${e.toString()}'); @@ -1094,10 +1277,11 @@ class CallSession { } void fireCallEvent(CallEvent event) { - _callEventController.add(event); + onCallEventChanged.add(event); Logs().i('CallEvent: ${event.toString()}'); switch (event) { case CallEvent.kFeedsChanged: + onCallStreamsChanged.add(this); break; case CallEvent.kState: Logs().i('CallState: ${state.toString()}'); @@ -1155,365 +1339,6 @@ class CallSession { terminate(CallParty.kRemote, CallErrorCode.AnsweredElsewhere, true); } } -} - -class VoIP { - TurnServerCredentials? _turnServerCredentials; - Map calls = {}; - String? currentCID; - String? get localPartyId => client.deviceID; - final Client client; - final WebRTCDelegate delegate; - - VoIP(this.client, this.delegate) : super() { - client.onCallInvite.stream.listen(onCallInvite); - client.onCallAnswer.stream.listen(onCallAnswer); - client.onCallCandidates.stream.listen(onCallCandidates); - client.onCallHangup.stream.listen(onCallHangup); - client.onCallReject.stream.listen(onCallReject); - client.onCallNegotiate.stream.listen(onCallNegotiate); - client.onCallReplaces.stream.listen(onCallReplaces); - client.onCallSelectAnswer.stream.listen(onCallSelectAnswer); - client.onSDPStreamMetadataChangedReceived.stream - .listen(onSDPStreamMetadataChangedReceived); - client.onAssertedIdentityReceived.stream.listen(onAssertedIdentityReceived); - } - - Future onCallInvite(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - - Logs().v( - '[VOIP] onCallInvite ${event.senderId} => ${client.userID}, \ncontent => ${event.content.toString()}'); - - final String callId = event.content['call_id']; - final String partyId = event.content['party_id']; - final int lifetime = event.content['lifetime']; - - if (currentCID != null) { - // Only one session at a time. - Logs().v('[VOIP] onCallInvite: There is already a session.'); - await sendHangupCall(event.room, callId, localPartyId!, 'userBusy'); - return; - } - if (calls[callId] != null) { - // Session already exist. - Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.'); - return; - } - - if (event.content['capabilities'] != null) { - final capabilities = - CallCapabilities.fromJson(event.content['capabilities']); - Logs().v( - '[VOIP] CallCapabilities: dtmf => ${capabilities.dtmf}, transferee => ${capabilities.transferee}'); - } - - var callType = CallType.kVoice; - SDPStreamMetadata? sdpStreamMetadata; - if (event.content[sdpStreamMetadataKey] != null) { - sdpStreamMetadata = - SDPStreamMetadata.fromJson(event.content[sdpStreamMetadataKey]); - sdpStreamMetadata.sdpStreamMetadatas - .forEach((streamId, SDPStreamPurpose purpose) { - Logs().v( - '[VOIP] [$streamId] => purpose: ${purpose.purpose}, audioMuted: ${purpose.audio_muted}, videoMuted: ${purpose.video_muted}'); - - if (!purpose.video_muted) { - callType = CallType.kVideo; - } - }); - } else { - callType = getCallType(event.content['offer']['sdp']); - } - - final opts = CallOptions() - ..voip = this - ..callId = callId - ..dir = CallDirection.kIncoming - ..type = callType - ..room = event.room - ..localPartyId = localPartyId! - ..iceServers = await getIceSevers(); - - final newCall = createNewCall(opts); - newCall.remotePartyId = partyId; - newCall.remoteUser = (await event.fetchSenderUser()) ?? - User(event.senderId, room: event.room); - final offer = RTCSessionDescription( - event.content['offer']['sdp'], - event.content['offer']['type'], - ); - await newCall - .initWithInvite(callType, offer, sdpStreamMetadata, lifetime) - .then((_) { - // Popup CallingPage for incoming call. - if (!delegate.isBackgroud) { - delegate.handleNewCall(newCall); - } - }); - currentCID = callId; - - if (delegate.isBackgroud) { - /// Forced to enable signaling synchronization until the end of the call. - client.backgroundSync = true; - - ///TODO: notify the callkeep that the call is incoming. - } - // Play ringtone - delegate.playRingtone(); - } - - void onCallAnswer(Event event) async { - Logs().v('[VOIP] onCallAnswer => ${event.content.toString()}'); - final String callId = event.content['call_id']; - final String partyId = event.content['party_id']; - - final call = calls[callId]; - if (call != null) { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - if (!call._answeredByUs) { - delegate.stopRingtone(); - } - if (call.state == CallState.kRinging) { - call.onAnsweredElsewhere('Call ID ' + callId + ' answered elsewhere'); - } - return; - } - - call.remotePartyId = partyId; - call.remoteUser = await event.fetchSenderUser() ?? - User(event.senderId, room: event.room); - - final answer = RTCSessionDescription( - event.content['answer']['sdp'], event.content['answer']['type']); - - SDPStreamMetadata? metadata; - if (event.content[sdpStreamMetadataKey] != null) { - metadata = - SDPStreamMetadata.fromJson(event.content[sdpStreamMetadataKey]); - } - call.onAnswerReceived(answer, metadata); - - /// Send select_answer event. - await sendSelectCallAnswer( - event.room, callId, lifetimeMs, localPartyId!, call.remotePartyId!); - } else { - Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!'); - } - } - - void onCallCandidates(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - Logs().v('[VOIP] onCallCandidates => ${event.content.toString()}'); - final String callId = event.content['call_id']; - final call = calls[callId]; - if (call != null) { - call.onCandidatesReceived(event.content['candidates']); - } else { - Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!'); - } - } - - void onCallHangup(Event event) async { - // stop play ringtone, if this is an incoming call - if (!delegate.isBackgroud) { - delegate.stopRingtone(); - } - Logs().v('[VOIP] onCallHangup => ${event.content.toString()}'); - final String callId = event.content['call_id']; - final call = calls[callId]; - if (call != null) { - // hangup in any case, either if the other party hung up or we did on another device - call.terminate(CallParty.kRemote, - event.content['reason'] ?? CallErrorCode.UserHangup, true); - } else { - Logs().v('[VOIP] onCallHangup: Session [$callId] not found!'); - } - currentCID = null; - } - - void onCallReject(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('Reject received for call ID ' + callId); - - final call = calls[callId]; - if (call != null) { - call.onRejectReceived(event.content['reason']); - } else { - Logs().v('[VOIP] onCallHangup: Session [$callId] not found!'); - } - } - - void onCallReplaces(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('onCallReplaces received for call ID ' + callId); - final call = calls[callId]; - if (call != null) { - //TODO: handle replaces - } - } - - void onCallSelectAnswer(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('SelectAnswer received for call ID ' + callId); - final call = calls[callId]; - final String selectedPartyId = event.content['selected_party_id']; - - if (call != null) { - call.onSelectAnswerReceived(selectedPartyId); - } - } - - void onSDPStreamMetadataChangedReceived(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('SDP Stream metadata received for call ID ' + callId); - final call = calls[callId]; - if (call != null) { - if (event.content[sdpStreamMetadataKey] == null) { - Logs().d('SDP Stream metadata is null'); - return; - } - call.onSDPStreamMetadataReceived( - SDPStreamMetadata.fromJson(event.content[sdpStreamMetadataKey])); - } - } - - void onAssertedIdentityReceived(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('Asserted identity received for call ID ' + callId); - final call = calls[callId]; - if (call != null) { - if (event.content['asserted_identity'] == null) { - Logs().d('asserted_identity is null '); - return; - } - call.onAssertedIdentityReceived( - AssertedIdentity.fromJson(event.content['asserted_identity'])); - } - } - - void onCallNegotiate(Event event) async { - if (event.senderId == client.userID) { - // Ignore messages to yourself. - return; - } - final String callId = event.content['call_id']; - Logs().d('Negotiate received for call ID ' + callId); - final call = calls[callId]; - if (call != null) { - final description = event.content['description']; - try { - SDPStreamMetadata? metadata; - if (event.content[sdpStreamMetadataKey] != null) { - metadata = - SDPStreamMetadata.fromJson(event.content[sdpStreamMetadataKey]); - } - call.onNegotiateReceived(metadata, - RTCSessionDescription(description['sdp'], description['type'])); - } catch (err) { - Logs().e('Failed to complete negotiation ${err.toString()}'); - } - } - } - - CallType getCallType(String sdp) { - try { - final session = sdp_transform.parse(sdp); - if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) { - return CallType.kVideo; - } - } catch (err) { - Logs().e('Failed to getCallType ${err.toString()}'); - } - - return CallType.kVoice; - } - - Future requestTurnServerCredentials() async { - return true; - } - - Future>> getIceSevers() async { - if (_turnServerCredentials == null) { - try { - _turnServerCredentials = await client.getTurnServer(); - } catch (e) { - Logs().v('[VOIP] getTurnServerCredentials error => ${e.toString()}'); - } - } - - if (_turnServerCredentials == null) { - return []; - } - - return [ - { - 'username': _turnServerCredentials!.username, - 'credential': _turnServerCredentials!.password, - 'urls': _turnServerCredentials!.uris[0] - } - ]; - } - - Future inviteToCall(String roomId, CallType type) async { - final room = client.getRoomById(roomId); - if (room == null) { - Logs().v('[VOIP] Invalid room id [$roomId].'); - return Null as CallSession; - } - final callId = 'cid${DateTime.now().millisecondsSinceEpoch}'; - final opts = CallOptions() - ..callId = callId - ..type = type - ..dir = CallDirection.kOutgoing - ..room = room - ..voip = this - ..localPartyId = localPartyId! - ..iceServers = await getIceSevers(); - - final newCall = createNewCall(opts); - currentCID = callId; - await newCall.initOutboundCall(type).then((_) { - if (!delegate.isBackgroud) { - delegate.handleNewCall(newCall); - } - }); - currentCID = callId; - return newCall; - } - - CallSession createNewCall(CallOptions opts) { - final call = CallSession(opts); - calls[opts.callId] = call; - return call; - } /// This is sent by the caller when they wish to establish a call. /// [callId] is a unique identifier for the call. @@ -1537,6 +1362,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'lifetime': lifetime, 'offer': {'sdp': sdp, 'type': type}, @@ -1569,6 +1395,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'lifetime': lifetime, 'selected_party_id': selected_party_id, @@ -1594,6 +1421,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'lifetime': lifetime, }; @@ -1622,6 +1450,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'lifetime': lifetime, 'description': {'sdp': sdp, 'type': type}, @@ -1668,6 +1497,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'candidates': candidates, }; @@ -1696,6 +1526,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'answer': {'sdp': sdp, 'type': type}, if (capabilities != null) 'capabilities': capabilities.toJson(), @@ -1721,6 +1552,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, if (hangupCause != null) 'reason': hangupCause, }; @@ -1753,6 +1585,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, sdpStreamMetadataKey: metadata.toJson(), }; @@ -1777,6 +1610,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, ...callReplaces.toJson(), }; @@ -1801,6 +1635,7 @@ class VoIP { final content = { 'call_id': callId, 'party_id': party_id, + if (groupCallId != null) 'conf_id': groupCallId, 'version': version, 'asserted_identity': assertedIdentity.toJson(), }; @@ -1819,20 +1654,51 @@ class VoIP { String? txid, }) async { txid ??= client.generateUniqueTransactionId(); - final mustEncrypt = room.encrypted && client.encryptionEnabled; + if (opponentDeviceId != null) { + final toDeviceSeq = this.toDeviceSeq++; - final sendMessageContent = mustEncrypt - ? await client.encryption! - .encryptGroupMessagePayload(room.id, content, type: type) - : content; - return await client.sendMessage( - room.id, - sendMessageContent.containsKey('ciphertext') - ? EventTypes.Encrypted - : type, - txid, - sendMessageContent, - ); + if (mustEncrypt) { + await client.sendToDeviceEncrypted( + [ + client.userDeviceKeys[invitee ?? remoteUser!.id]! + .deviceKeys[opponentDeviceId]! + ], + type, + { + ...content, + 'device_id': client.deviceID!, + 'seq': toDeviceSeq, + 'dest_session_id': opponentSessionId, + 'sender_session_id': client.groupCallSessionId, + }); + } else { + final data = >>{}; + data[invitee ?? remoteUser!.id] = { + opponentDeviceId!: { + ...content, + 'device_id': client.deviceID!, + 'seq': toDeviceSeq, + 'dest_session_id': opponentSessionId, + 'sender_session_id': client.groupCallSessionId, + } + }; + await client.sendToDevice(type, txid, data); + } + return ''; + } else { + final sendMessageContent = mustEncrypt + ? await client.encryption! + .encryptGroupMessagePayload(room.id, content, type: type) + : content; + return await client.sendMessage( + room.id, + sendMessageContent.containsKey('ciphertext') + ? EventTypes.Encrypted + : type, + txid, + sendMessageContent, + ); + } } } diff --git a/lib/src/voip/group_call.dart b/lib/src/voip/group_call.dart new file mode 100644 index 00000000..e8bfd5f6 --- /dev/null +++ b/lib/src/voip/group_call.dart @@ -0,0 +1,1242 @@ +/* + * 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 . + */ + +import 'dart:async'; +import 'dart:core'; + +import 'package:matrix/matrix.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; + +/// TODO(@duan): Need to add voice activity detection mechanism +/// const int SPEAKING_THRESHOLD = -60; // dB + +class GroupCallIntent { + static String Ring = 'm.ring'; + static String Prompt = 'm.prompt'; + static String Room = 'm.room'; +} + +class GroupCallType { + static String Video = 'm.video'; + static String Voice = 'm.voice'; +} + +class GroupCallTerminationReason { + static String CallEnded = 'call_ended'; +} + +class GroupCallEvent { + static String GroupCallStateChanged = 'group_call_state_changed'; + static String ActiveSpeakerChanged = 'active_speaker_changed'; + static String CallsChanged = 'calls_changed'; + static String UserMediaStreamsChanged = 'user_media_feeds_changed'; + static String ScreenshareStreamsChanged = 'screenshare_feeds_changed'; + static String LocalScreenshareStateChanged = + 'local_screenshare_state_changed'; + static String LocalMuteStateChanged = 'local_mute_state_changed'; + static String ParticipantsChanged = 'participants_changed'; + static String Error = 'error'; +} + +class GroupCallErrorCode { + static String NoUserMedia = 'no_user_media'; + static String UnknownDevice = 'unknown_device'; +} + +class GroupCallError extends Error { + final String 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()}'; + } +} + +abstract class ISendEventResponse { + String? event_id; +} + +class IGroupCallRoomMemberFeed { + String? purpose; + // TODO: Sources for adaptive bitrate + IGroupCallRoomMemberFeed.fromJson(Map json) { + purpose = json['purpose']; + } + Map toJson() { + final data = {}; + data['purpose'] = purpose; + return data; + } +} + +class IGroupCallRoomMemberDevice { + String? device_id; + String? session_id; + List feeds = []; + IGroupCallRoomMemberDevice.fromJson(Map json) { + device_id = json['device_id']; + session_id = json['session_id']; + if (json['feeds'] != null) { + feeds = (json['feeds'] as List) + .map((feed) => IGroupCallRoomMemberFeed.fromJson(feed)) + .toList(); + } + } + + Map toJson() { + final data = {}; + data['device_id'] = device_id; + data['session_id'] = session_id; + data['feeds'] = feeds.map((feed) => feed.toJson()).toList(); + return data; + } +} + +class IGroupCallRoomMemberCallState { + String? call_id; + List? foci; + List devices = []; + IGroupCallRoomMemberCallState.formJson(Map json) { + call_id = json['m.call_id']; + if (json['m.foci'] != null) { + foci = (json['m.foci'] as List).cast(); + } + if (json['m.devices'] != null) { + devices = (json['m.devices'] as List) + .map((device) => IGroupCallRoomMemberDevice.fromJson(device)) + .toList(); + } + } + Map toJson() { + final data = {}; + data['m.call_id'] = call_id; + if (foci != null) { + data['m.foci'] = foci; + } + if (devices.isNotEmpty) { + data['m.devices'] = devices.map((e) => e.toJson()).toList(); + } + return data; + } +} + +class IGroupCallRoomMemberState { + List calls = []; + IGroupCallRoomMemberState.fromJson(Map json) { + if (json['m.calls'] != null) { + (json['m.calls'] as List).forEach( + (call) => calls.add(IGroupCallRoomMemberCallState.formJson(call))); + } + } +} + +class GroupCallState { + static String LocalCallFeedUninitialized = 'local_call_feed_uninitialized'; + static String InitializingLocalCallFeed = 'initializing_local_call_feed'; + static String LocalCallFeedInitialized = 'local_call_feed_initialized'; + static String Entering = 'entering'; + static String Entered = 'entered'; + static String Ended = 'ended'; +} + +abstract class ICallHandlers { + Function(List feeds)? onCallFeedsChanged; + Function(CallState state, CallState oldState)? onCallStateChanged; + Function(CallSession call)? onCallHangup; + Function(CallSession newCall)? onCallReplaced; +} + +class GroupCall { + // Config + var activeSpeakerInterval = 1000; + var retryCallInterval = 5000; + var participantTimeout = 1000 * 15; + final Client client; + final VoIP voip; + final Room room; + final String intent; + final String type; + final bool dataChannelsEnabled; + final RTCDataChannelInit? dataChannelOptions; + String state = GroupCallState.LocalCallFeedUninitialized; + StreamSubscription? _callSubscription; + + String? activeSpeaker; // userId + WrappedMediaStream? localUserMediaStream; + WrappedMediaStream? localScreenshareStream; + String? localDesktopCapturerSourceId; + List calls = []; + List participants = []; + List userMediaStreams = []; + List screenshareStreams = []; + late String groupCallId; + + GroupCallError? lastError; + + Map callHandlers = {}; + + Timer? activeSpeakerLoopTimeout; + + Timer? retryCallLoopTimeout; + Map retryCallCounts = {}; + + final StreamController onGroupCallFeedsChanged = + StreamController.broadcast(); + + final StreamController onGroupCallState = + StreamController.broadcast(); + + final StreamController onGroupCallEvent = + StreamController.broadcast(); + + final StreamController onStreamAdd = + StreamController.broadcast(); + + final StreamController onStreamRemoved = + StreamController.broadcast(); + + GroupCall({ + String? groupCallId, + required this.client, + required this.voip, + required this.room, + required this.type, + required this.intent, + required this.dataChannelsEnabled, + required this.dataChannelOptions, + }) { + this.groupCallId = groupCallId ?? genCallID(); + } + + GroupCall create() { + voip.groupCalls[groupCallId] = this; + voip.groupCalls[room.id] = this; + + client.setRoomStateWithKey( + room.id, + EventTypes.GroupCallPrefix, + groupCallId, + { + 'm.intent': intent, + 'm.type': type, + // TODO: Specify datachannels + 'dataChannelsEnabled': dataChannelsEnabled, + 'dataChannelOptions': dataChannelOptions?.toMap() ?? {}, + 'groupCallId': groupCallId, + }, + ); + + return this; + } + + String get avatarName => + getUser().calcDisplayname(mxidLocalPartFallback: false); + + String? get displayName => getUser().displayName; + + User getUser() { + return room.unsafeGetUserFromMemoryOrFallback(client.userID!); + } + + Future> getStateEventsList(String type) async { + final roomStates = await client.getRoomState(room.id); + roomStates.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); + final events = []; + roomStates.forEach((evt) { + if (evt.type == type) { + events.add(evt); + } + }); + return events; + } + + Future getStateEvent(String type, [String? userId]) async { + final roomStates = await client.getRoomState(room.id); + roomStates.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); + MatrixEvent? event; + roomStates.forEach((value) { + if (value.type == type && (userId == null || value.senderId == userId)) { + event = value; + } + }); + return event; + } + + void setState(String newState) { + state = newState; + onGroupCallEvent.add(GroupCallEvent.GroupCallStateChanged); + } + + List getLocalStreams() { + final feeds = []; + + if (localUserMediaStream != null) { + feeds.add(localUserMediaStream!); + } + + if (localScreenshareStream != null) { + feeds.add(localScreenshareStream!); + } + + return feeds; + } + + bool hasLocalParticipant() { + final userId = client.userID; + return participants.indexWhere((member) => member.id == userId) != -1; + } + + Future _getUserMedia(CallType type) async { + final mediaConstraints = { + 'audio': true, + 'video': type == CallType.kVideo + ? { + 'mandatory': { + 'minWidth': '640', + 'minHeight': '480', + 'minFrameRate': '30', + }, + 'facingMode': 'user', + 'optional': [], + } + : false, + }; + try { + return await voip.delegate.mediaDevices.getUserMedia(mediaConstraints); + } catch (e) { + setState(GroupCallState.LocalCallFeedUninitialized); + } + return Null as MediaStream; + } + + Future _getDisplayMedia() async { + final mediaConstraints = { + 'audio': false, + 'video': true, + }; + try { + return await voip.delegate.mediaDevices.getDisplayMedia(mediaConstraints); + } catch (e) { + setState(GroupCallState.LocalCallFeedUninitialized); + } + return Null as MediaStream; + } + + /// Initializes the local user media stream. + /// The media stream must be prepared before the group call enters. + Future initLocalStream() async { + if (state != GroupCallState.LocalCallFeedUninitialized) { + throw Exception('Cannot initialize local call feed in the $state state.'); + } + + setState(GroupCallState.InitializingLocalCallFeed); + + MediaStream stream; + + try { + stream = await _getUserMedia( + type == GroupCallType.Video ? CallType.kVideo : CallType.kVoice); + } catch (error) { + setState(GroupCallState.LocalCallFeedUninitialized); + rethrow; + } + + final userId = client.userID; + + final newStream = WrappedMediaStream( + renderer: voip.delegate.createRenderer(), + stream: stream, + userId: userId!, + room: room, + client: client, + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: stream.getAudioTracks().isEmpty, + videoMuted: stream.getVideoTracks().isEmpty, + isWeb: voip.delegate.isWeb, + isGroupCall: true); + + localUserMediaStream = newStream; + await localUserMediaStream!.initialize(); + addUserMediaStream(newStream); + + setState(GroupCallState.LocalCallFeedInitialized); + + return newStream; + } + + void updateLocalUsermediaStream(WrappedMediaStream stream) { + if (localUserMediaStream != null) { + final oldStream = localUserMediaStream!.stream; + localUserMediaStream!.setNewStream(stream.stream!); + stopMediaStream(oldStream); + } + } + + /// enter the group call. + void enter() async { + if (!(state == GroupCallState.LocalCallFeedUninitialized || + state == GroupCallState.LocalCallFeedInitialized)) { + throw Exception('Cannot enter call in the $state state'); + } + + if (state == GroupCallState.LocalCallFeedUninitialized) { + await initLocalStream(); + } + + _addParticipant( + (await room.requestUser(client.userID!, ignoreErrors: true))!); + + await sendMemberStateEvent(); + + activeSpeaker = null; + + setState(GroupCallState.Entered); + + Logs().v('Entered group call $groupCallId'); + + _callSubscription = voip.onIncomingCall.stream.listen(onIncomingCall); + + for (final call in calls) { + onIncomingCall(call); + } + + // Set up participants for the members currently in the room. + // Other members will be picked up by the RoomState.members event. + + final memberStateEvents = + await getStateEventsList(EventTypes.GroupCallMemberPrefix); + + memberStateEvents.forEach((stateEvent) { + onMemberStateChanged(stateEvent); + }); + + retryCallLoopTimeout = Timer.periodic( + Duration(milliseconds: retryCallInterval), onRetryCallLoop); + onActiveSpeakerLoop(); + + voip.currentGroupCID = groupCallId; + + voip.delegate.handleNewGroupCall(this); + } + + void dispose() { + if (localUserMediaStream != null) { + removeUserMediaStream(localUserMediaStream!); + localUserMediaStream = null; + } + + if (localScreenshareStream != null) { + stopMediaStream(localScreenshareStream!.stream); + removeScreenshareStream(localScreenshareStream!); + localScreenshareStream = null; + localDesktopCapturerSourceId = null; + } + + if (state != GroupCallState.Entered) { + return; + } + + _removeParticipant(client.userID!); + + removeMemberStateEvent(); + + final callsCopy = calls.toList(); + callsCopy.forEach((element) { + removeCall(element, CallErrorCode.UserHangup); + }); + + activeSpeaker = null; + activeSpeakerLoopTimeout?.cancel(); + retryCallCounts.clear(); + retryCallLoopTimeout?.cancel(); + _callSubscription?.cancel(); + } + + void leave() { + dispose(); + setState(GroupCallState.LocalCallFeedUninitialized); + voip.currentGroupCID = null; + voip.delegate.handleGroupCallEnded(this); + } + + /// terminate group call. + void terminate({bool emitStateEvent = true}) async { + dispose(); + + participants = []; + //TODO(duan): remove this + /* client.removeListener( + 'RoomState.members', + onMemberStateChanged, + ); + */ + voip.groupCalls.remove(room.id); + + if (emitStateEvent) { + final existingStateEvent = await getStateEvent( + EventTypes.GroupCallPrefix, + groupCallId, + ); + + await client.setRoomStateWithKey( + room.id, EventTypes.GroupCallPrefix, groupCallId, { + ...existingStateEvent!.content, + 'm.terminated': GroupCallTerminationReason.CallEnded, + }); + } + voip.delegate.handleGroupCallEnded(this); + setState(GroupCallState.Ended); + } + + bool get isLocalVideoMuted { + if (localUserMediaStream != null) { + return localUserMediaStream!.isVideoMuted(); + } + + return true; + } + + bool get isMicrophoneMuted { + if (localUserMediaStream != null) { + return localUserMediaStream!.isAudioMuted(); + } + + return true; + } + + Future setMicrophoneMuted(bool muted) async { + if (!await hasAudioDevice()) { + return false; + } + + if (localUserMediaStream != null) { + localUserMediaStream!.setAudioMuted(muted); + setTracksEnabled(localUserMediaStream!.stream!.getAudioTracks(), !muted); + } + + calls.forEach((call) { + call.setMicrophoneMuted(muted); + }); + + onGroupCallEvent.add(GroupCallEvent.LocalMuteStateChanged); + return true; + } + + Future setLocalVideoMuted(bool muted) async { + if (!await hasVideoDevice()) { + return false; + } + + if (localUserMediaStream != null) { + localUserMediaStream!.setVideoMuted(muted); + setTracksEnabled(localUserMediaStream!.stream!.getVideoTracks(), !muted); + } + + calls.forEach((call) { + call.setLocalVideoMuted(muted); + }); + + onGroupCallEvent.add(GroupCallEvent.LocalMuteStateChanged); + return true; + } + + bool get screensharingEnabled => isScreensharing(); + + Future setScreensharingEnabled( + bool enabled, + String desktopCapturerSourceId, + ) async { + if (enabled == isScreensharing()) { + return enabled; + } + + if (enabled) { + try { + Logs().v('Asking for screensharing permissions...'); + final stream = await _getDisplayMedia(); + stream.getTracks().forEach((track) { + track.onEnded = () { + setScreensharingEnabled(false, ''); + track.onEnded = null; + }; + }); + Logs().v( + 'Screensharing permissions granted. Setting screensharing enabled on all calls'); + localDesktopCapturerSourceId = desktopCapturerSourceId; + localScreenshareStream = WrappedMediaStream( + renderer: voip.delegate.createRenderer(), + stream: stream, + userId: client.userID!, + room: room, + client: client, + purpose: SDPStreamMetadataPurpose.Screenshare, + audioMuted: stream.getAudioTracks().isEmpty, + videoMuted: stream.getVideoTracks().isEmpty, + isWeb: voip.delegate.isWeb, + isGroupCall: true); + + addScreenshareStream(localScreenshareStream!); + await localScreenshareStream!.initialize(); + + onGroupCallEvent.add(GroupCallEvent.LocalScreenshareStateChanged); + + calls.forEach((call) { + call.addLocalStream( + localScreenshareStream!.stream!, localScreenshareStream!.purpose); + }); + + await sendMemberStateEvent(); + + return true; + } catch (error) { + Logs().e('Enabling screensharing error', error); + lastError = GroupCallError(GroupCallErrorCode.NoUserMedia, + 'Failed to get screen-sharing stream: ', error); + onGroupCallEvent.add(GroupCallEvent.Error); + return false; + } + } else { + calls.forEach((call) { + call.removeLocalStream(call.localScreenSharingStream!); + }); + stopMediaStream(localScreenshareStream!.stream); + removeScreenshareStream(localScreenshareStream!); + localScreenshareStream = null; + localDesktopCapturerSourceId = null; + await sendMemberStateEvent(); + onGroupCallEvent.add(GroupCallEvent.LocalMuteStateChanged); + return false; + } + } + + bool isScreensharing() { + return localScreenshareStream != null; + } + + void onIncomingCall(CallSession newCall) { + // The incoming calls may be for another room, which we will ignore. + if (newCall.room.id != 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 != groupCallId) { + Logs().v( + 'Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn\'t match the current group call'); + newCall.reject(); + return; + } + + final opponentMemberId = newCall.remoteUser!.id; + final existingCall = getCallByUserId(opponentMemberId); + + if (existingCall != null && existingCall.callId == newCall.callId) { + return; + } + + Logs().v('GroupCall: incoming call from: $opponentMemberId'); + + // Check if the user calling has an existing call and use this call instead. + if (existingCall != null) { + replaceCall(existingCall, newCall); + } else { + addCall(newCall); + } + + newCall.answerWithStreams(getLocalStreams()); + } + + Future sendMemberStateEvent() { + final deviceId = client.deviceID; + return updateMemberCallState(IGroupCallRoomMemberCallState.formJson({ + 'm.call_id': groupCallId, + 'm.devices': [ + { + 'device_id': deviceId, + 'session_id': client.groupCallSessionId, + 'feeds': getLocalStreams() + .map((feed) => ({ + 'purpose': feed.purpose, + })) + .toList(), + // TODO: Add data channels + }, + ], + // TODO 'm.foci' + })); + } + + Future removeMemberStateEvent() { + return updateMemberCallState(); + } + + Future updateMemberCallState( + [IGroupCallRoomMemberCallState? memberCallState]) async { + final localUserId = client.userID; + + final currentStateEvent = + await getStateEvent(EventTypes.GroupCallMemberPrefix, localUserId); + final eventContent = currentStateEvent?.content ?? {}; + var calls = []; + + if (currentStateEvent != null) { + final memberStateEvent = IGroupCallRoomMemberState.fromJson(eventContent); + calls = memberStateEvent.calls; + final existingCallIndex = + calls.indexWhere((element) => groupCallId == element.call_id); + + if (existingCallIndex != -1) { + if (memberCallState != null) { + calls.replaceRange(existingCallIndex, 1, [memberCallState]); + } else { + calls.removeAt(existingCallIndex); + } + } else if (memberCallState != null) { + calls.add(memberCallState); + } + } else if (memberCallState != null) { + calls.add(memberCallState); + } + + final content = { + 'm.calls': calls.map((e) => e.toJson()).toList(), + }; + + await client.setRoomStateWithKey( + room.id, EventTypes.GroupCallMemberPrefix, localUserId!, content); + } + + void onMemberStateChanged(MatrixEvent event) async { + // The member events may be received for another room, which we will ignore. + if (event.roomId != room.id) { + return; + } + + final user = await room.requestUser(event.stateKey!); + + if (user == null) { + return; + } + + final callsState = IGroupCallRoomMemberState.fromJson(event.content); + + if (callsState is List) { + Logs() + .w('Ignoring member state from ${user.id} member not in any calls.'); + _removeParticipant(user.id); + return; + } + + // Currently we only support a single call per room. So grab the first call. + final callState = + callsState.calls.isNotEmpty ? callsState.calls.elementAt(0) : null; + + if (callState == null) { + Logs().w( + 'Room member ${user.id} does not have a valid m.call_id set. Ignoring.'); + _removeParticipant(user.id); + return; + } + + final callId = callState.call_id; + if (callId != null && callId != groupCallId) { + Logs().w( + 'Call id $callId does not match group call id $groupCallId, ignoring.'); + _removeParticipant(user.id); + return; + } + + _addParticipant(user); + + // Don't process your own member. + final localUserId = client.userID; + + if (user.id == localUserId) { + return; + } + + if (state != GroupCallState.Entered) { + return; + } + + // Only initiate a call with a user who has a userId that is lexicographically + // less than your own. Otherwise, that user will call you. + if (localUserId!.compareTo(user.id) > 0) { + Logs().i('Waiting for ${user.id} to send call invite.'); + return; + } + + final existingCall = getCallByUserId(user.id); + + if (existingCall != null) { + return; + } + + final opponentDevice = await getDeviceForMember(user.id); + + if (opponentDevice == null) { + Logs().w('No opponent device found for ${user.id}, ignoring.'); + lastError = GroupCallError( + '400', + GroupCallErrorCode.UnknownDevice, + 'Outgoing Call: No opponent device found for ${user.id}, ignoring.', + ); + onGroupCallEvent.add(GroupCallEvent.Error); + return; + } + + final opts = CallOptions() + ..callId = genCallID() + ..room = room + ..voip = voip + ..dir = CallDirection.kOutgoing + ..localPartyId = client.deviceID! + ..groupCallId = groupCallId + ..type = CallType.kVideo + ..iceServers = []; + + final newCall = voip.createNewCall(opts); + newCall.opponentDeviceId = opponentDevice.device_id; + newCall.opponentSessionId = opponentDevice.session_id; + newCall.remoteUser = await room.requestUser(user.id, ignoreErrors: true); + newCall.invitee = user.id; + + final requestScreenshareFeed = opponentDevice.feeds.indexWhere( + (IGroupCallRoomMemberFeed feed) => + feed.purpose == SDPStreamMetadataPurpose.Screenshare) != + -1; + + await newCall.placeCallWithStreams( + getLocalStreams(), requestScreenshareFeed); + + if (dataChannelsEnabled) { + newCall.createDataChannel('datachannel', dataChannelOptions!); + } + + addCall(newCall); + } + + Future getDeviceForMember(String userId) async { + final memberStateEvent = + await getStateEvent(EventTypes.GroupCallMemberPrefix, userId); + if (memberStateEvent == null) { + return null; + } + + final memberState = + IGroupCallRoomMemberState.fromJson(memberStateEvent.content); + + final memberGroupCallState = + memberState.calls.where(((call) => call.call_id == groupCallId)); + + if (memberGroupCallState.isEmpty) { + return null; + } + + final memberDevices = memberGroupCallState.first.devices; + + if (memberDevices.isEmpty) { + return null; + } + + /// NOTE: For now we only support one device so we use the device id in + /// the first source. + return memberDevices[0]; + } + + /// Monitor member status and respond to mesh calls by regularly updating + /// the state event in the room + void onRetryCallLoop(Timer _) async { + final memberStateEvents = + await getStateEventsList(EventTypes.GroupCallMemberPrefix); + + memberStateEvents.forEach((event) { + final memberId = event.senderId; + final existingCall = + calls.indexWhere((call) => call.remoteUser!.id == memberId) != -1; + final retryCallCount = retryCallCounts[memberId] ?? 0; + if (!existingCall && retryCallCount < 3) { + retryCallCounts[memberId] = retryCallCount + 1; + onMemberStateChanged(event); + } + }); + } + + CallSession? getCallByUserId(String userId) { + final value = calls.where((item) => item.remoteUser!.id == userId); + if (value.isNotEmpty) { + return value.first; + } + return null; + } + + void addCall(CallSession call) { + calls.add(call); + initCall(call); + onGroupCallEvent.add(GroupCallEvent.CallsChanged); + } + + void replaceCall(CallSession existingCall, CallSession replacementCall) { + final existingCallIndex = + calls.indexWhere((element) => element == existingCall); + + if (existingCallIndex == -1) { + throw Exception('Couldn\'t find call to replace'); + } + + calls.removeAt(existingCallIndex); + calls.add(replacementCall); + + disposeCall(existingCall, CallErrorCode.Replaced); + initCall(replacementCall); + + onGroupCallEvent.add(GroupCallEvent.CallsChanged); + } + + /// Removes a peer call from group calls. + void removeCall(CallSession call, String hangupReason) { + disposeCall(call, hangupReason); + + calls.removeWhere((element) => call.callId == element.callId); + + onGroupCallEvent.add(GroupCallEvent.CallsChanged); + } + + /// init a peer call from group calls. + void initCall(CallSession call) { + final opponentMemberId = call.opponentDeviceId; + + if (opponentMemberId == null) { + throw Exception('Cannot init call without user id'); + } + + call.onCallStateChanged.stream + .listen(((event) => onCallStateChanged(call, event))); + + call.onCallReplaced.stream.listen((CallSession newCall) { + replaceCall(call, newCall); + }); + + call.onCallStreamsChanged.stream.listen((call) { + call.tryRemoveStopedStreams(); + onStreamsChanged(call); + }); + + call.onCallHangup.stream.listen((event) { + onCallHangup(call); + }); + + call.onStreamAdd.stream.listen((stream) { + if (!stream.isLocal()) { + onStreamAdd.add(stream); + } + }); + + call.onStreamRemoved.stream.listen((stream) { + if (!stream.isLocal()) { + onStreamRemoved.add(stream); + } + }); + } + + void disposeCall(CallSession call, String hangupReason) { + final opponentMemberId = call.opponentDeviceId; + + if (opponentMemberId == null) { + throw Exception('Cannot dispose call without user id'); + } + + callHandlers.remove(opponentMemberId); + + if (call.hangupReason == CallErrorCode.Replaced) { + return; + } + + if (call.state != CallState.kEnded) { + call.hangup(hangupReason, false); + } + + final usermediaStream = getUserMediaStreamByUserId(opponentMemberId); + + if (usermediaStream != null) { + removeUserMediaStream(usermediaStream); + } + + final screenshareStream = getScreenshareStreamByUserId(opponentMemberId); + + if (screenshareStream != null) { + removeScreenshareStream(screenshareStream); + } + } + + String? getCallUserId(CallSession call) { + return call.remoteUser?.id ?? call.invitee; + } + + void onStreamsChanged(CallSession call) { + final opponentMemberId = getCallUserId(call); + + if (opponentMemberId == null) { + throw Exception('Cannot change call streams without user id'); + } + + final currentUserMediaStream = getUserMediaStreamByUserId(opponentMemberId); + final remoteUsermediaStream = call.remoteUserMediaStream; + final remoteStreamChanged = remoteUsermediaStream != currentUserMediaStream; + + if (remoteStreamChanged) { + if (currentUserMediaStream == null && remoteUsermediaStream != null) { + addUserMediaStream(remoteUsermediaStream); + } else if (currentUserMediaStream != null && + remoteUsermediaStream != null) { + replaceUserMediaStream(currentUserMediaStream, remoteUsermediaStream); + } else if (currentUserMediaStream != null && + remoteUsermediaStream == null) { + removeUserMediaStream(currentUserMediaStream); + } + } + + final currentScreenshareStream = + getScreenshareStreamByUserId(opponentMemberId); + final remoteScreensharingStream = call.remoteScreenSharingStream; + final remoteScreenshareStreamChanged = + remoteScreensharingStream != currentScreenshareStream; + + if (remoteScreenshareStreamChanged) { + if (currentScreenshareStream == null && + remoteScreensharingStream != null) { + addScreenshareStream(remoteScreensharingStream); + } else if (currentScreenshareStream != null && + remoteScreensharingStream != null) { + replaceScreenshareStream( + currentScreenshareStream, remoteScreensharingStream); + } else if (currentScreenshareStream != null && + remoteScreensharingStream == null) { + removeScreenshareStream(currentScreenshareStream); + } + } + + onGroupCallFeedsChanged.add(this); + } + + void onCallStateChanged(CallSession call, CallState state) { + final audioMuted = localUserMediaStream?.isAudioMuted() ?? true; + if (call.localUserMediaStream != null && + call.isMicrophoneMuted != audioMuted) { + call.setMicrophoneMuted(audioMuted); + } + + final videoMuted = localUserMediaStream?.isAudioMuted() ?? true; + + if (call.localUserMediaStream != null && + call.isLocalVideoMuted != videoMuted) { + call.setLocalVideoMuted(videoMuted); + } + + if (state == CallState.kConnected) { + retryCallCounts.remove(call.remoteUser!.id); + } + } + + void onCallHangup(CallSession call) { + if (call.hangupReason == CallErrorCode.Replaced) { + return; + } + onStreamsChanged(call); + removeCall(call, call.hangupReason!); + } + + WrappedMediaStream? getUserMediaStreamByUserId(String userId) { + final stream = userMediaStreams.where((stream) => stream.userId == userId); + if (stream.isNotEmpty) { + return stream.first; + } + return null; + } + + void addUserMediaStream(WrappedMediaStream stream) { + userMediaStreams.add(stream); + //callFeed.measureVolumeActivity(true); + onStreamAdd.add(stream); + onGroupCallEvent.add(GroupCallEvent.UserMediaStreamsChanged); + } + + void replaceUserMediaStream( + WrappedMediaStream existingStream, WrappedMediaStream replacementStream) { + final streamIndex = userMediaStreams + .indexWhere((stream) => stream.userId == existingStream.userId); + + if (streamIndex == -1) { + throw Exception('Couldn\'t find user media stream to replace'); + } + + userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]); + + existingStream.dispose(); + //replacementStream.measureVolumeActivity(true); + onGroupCallEvent.add(GroupCallEvent.UserMediaStreamsChanged); + } + + void removeUserMediaStream(WrappedMediaStream stream) { + final streamIndex = + userMediaStreams.indexWhere((stream) => stream.userId == stream.userId); + + if (streamIndex == -1) { + throw Exception('Couldn\'t find user media stream to remove'); + } + + userMediaStreams.removeWhere((element) => element.userId == stream.userId); + + onStreamRemoved.add(stream); + + if (voip.delegate.isWeb) { + stream.stream!.getTracks().forEach((element) { + element.stop(); + }); + } + + stream.dispose(); + + onGroupCallEvent.add(GroupCallEvent.UserMediaStreamsChanged); + + if (activeSpeaker == stream.userId && userMediaStreams.isNotEmpty) { + activeSpeaker = userMediaStreams[0].userId; + onGroupCallEvent.add(GroupCallEvent.ActiveSpeakerChanged); + } + } + + void onActiveSpeakerLoop() { + /* TODO(duan): + var topAvg = 0.0; + String? nextActiveSpeaker; + + userMediaFeeds.forEach((callFeed) { + if (callFeed.userId == client.userID && userMediaFeeds.length > 1) { + return; + } + + var total = 0; + + for (var i = 0; i < callFeed.speakingVolumeSamples.length; i++) { + final volume = callFeed.speakingVolumeSamples[i]; + total += max(volume, SPEAKING_THRESHOLD); + } + + final avg = total / callFeed.speakingVolumeSamples.length; + + if (topAvg != 0 || avg > topAvg) { + topAvg = avg; + nextActiveSpeaker = callFeed.userId; + } + }); + + if (nextActiveSpeaker != null && + activeSpeaker != nextActiveSpeaker && + topAvg > SPEAKING_THRESHOLD) { + activeSpeaker = nextActiveSpeaker; + onGroupCallEvent.add(GroupCallEvent.ActiveSpeakerChanged); + } + + activeSpeakerLoopTimeout = + Timer(Duration(seconds: activeSpeakerInterval), onActiveSpeakerLoop); + */ + } + + WrappedMediaStream? getScreenshareStreamByUserId(String userId) { + final stream = + screenshareStreams.where((stream) => stream.userId == userId); + if (stream.isNotEmpty) { + return stream.first; + } + return null; + } + + void addScreenshareStream(WrappedMediaStream stream) { + screenshareStreams.add(stream); + onStreamAdd.add(stream); + onGroupCallEvent.add(GroupCallEvent.ScreenshareStreamsChanged); + } + + void replaceScreenshareStream( + WrappedMediaStream existingStream, WrappedMediaStream replacementStream) { + final streamIndex = screenshareStreams + .indexWhere((stream) => stream.userId == existingStream.userId); + + if (streamIndex == -1) { + throw Exception('Couldn\'t find screenshare stream to replace'); + } + + screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]); + + existingStream.dispose(); + onGroupCallEvent.add(GroupCallEvent.ScreenshareStreamsChanged); + } + + void removeScreenshareStream(WrappedMediaStream stream) { + final streamIndex = screenshareStreams + .indexWhere((stream) => stream.userId == stream.userId); + + if (streamIndex == -1) { + throw Exception('Couldn\'t find screenshare stream to remove'); + } + + screenshareStreams + .removeWhere((element) => element.userId == stream.userId); + + onStreamRemoved.add(stream); + + if (voip.delegate.isWeb) { + stream.stream!.getTracks().forEach((element) { + element.stop(); + }); + } + + stream.dispose(); + + onGroupCallEvent.add(GroupCallEvent.ScreenshareStreamsChanged); + } + + void _addParticipant(User user) { + if (participants.indexWhere((m) => m.id == user.id) != -1) { + return; + } + + participants.add(user); + + onGroupCallEvent.add(GroupCallEvent.ParticipantsChanged); + } + + void _removeParticipant(String userid) { + final index = participants.indexWhere((m) => m.id == userid); + + if (index == -1) { + return; + } + + participants.removeAt(index); + + onGroupCallEvent.add(GroupCallEvent.ParticipantsChanged); + } +} diff --git a/lib/src/voip/images/famedly-1v1-call.drawio.png b/lib/src/voip/images/famedly-1v1-call.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..a523c2abd968f40b4d9f8caa1fe2d686c0dc8fe1 GIT binary patch literal 157352 zcmb@tby!s2_6JNUVxXW{ARvMr(_u2hbaxpz-5tz~0Tvc2SlHMtDi$gVHWs#s3ZkOe zASx#K?$LYi?|$#|{`bDmb2xM6%#O9!T6@K3FXnStO`dwctJs7;o#5M zt}S@;yXl4!d;~jrWPDIzMcnJ4pzh~gM2XAn)#y#?pcHu8-=`E9L}zunQs9IX7>sCA zD_st&1w4ZHW~)lC(W_N|e}h3_uwf9?FbGTlMW?`Va5%WZP{XlEi0p5BxmIoY*FebO z5HP?%rd+AFxU5cH3LFos=`@ z5)zIc4o8941L+h2laT^T1J5SCQ4K!GYNgQ{=%RASGluJ}pg9yX96B5hUZv?ARx@Zs zf&{7v3QYB%0?3@N;L!vr}PI^rSkRcp!%5R5L@(ZD z(=tsglQ=_1W60osi`;D_gWpBq@6Sb|G14e%1p`4Qc{!ni1pn}x;EEEX2Y=Ts_?0t8d5Hy~XW7?S5j$)FYi+2{f4hc^lQ zES)Yx&w)97S_hfP&(JwlVhh7-w5j-7CseK`($rcdiXbsCtr}OF*Xl^4c?Eo>Q0+kp z1Ue3kMDgfQFt}L-*9kI!B9qV*I-H9lp>;Bnn}PNrSQI@6fi$Tw7zGPL=dfXVfe4B5 z;?!iHKp}u)fI8!G9&n>V4g3JBX-Y4+tG#F?S_L)1>24w)LNih@^nkb!V87t45@;IR z&V{gve4`Ca>1NW{bh|9g%;h3IbeqXXhhbqTmIv<6;7fdH2Oa9p0NpTJ3RmOsBEchr zYbQ{=MxBu=6RW8{4T7bBrcuD=AQ^;I3TR-%iG?sKSxta@tZcPgqwy2GMkkn@1b(!T zSaJqKXI8?wV9VfhHEMJCS zXGI&efKhm^-fW=CL{dE8YBLf=Zl*+yM?q9xEY_hiXcRc1P|4-lTw038!1ZA{6r~bo z^g(@op%ZEISaouYR!-vi%_IU$#?nLFc0L^Dw3A3uhycS)b7Rd42L`7wD4jU5$SMXF zhXe#pa&gdVG5Ev)0?-~G9HrC%o`Xe^Xc~_N0p5BDXs!dvHs~=N+8sz>#7JD1Q!i(bL=XqYNtGZeF1*m~WYBbKg~q8SXhqI6s)R?yVu(5w z)nukxpd>%&y(X8;Qman7K59G9V2SoJl0gFj@oEjno)%O0wL` zSHMXIf{p5jI|PB13cyHsrqO8>AxIRIK+hEu;Z6gWA&?+NOg}usfp!v^M5;zZfcs%w zy3vTpVDbf6Ck1YhI>}VfnM7wh1QaX}3?}5lwQ4kjO2A{NZZ`@Suoq6Q00YrEsdBf1 zh9=PbTm$Gtve7M4kjt3?OVqlv2coCGV7D7-M7tYQ$a-1Z(fvr@dbZE4e&2x$P9K1vzrlQno9xIW` zhf8!WM1V(Tf`hF^A}Mk=%*^Cyg({&7WkhEXaUvlqLo72mnKq8zBf+5cdWjw{lj%t^ zK32kI8(BiM5v)%v71@k{e67SPH)N>XB)bacV>v|TGy~5m;TgF~q*%`dj?#}L* zP$T3hs6;A99x$I2v>xfh!LfR?MUGcOc{C)2%?<2wu98YMb1gEb4}9_Bz)hw=sF4Jx zM$ZCYGpMmBT;N7GUW~4q8w=R<#0~rh&tx zWU|JKb+Aojp%BXVatWw3F*C5uxfG+EpwVGy7{3sRgM^Rqg| zr5Kx6C^SR#WQo~C!)U}zDj#LVuncgAhQ$$p0o550q=(Als#Q=0$)<5}by5_Sprb$~ zMw3oK!+OwsHP24P8XQU)LO?{3@Cuoa#BynPFgqOzI>JO!icG>xGfJ3%QWaZeEMk$#W?YL^i{~ z_TZ67ItC&o0u>}#&>X*4E?`KZ9+$&_p?Z82ve)FX(%4S41xMvTq+YJx&42*M*EQfW zI7d$6ii82zOUW<`10M#k{YX5mLM^t^&9F2VpCxyhG~k`YVA8WhEG*reAy!IUBDl$^ zu{qqp9)s?9lb<3%%}=J?3OJ1>(NQd(M#p4oGF)O91_BYYmn_fSf_x%F+lIda)iT= zc93jpt5^&SDxPVQTD))~p6de19bU}U>RmXF+oSY)R4%Q|>XtE7C?zw4J0vXGAuL0?iTvVVvz`95v!<9kyNeZ@i|yFO%BHptzw6PO;c(mY>=mW zX?UW_?1VGeSSidLh#WM)Ws&?)K33t=IG`?$k%i*XFnTT=1Y$Oz>m+lUfNsPIy>5w` zOEbHeFdNJ+GO(>yjMq-k5u9e2H%;j`7@R(}80DitK%NYBcnBJVhT{;c=@^>=L4cz) zI0XU?Cqvm78X5w;E8fO3SvgLY6>0XWxG=ZOPN&;AEF#;C((6fBhz+Eiz{=4zCKuUh zLSir~n~01-ij5qXoQu(MnKCrofkS)gIy~25Ra=Q75=*8_Gs1apJDr2YS;a~=-sr&K z8E75^L|7IVna6OLQC0~%xF`ZTw!lblW>1dMOZZ#wMMk-y- z#JJ&9HddJ7ffCp@qTi0o(97s#xWnsl*mMN1iQ|XL6>hbO?q-_^3c3l)^~-z~3{Vcd zN9v)|JQ{~pC$LBrW*ZJDCkey>K0ZSvcEb!rivS_uDg{)G0%d0zG-{aFV3o_X5Hu4i z;h>y>Z3-?GoDE~*9UN)ENc#LD9r(~m86Jg%xCwAQxaVRCtda!dIBsa3_x=2?SC!vCN4v>OfwJ7wBy!IRdNT zYSNr;B2>zl1smZQ) zfI%@nEZnU%q@{UWdX5&vA7reROcdLF0e@#;v4{*D(ubhg*k&ycPL%}`LI%XFq6@`P z;6epF6WLD}aphc_B?D@L3UvaB4l2ak(#$rePNKn~SQ3McXI25bhF1xMSdUv_BoJZX zn^nMLLMS4(O`oAwl1*%y39AXLAx-Ev@l+lj!pAgOz-(Hd91bVjr1CTai^8^ITp}d~ z!C?p`7NkP7OD1o8A84Y@0HsW0t%9dB~b7vp#ZED4?){_Zbcw0 z;Q=g0z%#PV4m?|$rZID9e27NKgOFW#Jjt$vy4X^Q!-!2o{Ly{`is>*1a2bezt+R2s zPC5;#_38{vzF)7S>k%##PX;$=NJt$kU^uW$gwq3)Ib1HU#RrGM;0!k$fLJg*O6``r z7!(zFbRanyM!O2n&?q5+G{j~n*gYmFm&K5yjA}WB#^U;64m?zWr0|gvv&KynN?ceN z5rRVdOggCDj-mM#4wNthixPpD(}I;7u@W&&XLXP~!~heaE*Ar>^pWU)w!YWS@hK5J zGlOQek)UFQ0>vO(Q4mO)34tVO8E%=|AyU(*Y`M;0^I}PW zN62UB$r(_&&_(vSpmaWz?xKN2o~9#E($b&?v&pLB!=Z{alM(6@NgzTyn@n}!JWiKR zh{uyb%*3L}*%Gc#uGSJ1Ts_y6rZ-BwCL=zO`-2b+=hnKFNRv-2$2j3KvW`fzAoW~_ zMTf@nP(+*t;-*o|CZR)*;7RR#o}FPK(mi$=gk-Z|V0;Evt3vx^86>Ncf)*1AevKty zi2QgzJdk#!x#t!g=ijXiDMr9{1_CJ7(^xD1l~#S)`xpzE>vDj-_>_^?{>o7-SQ@F?SbQV z>9TbQ6NlTK)%*3Z&%5W%6vPa=y{!lF#OIn(lqFsKx~EerzLkEZI>n+b=NHAI75i?q|JW~g>@>e% zw;&=R^B2@`cV|@TMK2-(vpQ&hIIC@My0av{I3GRbQ1=-zLqwB*e12V0dT`~Vi7?ru zJrif%+qdX6!Ctnnq-r%aVrp}h_6_~e_+ysAoa@U6Eu)eZ@rU+LI35yFb}^^d7Q)Pg z>EF(bzIcbXzkk`=89%G9g4^mtWMdayWnmwbwI-)<;>o7gi@k?uydLP==NUBV7=mRl z8@8%y{_2SG9$E^eo#w`dMfsEbm#?Kkx4kba-le~@ow9x;vOjB`?h5p#uAwg&S3B)8 zxP3pA(U4U3c6G-0JCrSXM)$J*%v_XNHuf=esS;t@gxMH8U$~c^y7J+)FZaY4Z)4(| z;Lfbj?@I>>c6K4<^qjEmV=gtY6Kuj&Xw@0+^Y>DsFgnMlsxnaQz zLRoFEyewImnwlEa-0Z^e{Xvm?4D4IO%IZ`y%%)!o8});l5}M1}lna?M`( z_U-M2LhZHq%>&B5Yxh>+GJ9T4IDBzJn74J+)pX5~mCv%ik0#s8rjFfm!IE4yf{i|V z|J8!ln_1@v5ARp@y&iVhZdlbiuPZ8^5V9cXMgl7=qsvB2ZRC}T_p_d5IekUQ5&b|f zC3bl$WaYTMnojiv2S>C1KTI+KTZXMgg>(yloc&PWhZ|?>O+(c!3iE!_58ZzoEPU?0 zy-$C^S6+JES^0g|{Z$JAP0sIE_ul8lK?>>zMy|S{J^Wx%*tZw2Ne_BYyp_;9S8_oe zhWR}q8^!8$C&7O9;w(zmkZ?@+V^Y?$tVf(t`}zUNrCrW0$qTZMUGzfur|&u4Pv!-cj;?Mue#?*_X`#`3igU8^ z>YtpO+xgzft;>?B&x$_A?vwZXc}}-i*ExdFzwFao=h;4C10#!P6*+$b{!Q3mx_-4? z&!<;Ey{kG@zG6uJ`n|tOfy%zRzi%yFH*QLE$JKD{#V>Cw?H`{JRoBWnAC%4<`%{YE$0@p_)b>A$HKewcsz+tb(!TbR1^~t4~Y`do`?0QtzoBFZ958 z-mUu4lqYPnjkbcfT=8SCsjBEz)u92R!U^+FP5G*Lcv$ZRM(5+}9R*1UL^oek-I-tC zKOM~cJslmrVG0gk^Naf9~)UNJ6I|aLzjx60abmEC_K)uBEhd%c?O&NJsoIY*Nlj%P@HT{OE zuk740zA2e$S@nX{d}G{iL1w;j)`OI7#>ztPixpzqrZ z6ZScN6c|L>wBeP@C!O-k@9&jT@`v|%c*MBlVt@CX%A@8-f`qesCZC!4rue~v!f^`Y z+=69UF%f?xlsZ%R=uL1$&5P-;udRlc=k`rIT)8v6b3De?5{(m|4~|MS5es*%AC)2c zsuKj?Xf4*$G3U3u=_Cl7SbX?>d-xz(|>&=PdMcluAkdt=?<9xKk3{ox&t-SwH!sB8+`&#j z81FNNy^Wc4d-37CVdGILj`Jc{OVs#Lzt)BArIanGXRGX~A6D1(?j9YznQ;46A$Rel z(z>zxru}U0Inp(7!)V{;^K4&Mzaj8DTdB%juSvkNf7p(hJhtGL0v!!Go46S|L>6!%`6G`#%5)_oZ@zDq3E)3bAQ z_jbq3oHa|Bo-{t;d2f2@^f#AJ&FhgA@{o`w9hR7MCc9H?;`bt6UEAa8t1I7J&Yu^W z)UIPM*!h}5CP8w<5K}ne_~z!}dG`6meFt?;;>>%MNF-dN=MTpfV$ae=NU*^#Y}$6} zL~CQYO1>j`vMoe3I+GBWJ7q`bvENc7)`RV|&wqBDt+q_JpX-?B`us!v$eB7je9$pV zHBYf`g7xRu25aN$rxQL6jKt*c7$w^h@PqHp9S-|zXH@E}(b8*c(fj=iRt@6!|B}oh z1RbY#PA5=uN9=etcF&|!C)$V9X&!qH4ZlQBOv+rAxs8&QxZp(C(4wvs68n{+5AJSMerD0dMFyF(^K=t-a9yijS z*mOKr8kajjEG<%TceVf8@Z{KtFH0C~RG%0er@|c-T&K^U*R{w`5n-2)NFRH)@9ia{ zofCaO^m2Z$vwI+uHA@8%8w;Ox8>gpJNjb#ZMN5*D*930 zb-?)fvcpDB`91`My;PwkBQn)f`lkb7Ik~7f)!tXFN!G-8PRdc$RfH z?;a^hSDw9VjQ=y|?5xp9`dZX5PeaYVu$kp^o@Ld)L`*q4Jh=MpYIM|sb)w0!;~AAZ zizk$Bjt(ab%stGETG(%4Va<6=lO*6GPX>$}T=tF>Li*N}LybEyCU1Fas>AhyH2uuX zHndLpBM?4WuY201v*NI2xhdsE-LOrAgMH2!J6{&%t@(%O3;I1P10jFo%eB_s!my@? zH>(b5_XJhMu3;nsBU!;tGiM4x!1d-FcTelEs?5( z(92)*o7FK(O>GD6S(u!H6Ah}my`b)LT#ZcXY2S}Sv2_kQu}%aC7>+Sm16xVLuc+jSGt zUR-Z;R4~k{h^_v^f~)_-f-c>bp8WZ}zF<$Reb7=*V(c911}me0*gc%U$=&8Dl1siN zZN`C(m55{9-b$^e{Jv0_Q`>n-$mII?l#Qc)t7eU>(xy5Zwh<;_8(-b(_wC!aok3Dm z%d!$d)R(Ok!wgeo@wA_wlGYz@4eH#f!-Gplo1<}%-77+i5TZ9%+ipqfSl)KaQKxie z>(vdRMVD}O$70HdoKmT0bbEBNSk1>thaGUg6{SR$+mGH*6SF?2JjsxU^PdrUpTsIX=}=g#NFLOq;ryov`wxqKPs0`Sd@!~ zl(*mU>W5?NMDdSvQKKaJVb>^G_3XQo76L(Cdz;^@o-x6+9n+?@VUBpaXsv;&o zs`i(&*Fy}Fk$FBMwsTT2F{{4wfSIG(apG*@>iTA!z3lktsx_clU=rH@Pm>hJ)yCOk zL1tx1dw+N_FuI*mVw*@ICEpP%#ewkuoZ&vmhps;VJon%)obei6dCRGw)n~Kk7p8j!mkx z8cG(r;9}F@K=?1Y-n(L)(zh;<-RKq_Z`b$>nqPX&Fbr5V-(rwEO(iX?revjWu*}|f zE2d==$X6$jvOe8V>z?0*v_uUsjh+`)?8tJKnFGOprz3YpZpPFimYd3BBN|`ckxbea zkij15x$oMcOYZznTDJ{|;s(f73vTERg63E6EINQZv3VRK@!*21?QE^bJDS(^-ahWx z>_y>uLE=bTWbvY)3&F=Wd1T~n;p?)OcJlR`k(Cwn?n!CFYM8p|;?vazhPZG7E-#5_ zYx&{kS22~R<^uHK;>VQ0t<5m#fZ$0tuDwPdl1}^Hr0zCE)UmQ~B7E)Zl}GZQWvM6L znDMrY?&ii|!~MPUmyBzb>=^mBIJXSs76%j;+FvUe_hing1N!KB8v;YF?gLWsOFa&s zUl-;b1K;uFn&!*hUHpRp)~GqTy4>C^oKZJ)&70{VSMx`ZCr?g{>@%|NZwB6`L-+HI zPtSe)aI#@(#|ZCiFj)MVc=e9|9q!14J>;C8o8k{2Tr~@<+WBq1$e)`E5(C@MX7jZT zV;;>CNlv~6fmo7ow_p{r{OGS`tT8PNYb^-cuU*%vBDu_X1g`gQAGRQL!_$gMOiJp?T4ur6 zKL0{>7?=>}>UIAHbwB{hkGS??BwYgVmMPWOz|D6EZ3JUqleKzY=**Rdsyz#Lg%u{h zKXG(WWbxu*ry+UwBz&kz40OcT3c!;0FW4zjhVk&bKh!9r?)V4W0?Je$AEnL zGv;9Th*fKXW(D(4F1ffYix1FhLUyJGprpvas>C`v4GHNoUcH~jh z>ac61tjf?szMSTzK@mM?fI0hlXP}DLfCq6`&Cle?K_L@Jrl0{g!=fPp#HZu4c584U z$&&|%For-@oYa}(A~!&#B?FsM(2cG zBY^c?uN(G|7E+i3RM7an$PHRHB$piyY4|*Z9FqsAsc08{ASl?9stF$8oR_?j3aDms z_WqgmX**jQo)ta>056lX>IGkAeUBuZ1*V&Gz-N0 z(Y94V`B9^Ca>8@7DgoH^Zz&b$2Hj~tpu+q~=0gBACBJVE#6os;y4}Ga>8f zx-Jptmt7Si_d6+T&KllAH118QukOLem&+&KUtF|Cmz#oLV+H%w_%M ze%s6BMa&JOByI@vez?>%vGai7=mW{1(Ms3J7j2pLGRN@xtW)c=J}fG=jIq{V_?VY< zFEF(szTO*HIxevEjpe8u7FhbNF@&F#GF-?B9B3SVQX5dk|Gv5cy`7BTaZ43FZ~h-z zmmCIT{P*rHDM(%ZtnA*xOCJY*9$QpD=z}0+N2Haw#wD#S=zEg?XpvC~yLdA^BNxVO zTk@GVQ?mB>^w-@g;vQ{!qrNWfB6Y?bP3ZUj-J_$7mJLb5ktL!c|K9fpXDY)sjLTg7 z2)#d1+9aU7S@!tgtWKgwE9z$k7crMbUyaM% zq zc+sTXFxEG_;NoH8EOvI1@F9cS+@tzd>#1B7z>czmt9lw8tORMqaw$oH3;ae|>wpaZ#mk#ree! zE0X;E*m!*gbehUI+84LrV!@(|H01GO@-=8^P3KJD=Rvl{lM&y z7`)*D=cF{D+t4qb+5V4F;jFmFvwLlMxUv4&_-?U-6#E;iiu`TkA9uj5`7FIv^foz6 z^p1)J4xH`ZLjQC2~s z3MXI|RCOIoZoKED4fVY^;?Ujo%#PNyP8c1W`TOIL57pnr)B4Y3zHzO-b^6KFd+ChF z#5nIDT*%=!W2N^dVzpsM_pY9?QBS_xFkbX~_LtJk{TK7zuTXs3c;}cWkII8}XS|*< z9^J|v(y8~6*g0`N`h%(kBd!Xqn?!RS7duG%bh;|@(lzOd48ahoqUp#td&9~U!-sX4 z;;8*4qWFf)xUIJ-Gxx8%P$$36EtxveB>e>c6%i(^9=eX);rsl2~dZ_g6=8GTlZznVyd1qy_=fD27 zaP)k4z^YzoX*$~v*tQ%Bu#cqA?afKR@Q)1M9@Dcr?eL9W0S#iuj}x4!`;juP_UWuq zQ8N$jIyW9S^ZUqQKXw6e%ujz1YKoWu?8f8x3AD0-^oEm5BwI|&Q(s%k-Pd}y%=gpG z^dY%H6K3vlcPJ&6U%#rmsadtVV7QW1vb;7W=SS19V3}CF_tau zKbTSm+f!$%4>qj7m=3mojs$UPiT}!khAp@UM=kx6&5CmNr7d;GEKkwptAD%iT>=^^ zEUS6`XqX`(c>Me&n8uc@wv_z7bKB>bFKGBH9=_*nH4R%gwxpBP(fx44_r~Y7o$Vja z4?ps-_RDUj?e$~%@_o&HMb~;Qo;6Ce+nz$n?eeLA__I^pTLf3mUAXhp)>=0yW!>XW z$7fV^J+U$MBl_d?w`Z;-bpB9t_HNX^Zuv`B^iJeHOQyu;G9zN3_Rav7i2gkvx9DSI zYo8Zy(?*Gv_cPXW4^b0~>ITnN!)N98rN<-hk2+Q;e^9@Iv-j7Of&BNOrtzb?F3ou2 z{3RMSw7H=GskzSUOv*5f^+g?{(nxw4B^GH~LNLG71& z`SVlGN*)z=v~m$Q#Lm6omfu3SDZF@#|1SlHr>e)4(u9Q^)Dj`{L~4>hM} zR6fydp1tH}hV|Lc;7%XqJ3dkh2IW8Ohnu~9RYjXBHlQme@9~I5YZ^xV{=xiy^b&gQ zy@Sy8nINsQ4^qYcBMzKw4tY2wl45Hb_<6&`pq>x6g$%y8d~#{Z@O6(D&c`utzCCGv zv#U9m^@8)^{mPxZJ?5|zw_ZreD@2LMe|G&*Nxrbg+$9&AZy<-9a#)Yd>yk_Yn8cg# zh}$PRez(pl14_PeL*dsJd5`Gt4?xa!4Ij#*Ph(Nk|f?~&5; zyg2T_B5KI%#SH!O7Yqpv?av%G%hURBReUq&5xzq;>Qb);_mgpY%T$2UcX zZyxnKiLI)R2|n><@3i@Kul^CPl=Tjaiw?i7zP4%QNc#6e>zE_&e(w7*@oq!txFNe% zw$L{sPqV9gY-ptSA)lWS`ba#v;_Jk`(pRxjqw{u5{@xC!0AS&R{>N)Njq9^%arF^- zV%~JoziR=;c4xiaChAbW)Lk`i#EBK5O|I{aACTosw``0asrp_#bW{F-#-q`d0F-h( z^GP0bm@?$EnJdzI(l_oz%TC&a^TFePKfS{| zU#hP8)x~=M?cVbqJ{-__TM7xgc~08`gx-I$|Yv7hpJGgiKf?Q)zjAhNt`U97l*0N!OBnKCZf6Ub;(Ty=Ix4x#@*OqH<|5L5PX9Eiox<8C{cQOM;rrOjm*YD=X)c_# zvU)n5_7I`JQrCj}XO+jCrzV<<3BuBTCr-^6ik)3XDU^XQcPq$#P7Py3KFFch7e(1P=I z#$oo_OVbR_D~-=mO&z}w13si&dK5A2^KT;Bm8@p`4*`i(> z-)*zG8VW`LCZYO}i7_dEgd`&an4AKPv}i(iIGoS;gd_j2NC^6AY&+wTfy z+>_Galp$!2GCwAz>BpI-d*ee!oSHrD(RA`H*pc_YHg^p-S%-K_zjQgGoiX%G>D1N( z56AcIpMFUgH_!axa!qXXUa|E-rx!J*#W&|>#QbdBLr*@kT{6e-Jv51&OnVo4yjSP+ zq=F7b&lIUAqjOh4VHNK)E3)5Ze*B*M!Z4f{ci>{1)mxH-|5YaOE)bzU3gptLC63YD zsCkKzvoq(O+E*0zHhuArv^Eclj;|@%l)7MVTzUH8_BB&~mmUroRJ3=(1{U2?vj0h@ zH9j}#>dTbc_UU!zaQC?0J6n@uA5z<&dA;S)-6`)vFSTx4dFATuZ|N7OZTFncPdYoR zPF3GN|98R)OyV?V!adTd)u{_iv~T+TQp&R73xAAB{s!B=cu45hM6(8rkgK&+-|Q*8@DTC>hE-e_WeIP0 zO}4h~fEMh&9=`3A>G;B3i<-{vh$-LGuE&j6bFbDf8JJnsjF>n7AW9JiSo{MX{1oEdwuaSz^AF>d^m6X@0xUy>LjTk!x6@(19cQys8D zAXDz#WwLBW_{Kx@V_oMtUSvJmH*edyQGF|;nm-TXeb?qVLW^HT_TWCf{G5{#Nf5@@ zly>?~T|a!`!}m`_@ncHnYHw^Hb-_G4N0_w7Tic)>v9&kB^6BH2xrX{FzI_`@LfFS< zk1Yvg>R_5C7 zt*xzrLV=KwkX)#eq`I>F^_+Xr_Ff&9^!*2h5N+!m%%2zf0r&L8q-0xGnIl*tZxk;_2%`SPjpY1-q`%!IEWs?SxQ%&^y2 z^qD<-cJ2Lr|lO<_=)T z*ImtL&yN`Tk1#DYS~vjswMw|AGnk=<3{r=hdj8^9 zL;!SQhEU2tRSzz1UZ>Z`o%IJmvBmCgm-!H&JXIFp%y=+d{R|!rfQkQf!=4I*I{mpkP%a+7pXM%#CT7bWo zfnp7Au}yw=x3B{!H4*M=7g%vc-^quaPq14QLjudMl?BkZtNHt410cEb+YC`|)}4P1 zwniR_N)9>m?7~8*wPtimH&A%c5tJ)94#bE6x-^dg*0rwdpFuDnBf?bVkXHp@wm|LE zvecZgKTrgz?3C>(9#kqaxAk?sx*0t2go|rsP5xh9hy+Mg0C}U7LH_p3?$vW+^Prw+ zOWev4>5D-D%AmZgIsbr|D2c5glRsb3cYI=STGY)>KL7@1u>FRE(!w#XD)$%oS3CoS zL0nMs)Nc)SEu}0s5O+|H-5dxwIIAnzpJ)0IKCKJDvwTQVR5TNXNPL;umXMlb3wuYf3t0&ixPwD|FQ~u16FzLu=;BElAj+= zV*H;6f*PT^ACXm||3K)y5uo3sXOzfU0C)c*Pffqro;~MH`T9W)41)%~zIkcZ_yCRw ziaR5KqUJ{d#^z&}pPRol0JwGx0Iq*f#6wT>KFA_I2z^ef$Qd&9QQ2U3ek@wsGyHnL(>7fI2 z7xbNA+-HYlqEsV3PlU!0;hlYeIpv1%Gl38|?Cjol^YWl*uzl~-cQHb>B|Br z{GI^xFV0h+Bqag!MXA|1EV(u;vJYu*oH%9mP(nz5_;;)7 z&RD>|PXYc-dUDG#4NUu4da!@Qs+oU5Vt&VwOY`U}ho0F=T^2aMgRfz)rIu|2yQr2| zVp<1c7J2|&rT4iiCnl!av%1ap2%oe49@*F10$?K6rcix{^!bi1YmL7 zC=Fy#+RocoRx>c@bSM@g}*?}F~b-mHmyF!;ZE1Vwmz1r+fmCvxt;)o>&zNLs-= zFulcpXf@;RTGYSJ|4EJVNoepRcWM&UtXapwjFAE2(d3k$#pwu~jjDIq)C+*s`v+2_T z@2&-Gr=~gH98kq>K=R%Nqkp;wfHi*Ddi^w|tkd6BS|He(iRF7b#3BuWTER`A_Uxl& zNvHo~WkCM-z_bU}T*6M;Kj5!^jXVIVsW*XYoj32pK)DtA#PP#R{$V*g0w2P=0?F*xa2{ zwfV`lw7=V1{IGZBQBb5@*xX|vcXQaqB?+C!jVmoiU3g9@bA(-+`eA&9G!6o&l?Svn zaG(T^m@WkOIIF&sTpRIMTSr5)U;TJ|+&Lh-bHd_&K+Yg6Aa(* zb9)dy0LGOBj^_4zMudMI2b2~Q6A<_x_XiJ&r}hCyxk-dLP$e6iyguME5-mV8x}Yti z7XZz87;pmS-{aMvyM^z4reAyq_Ll)0LNc1)Nw2LR)uXia$B?O0r`|l_HXb%sR`6dQ zNdG@O3N~tE;JB*f;ihX1<%^twHOw*U_$UY0+KRTxT`0T>)G|#J>eL)Rco3$wO%%PR^yren) zK5}rd+*mQHp?GgPiV-5}4?MzdPqYA$Nz!)#K1of z_&&W{l#y-%=Q)WQP~g+fk^IMze9H`ZoSS>-OncyTy_@pSo!uaBSb4#_@BH}2k4$~S z)9OcOifxY0lZHsE-)|Xv`uB*foyxO6uXdcenZYPHe(okX>^We41iL3(Ab7p`Mc1YV z^|$`)Mag4Ne}D|Bm9x@ZSweD#qONvF^Z>hdmx7c34>pOZ%b69ou{>3+!n}?4s?yrpT7k=N)fB3dD6d_WaD$Q%+<>~`xk_X1! zM#q<_a&>LX>H~epfx6eh2%0@FyGFLt2GSVXW7+EmR@wo9QD`8m1PmaXZw1F5)XA>mxmyfHCkwikW};a zL{ei;)_B5wsdGoCawLtsXvv|aKY72W@Qo#W10()FB$h*BC@ZFo8oBBCk8k6e)DIsH zS}B54Of)c&K!tHf3{zlgDix6Se)EL%*$>TrXZK!SUJea-ohJWR#o17USo4IO-^~K~ zmC^}0)>dGe>6ie&hcVf>`(+8*yHp|1p#N|wIU)tew%E9g@_v8lv=@H)f2nX<(LVuX z#AgW^hpMX(O9z&Pn)Ab zV=QG<-Lv!8l3xIuM7QlFSC=`ROjR{^HI;Nblpp-q^>#s3z$T0$j0KioLSi}^erm3um~K0b7HlV!P7s5g`gl7e@t`U zonNkP>VNx>RUg)sNQ2Y7^b^-f$qe}zvByhojZ`^zlv0}N^-57WJ_&{ycc>a1 zUT_0$L|mxGi>#WDUE zekr~5BK75Ifw;})?+%oCh^JS-RId*Yi_);!JrGG2tt4_m*KC@KB1;rbM@Pf=!rLyV zBsYZj5KV*oZPK2Oh^qa(e1~kY>$-|`1frIdA!>=mT7N^t#fE3Cj5OQt!{tPLjy)<4 z^2;+eM%&q=ZdT&yQnZlx{P33qYcI#T8lCs}@%6)>7s!tT!U|&M)y>Prql-&z&USc> zJDy{Y?c=wlbce>C(A9?)EGu5XD~v}zo%{W0b0Juk96Ms>AP?dUKkFP4I+tpx)62(d zQP6!3Lg8O0^bCzkO6X7P<~lsKC$SrUM*DfsVa%LreleZh+~1F@<9%)vuFwPHjxQ&z zi!l&ecNE*Mo5to>-=+P$a>wz@)_s*C&*T<3GDMd*4eil%MZk~&vaq}*?nxV!m#MD2 zRxqd}$e~ICQ?y?e1c(uI>VX7?I^qon^WKZ0SerU}!uJxGI@*q{8i<`OM2K&x_g(D!Og3@Rl^=qBs1^F%l3STSr8#Xi% zwGMhTetW%2%5jEJ1{=ytdR?y+GM&(Rl6M zaXy%WEOmV0qhDthSs+;;1sLI>6zpPa381r3Nnxj*Z(j=)f{u{X6ukuSX_zepP$`2crCrxUZ|lwrN8Bm}|-}HgT{$ zOT_BcuAHwIu2aIK%2&J*7sxc_T?~8Sl2B+mmsYNcgL1EvmCq3et%kLc!iqOM{!Iy` zel-BY?1LEgUIY7zxdwPCgfSQGTt0siv9qe?r|#@nLakvt$I@r1*^eIop-haCVvEmN z8%quEyOYEuSxPf^8so<0V#-rWFZ`@b)AsT=Gri)x@Asb?(A{Xh>c!Vl zP1oLi{uW=YjcTHt5@;|ak7^jW(kyKWGCJ|BnCFpE_2g>%`Nw;W)y3rJy)8&9UU$Ac zIC)iKKCry*Si3^4o$_*ZBC8PBz(VY;vtp74ur-oh!HvZ=(B$5i)wF!)B)UqH%Wsft z!+P@GwaM$E;Vwp^J1jFxI#_``XyeV}SI#59Si0%nd~d?JcwXO)9XF5jNl5}F>M`|v zgVW{OH+Aay)VxZxxo+1_>e}EmDsZ^y5J2+aqI}*T?5YidD67fm)7+{N@1UbeGK6XD zYZWsr;BwXS+ng_}6PS947+3yc7j-c$8JB}K!;KjfYUXOH_ZhpHQ;iYu+Jb_jg@wN? zj>4;3of++tX;a;+>Zn)7eIS>x?&?*{FZ6N(RAmzrZ^RA+&&f!@BrLSiP_?$sB?z+acvY>1YRHb<=!Q@k0=rN_o-{pNTtnGloyBBG`JsAMJqeS|@sHO%fjS#>9 zqC6r+{(LdPKJ@I=GZnuDYz+~!zWHxFHNp_utb&fhN^46$FEl)DPpxgVA z#6R1ey-oU~tjkOP{^4;ga6vkG=D!gN@?ubfrcVv-y-E1bs()48}noOlu1;m#|d zP>LOu5_S=Gb7x}2GRsPdUvVuBa~+x*E)_MJUG!y1)zLs{H@`zQ6FXY9%#^2la_`K& z9+nnG5s(u zJ-?8f($#71Ykny3t(>y`nLCB8!LXC=AQ{FYb3I$-wTp%Jp*^raNNfKtNBoI{7)*8z zo7ST?L!|xH{wv^%+x3r7gyI~SpFg24(>Hf77jtVl)}TK;N&M8GeK$L54p_|)aJ?h9 z_bI2X4v=Iuate6Qt3kEk>Q%0LDsl8|6bP_BmQe-5Gfb;JQBb84HJO=>Fj1~rLw5e6 zC|HVITOfwoNcO;Z62`;cg;@y;j(cUbFH~LVp_N|;#I7UnI z3hM0b-?69sArZ_o*OIi_442f`U(>PuM-EAK>iU=I)=dL0q_kE8pf zOap7P53pIj9QNa2c?x^yi5gaYPut)A!`mE%Bl_96WGn4&(}mi8nJZq`+;aK2QOO5D z)c_m7Tu@UVt`Hv4N!gM$VDU=e!yPom4N{FG%c-JOym+w1Q;J@T z?}F~V!I;W=y}sszN?fw&&ffI?XjB83AttVqeKUEGX*b4rh9WX}+RfomUpiUpc>6!Q z)~4kooA95B>LU!2Owpo!C4Gm}?P2@Y^@Dhb(W%7;PAV^CC$>;95p_|FG?u|O)Q)l; z&kEnYD5Wt&Dy~e=&_$s$WN|s$JGknu`ZO5e$L-la1;9;b z63d-6E$s*HuAlU*@iXIIXck<%_z5Ee_T`JJ%wjjeTb-$u8Hv03_{>7 zkT&3aAmslqEr8?vtmL7rsJ4LjW(hbBQzxxt?)7Hyx(~2NKG|LX^1}?p1cs&b$yMof8H{iyA6HbSPp&`E~8*lrt$sf2bC>h_?+{#XDuL-K=Pfq|7N7l&+ui zVBcTZ+FZxn$60o=($&0*>=NSDHafrfGth>81SlmOm!^blcU*0^y-KS-&O-~);trF~ z62XH?^UcB0^fI`IEaFsDRP=K37Ln)OK z4i3)T>z(|E2Tz@!W<+%7i31Ws1f(Gwsy9ax;Il9UWE zX=<8%=CU*Eb5`lJO}RB)8w!kt)b#X-z*f}Moy3PjP7ZuuRxn=98rQ>|GdFMqjsliS z+^B2+X8cTJ&7a|_=|_TTsbt;#SdpeiTrW%|*63Y`n1j)$qnOJA`6 z;Q&RyaQVz>#V9Q*aq2A;C?!QsNEi@G#0jAr{7zfsJ;{%K0ayga4-dfa*)L3#vz2LX z;EPZqDPVov1uiwg@q6n4!f1~6&h5G6|M>GK6lTD%4Frr$3J)KoK;Ig~gg~hXulE#o z+zQx>lDQy*8%}Ag1o>Z_lKB&Wh^PQ5zFMxvJ>VI4bS$?3fZ(hb80dy?H_&zj#D^?& zIs2VL7$$I&aHBR$!}wN5@>eh?1F@)Dfv<9BCDrRCaJ!nTi*1gVssOk0Vt3LA1!E!B zKaNNM#{Ul^0*O&m4F2Mfysq5wIdq7@uuFBuvkM!do!=r$0G7G=CkKhwQTgnmG-z*t z%>9Se!0Q1us(>t~DyXeEKqd7ECjVJY+8>O$SN zF9L31==-zAsqX+CXb7Gk__n{|qC(g$Ig>$|-OMh|9t!leHX?v^Sar{`=ywLgN(?45 z2yy;)F!gG}Y#SSkqYY>3JZyny1h}EmGUXB+#W*eABi1B%KkxO}K17iMTdKibfC+ZZ zGK>@JgAem^QEf5ajekLsqdObR@+m?j6ax0jxv2D05F`nU1b2L6wg8Xfcf;}gO}c;k z^gIu0e37v}18{Dw`>vRnm?Upp*+2Xf-su2F=lbDhN{-5}cso}WNL#}5fC-f)&(_E% zT@1btxWj+L?hsbI0G<)z>7u|N-)M3$gh#JQh`=fcAz=M16-f#F%l;(tS>g1v++a!W z5W70cgU5RbK64fAn^y6HxgF2eibm`byZOe~5h@7UWfqOq+jXH|q#|jbWy!zdPI7z? zAb#hG%56vQjVKVX?;%*7Valpf48ZEiosSKynbO=AfM8p`2DAdO-JTYfPvmNpe>I?i zYr_g>Ep$AE7WE05ScpTCtD1W23tV!K6YLtM6>p>bgR_jQSO5lz&Ec9lNGa3uS+X;KPeTa}fe-)tW>Ee=sSG0Eq?mo$v#2Z$L%A8yF3CgC zBw=kRfJrf#E9uXV1jNZF0PDAE>{5jGg)lJjHC^fCim_lOY+gcy*$s2~;a)GW?$2#+ zM*^38Vz>Rc9K03HZ2n4}_G}Rzh|^A7K>5Zy)MnlVjZ{>(mJRY@w?jel!DWB6YZYtU zf(>5i#;phPsR1Swk8?~fNFacGzAaKJ#(VCsc$~ar#C;!hD=_<=;gd%V9h97-!A${P z8pGzfY6r$T^0IsH97x}Se?aR zap>qo91?(t?|vn)$pBZ5y)$e#!@e^Lk&-5m2&%JcS{30dzX7w8U7-f`U-8$q-1Xs$ zD%x;?zBOZs87pcuon}@A| zDG&lpfCml%7-d891w;W`aY6-Gl6r1W4~32MJMg$l%CA$mAo6Ugd5j9RBJp;kBuZMh z*$1XS&2#kF`zr8&j0Ny)>QBp^JGg}pzzWwIJ0}+eE)N~ZfAw`#?Hq^dAoFl?kTAvgAPO} zILK8F>vv_C{>LP7fJM=>)G-VZ`ZsKN3Nd89=62fBxj0)d*xB6`TOZEv|E?>jl-ML9 z)36SO!n)2cIFQULe83D$-ua;G47so(tG(~u$T}e3Q1TnygZxck!UW(&bFoRM4e<94 z7U)v}XkK#ZSKO2d{5cJn0j}{%Yt&V8Fo52Fpoku*jzZ`I%qt8umx=QJ0IlyfHS5*B z^wByGLC~RLqd1*Pz#aw4&%otKZSEd|&pfz}@@6pG2kO~ia#?JMTPOfG^~P`LvF!!K zabu@|TkHA9R^+xorCtk)h8#XPAgWOiF4C`dw5%Be3l$4&7ydzT$e82}bsp!1ZpT)j z2@QeCuUN6lw@b?3R2e{dz&Gtt1a^0yQE0LpCG&u74hA0$e(<(y`fK>D$c|O7Bk_n{(MkR#g(2n96MjH(MNJ zjr4GpklQ7_7J>zROp0#49=CR>k)SQ~H`m_Y0E4zzZd1NxPma6pI7f&9hquoi31 z3EF7gS5CWwM#vTf^%&7AdI=;69u^AwcK;}4sNwja48_>@ePj8*n4;~q{-18decusZ z+&YBpGf|Jq{-^E7^4g+h!w{Sx&%Bz%)(-CL)p1PcT|Wk-z#`$q%QfRaoW3@fduBf{ zlhZ9bA=k@7$hVWU1wIwZ{l#7JgY%600{32o$&cv`S@G2oP~adN%Xld(W0zS?g#eTP zcy&(#Cly4sgSHd7^_`K3r!+J)kPHfHT!M&xnRsyYv`Ilg^Z9-mGA0ZX4^(?#w=mcD0ZK2oa`5pf$VDo- z8tn8+Dde7>p3)y4WS+@D=ZOrNFCxds$C^slOLm0d{`Vopos0WZIgp>iARk{Vuq)d_ z7QOFA>&l(wRu{f4JZEzU{g!JL?+M7#wxke-u9r{`e(S#jw4|z^zwp!kVh82z1?t0Q zkF)mSinnw3fyISLq>ef2z6vfryC(9ZB+qaF+ymgRVPm^$wl$7tNA6GL@r$GKuW;?} zPwL|po8pIfc4qZZerNQC#OpHAP%z zj%_G8Dudcl0-ABrp0;v_M<8KeNS#|4i5LkN2TcVuv)XyJYG81L&%x|k7S-g_4ZCQL^X zc1$|=0AUm;1b|g73bLxD511DJgv!;hu1Cny25y_Fc&4L{^z;g__`LcnNxY%4|69g| zLqaX8;qX7c4Wjpebcx?=1JIi?|HgiZ^j5-Mh+u)Bg0t1#?pF?Z>^23e)f6GI`Thfe z!5p#4>D=?<`#?k|+1{aPWFl zQomt@lK^vDFVuG{@@#T#}H56)k_D4N_S6`Bm%HRFVD}$ zh#wx>AkHzAkz|7npfV+cIE?%;E+h^CDxyI~A1}}_$zp03N|!NUDqABvZ}8o%(`&R!0SD;KL?-WR4EN)YZr zu)+WG_M0>;dPpbJlSFKtBGkJNQ0j;4MGpRXAnvYvI#uSJ>q+#8>aG*p`I9PXi)R|g zt)f+~AjLM078!Aavu~bGrE0ATbo^^@gX#h|_l{@~?G?~jaI)>AunVLN=`D)v+h5w3 z;<|2wwX1sR*AD`Al@G*ae*6Kl!~Ohs(>qD`&NBBj$GX$rhbvWtuLP(vtgYN>5t&NWSde!xTBtfEbjUBkN(sHUTH#xOL-0r{2 zq6q$-1X!R8EjrCGMRIAfod=|z0#DTS1$afdR!EOsPr+ll#6-k>d|z|1T~pO9s}-1&9Ow&px}s=&9qaR?|0cSFN?kpu z)Lj-tC0q(Vott{YHQRQ{g1t4grcO{OS4gQ|1Zd_OR!?~|r1yc>3I#8Y8lJ^3A6x25 z3lk9ez*1IUckGgiY+UI2ahS9`nc?+U{$fl0t9}Z%n9paU{rZe7TxI>I-cgnI$OQ%t z4t$$c;kCkVL`Z<{d3Uee6VD0o_eLkc0^2WgS^kJtX^#QvWlQqhN#3?jO>%X%`92VD z?6kC(a^_V=d%@3j&kaW-BwUSTZ4oq)`^Zi5$nYsT_uKGi@HuiTqg-%9?y;vRI<%$2m z>H~Fu(kv>N+|b(JyN*dHN%-w3jRo4X1Ol!V5HJ+c_lqKbsg#2i(~*+FFdAYV@(`G( zb!wND&tjL305%#9%!iFD;_ zflFbuIZ(;9M`GEcAVa9Re>iCzv&CvMV%Na&5B}xeEH(7CGKc{)Rb|+b= z;`t^YbuObK)mXsb)rTQjqzOHw_j^&*u;-;Y1~y-dol8yC2hE4i>xGdW^VQwZfMYGH zoaG1R1uD><|IBElO7l#jxv#ohe-HtX(IGu#_Ug+ZA%B+aODup-f_+8|82pqr!>O4u zi*4uppSks)S8vL)asB=UC$-GE4o>^P2R(q+wZQrUG%YY7hj0z3apyWXL)cJg2fj$* z&kjbzWq`%r`(yqPRju+`bt_=90eDO1e^>rI;?3pTAT;c_p7H8x3K{#U7Nxs-ND7Q< z$-fc+ZS*!;4es&P==5RY<2r<<6yay;O$CZk4H~?pv6=g-&ELyvUwvab+*{O(AC=QD zb}{_iETfXM&@`mX-6tgDaEAMe$zwh;-O%uprA zX<+M~Ux4G8cO|4)?-G`Mp#&}D9)^Z{sGmAx@=0Pql0!IMx|-5<1`u~GFINsND_n*m z%eDNPC0x(_Vha%<;&FY#+338uchWCDS?Qo)8BbKFgnF_)T}Nq1h4^}u&axfvwD7A^ z^e<5((AkDzC!zOume&{uuEf?e_*2m4ly#S;?rF0RU>_Iq&r%oWF0sExs5cYgcr=g9 z@o9z?bib{+X@l^3w3!#IF;Fh9)Y-a=$$`&Z;$Z)R%E$%K2LU!YwkyraWPpb}eVG>c zO)}>Ov{Li^S_B@c2-o0+SYD8p54246sDUa^5$)gYH*9GOU+>eFex8pT`PK5rS&1S8 zYX7i5$Upi|)FjIL9E!Xf24{1^VvGl)yrSFU{D#!%4f_HPoeLVA|3}nbz z83^X0_c#JVR09ntm~&T5%jux1=}D-AOatQWDV34lsvl)T0SRQ+pA)YNx?01O5o750{7Uj{gyo65jU{zu$RWdQZm$VDyjpo1y{ zM9#juxHhyv_zozIrlzJHz$OWKoZ%~_2sMHu|1Rj~CFFjhd0jkZQSu5X{^{p{Agn=n&-9RV$UZs(1}y5&ixeX6U-Emr~ppi z{16Bi(f3jpI_jK2TP{Cd9t_+OK}{F;ix8Mj3*74`xarVY5o#q08jJ>l=BKlu1u2k$ zBSugP=V*}Nv#6l}An++rYN`|)%hZ5Aay<`yEdK$@f^UMZicqK(hu8&cKmlI9WC^a( zdM{CMd%n8Wj)p)6Z2m~>Ux(Y}3J)yd&F(r@|fDFaFV>;FA zD)`Zx;WX)J+HCb)!nNW2TvZ$h)Bbno67V_iDR|W3y>fLa(5)*cWu>Av-urorPdn!G zYDacL_7{GDDER_tJ?qtGD*xbbW$6u0?r9v9kx4>kff zXy=WdnqH__sjBKS{<=Kroh}u@Fcm z3b#1(M*r38PZ>m@r4_Pzu7*FN2t*(C1N+(kzI0`%U%!h-zbhw0pVkRjlRO_30a9rRA(gy?8$qB94TZ|k(BzJE z2{{O|L}LL z3%u<7=U+isp>s6PV|-lfTY#8HHBK1$S1j|-5wef{E*FJI(7YAutKDTPV#x@XfV;D7HX`?=0P7{u zv`f>VxgC7&xL90ONM!N${3~Tol`?HaBL5-{AJN#xLVZgn&M~;v+&S+F!1m z#UtQ2J2^jJ0KxFDzXLay={yE~Ujh^Y2?FsyLvC&q@!vQ4eAoH>q-%|6{Kh2k*H9S9 zgP{xiL-gV)ye(rI8UFfkXbBCa5Z6EO_S_Gue zVxXn04bZIrAK5aXmb8Dt@&9_1e+&WOX)>p*KQscKCM@@{a_(RD2T7~H0rUU*!kW`b zCuV+LMX>dlfu~~I^z6g{1e}RZx6cdzTP&C7ZD$-S5g~_8(C4S2AN$?M<>5IPz)qPk zZ?XVJL{AoED!nx=Ojc+YO2($2HM!leUi}2g_wVkGzb$U*1|uktFoXpe;YMx&-^y0m zky4giq`R2pK>`kI72om7K=%1t$m&6jydhT-&8-dOL(tJbkocgFP_p5Ha>)M^1M=Y{jh>1k!%v!lO_FEp3(V2FtX6U0jxZ^t#=YoosL~+vyIj9ao)!>zdIQE-jX;S*C{`LQA=jgKE zYP0l%{2XBCQvzIsKq|BLL#UBl>pm(wt&j697eT~nG;h1}zvA=N&Oh440r2GK-QVls zdb4|g4lncGIJHlblA%JSB)GWN6jjKJGQf92lU6_am-NRjm&fx?*2oJ@0Dj5OaBI}mw??P=Y3`Y=g|dw&6Rw(VCOYmvp+gJdBW zT+m2e3Lvw9kzaBy>-q_9!vbRBD{lt$znS=#KTZlvVhUeP6RTzKf)~)DsyUSBUm|tB z#(LWKgN}n@^Y5tf(SM`HWhN^DE)`CK#MXnSY`=^mHgzLET(?a>_g4R4Z+pQ>NP8dU zZbVEKlflibx@w`J>H1*Ze@)%Uj z4O}vuU6e9#44|YUdto&nz?pDO9-+Kl~P)OJEF>VAT)sHj-s ze)?e5tBR44ao)jCA&K`Xc=yBnGlGc+aEuLziFfb};N`thx!@S1P~h1x>ETm&^<1xZ zNRx#N$?tx9X7uYLnY48xj;5kjW3cc(*XxOA;f|-f`6Y$gf}}ir(~)JLK>O&%{RKJW zj*n#D1~THdI_E40<_=HUT_Y#(Hy`~lINHC$*xzu;To3E8QSB{6a{9E4(4YO&ba&?2 zc^v3r<2d-fsJhC+}hF22sX?L;mU|h zGN$?5Ho?S?D-vyDp;5h6H9PA|G3%oxuJrJAE??w_;+=O-NY4~TIS8k@4+9BTLa)t( zw#8VrWEtGOuAhtQ4Ju^zR&*B@c5z;vuS9N-KCCC2u9^}zl}351_58~K=vSPQ z8)fRYJ!pRL|bn~ zQSvFn(CU#0+J^^U4Fo$Bwi2Ef9M<~Ebl8E`N@p|>d4z(?_LQKAD+}ondy4i^ z;$3k%ybWzW4e}AE%7y#QK4l)S7W#(G605ctuY{}RJl-}OuQPR#Jx4dmEq@Ysrfa*| zrC1?S6(Yl0fQE}MIpY6ZgLUxDBMF^_tL0#=pjYL^5k;b{AJ}Ji=a5oF?ce>JJ|z}e z56TWDY{;2}*Grvgm{~y|9&?Pq)_^rNnVmD-*Ul$UB_W<2?HM~+JH=c`(8GV?dz^s9 z?`D8hQ!A3@QSb6uFO>YWr2~b9YDR?MQnb?A(612wMGamIWzY!#WL{iR=#U6B9nqp*p7?B9*r$6-1HT2st6LCbrC?&JV>>^M_7U@v$J{y@<3q?R++DWC`yM5b+BVHL zkee3J?G*SQ#U|Aakz!-gV9~!opRCI4-ql6KB?V=F$-ZD@o=_bi zGqaTMV$0)t?d%Yi=z%eokLKe(RcE36J_G@r{c&87-o1M_$Rh~4j<>&OK>x^R9RWzk zrqNMiAhp32&C1H+;^DzdO-f1%o$@+MMh6R}O3b?jZi<*`ivj#F{uKPMNs6@<{t5-G z8j|n9--o(eNXS+W8!`wK(>}Sd{1`3xokm9bgM_0Q4n$&ezXwZhR+-q`ZwCoB(O`sF z_r5=a8vK;cLtoS%w{_H_x4cVg!ZD4OuO6P03ummw!JiHM#2V1#*lAS6s#tww|K7XV z=Z@F5)_IJwt#`x(C+Wx?AGkW;1Nnh1G+im&kk=jAtm44}3@ky&>{)^sTGVS5F8!3q zyb~yQb7Bw%uIqg510<_GZOQvTd>wd1dLEt@uK^`igRkv!&>IRo0#fIeW$jPGl{Hz&>nd z%oa-;TR9YAeLEx>9yA}a_e*s=DEKtR{POb6wFvgTaZF!@g3nKqgD;lhf7xOWydGSc zVh!}Q0L?QQbkYj%{t|`(FvVzZXkVBx;#;6?XiuDm>4Q znB!j^#t$PHXd2jUC+(LU{a%T#p=cH!uqpZ3^-BsYN3Ftb6Xlnh8I5BphA@~B}2o!H(_MHdB0rfGnP50JhLJ>bVpZlI06Al{R|9$f{dS);;ci}$3L(?3Fn1q z&F1Ktth!HQN;&o5dwqFnY{BA~#q2U$a-LA=c8C&<5k*+@1Dis4JHxpeW^t)vm_hH2 zUk3A+2uCX&4=yLqD>TFLw;vJJY^YLW&r2Fp8C6c7`Gz(36RjP&`q7u2rlpUo`zM)1 z%|)JQN&IzgRhfQS zrA?KC5j>{>b!|^QUGJJJvAnRv(S-V$taG&0Pf<6DbIy*Uc`?$W znEQEk#$u}jH>iU55KpF+PPu0(p;Y09$WN2L6;5)@e+6zSNR+3x?p?$Qvim;HSJYL0 z(b|{F4gz8Y6eDyi##9$3n+GWOszFk@bN?e7!Ky>0&TVxKO94CHUf!sx6I9Do8-0yV zskf_+4CGS^UN{HE2;qm*%2h(RF=Vgjf>V1bZk?cX)d4w*n&ZjEW2ii*W&uMq`#c>H z6zKO2W5=YP;za2lbdEElQ3`@z{!avb|M%#ZP%^ zNB9S!o(KpC(NS!m!Ajb7mKS*UjW1|WK?`;-p#2fXxOXp_M*OaESE9;AVqzjS5VE!> zKjx8FP>5!bBTP(6`q|V3524J!5N%*)*75Tv$iVSen?44B$~xQUWDAo`~ng{VOYQY=9Iy*3&gWxa55Tz1$9sh=UCg2`WG{ z*x<&tgJL%s%6p(O?c)HWZ93v?WBpJZ3|F_!(hJbx3n$8BC!sVnl8*jCjmJ45xKd-T zX2H&U3wlLW)jT*Q+t}KQftNf%&~_|`E-4V6i360{KM~3ZK8tiUib`Pb&4-Ziia)(Kwq3c$*kxGR9#=Z$v289A#jn1pf^CHkJ z2Vx?`127tcaOj$o@G79Qrwx3S%{J4dVBLEtai)>LCr2ss=u~QK(t@<<7>;}#fJ*<- zd&9RrPxrH6EFyt&$~*9CqB#IUCO$NKkBxeB5rD1RZ!>j?*M;1iXAhwGam~{Ca)e`G z3p;Q1b8AM#eF{5j)F0+h`6M$RYCTgI4!V&vfddTm9xE_cF%?MPBT!dG!Fubv=6;3s z;SQd(D?GVI)Pa1ZEJ|2`|Q zc~FW)@q4;I!s`>b8BxJ}yPaza*PU6otCIyFGeFSR*0$VaJP!Tmma&QmExebB6*2O# zl$&o;tXo?n9sk zF3zf9B2;67n1H!c{XVAk^4hadCIz<@%R9eOFpYOJvwz^Zn3$L@z$T}3g zTYQYAdhR`d>&A`pXiq2|u&|CPbNuyKxzt=8gHnj!jCFZD(K`Y+sp0*M4otbm2pD0A##DRYcY@cz+w^Qe`;m+NUeT&S%Pm&CK4Q4BbDd#D zBZ{RE5Kad0FC;)ZVJF4dk}bwHu%&)(YiA@6+rPcQ!LyE9lynWnT z1$kH?YWws`v7*ay zij4JmAJmq5vlx`YZ_>w->wjGbtD^4^XYq>1NBVY)sJg4AfBC!AcJ|5~8QiPbu%cki zydHQ`u%zXn)VH@ZUYY>*uyH}&Ngl>1YWQNT(_%-|H3>9be0+RF9v&V*bYv-GRXk@* z(?vAiwtFtPxfkIEk~>JF7+{bd{)~GwPZd;Ep)OBI4jKl&!^)7c(L6B9bH|qugFkJ z-J!Y(-iWaqurL+|#-AbV!8tJn7z<|*B4EclkK%mubrUq`AM*{yW10sqK5UO?Pbzat zIB!FIA9tGs@;;-;0mhlC>03u)MltR(ZFE2{l1LX)dAvmZ2=`S znD?B=jCBR9KuYYJ6?kw5wfbhC3yJ~3XiNJNK^TgR6il}2Or6`99G86yUgt(E;$Gn_ zCr!ILhf*KeRi{ zR{3Icc3=kee*xA=o=a3CBO}W-%42_2Q;OScuui^+393!7!k{s|2U}uscn2OzfLnf2 zhLKw7_9}JYB@WXSwg})oxov>X=Qe7^0L}lz?80rkA0QZ9$JZ-;~#HRK60*zS>WktHK3@1Ze4Ta>rZ?QPVVlo>;RyvhLv#NKJ6VkH=C zao5T-ImH#RM#BLJw0Q&t6L};_1#ste_ z2VaE+TrpN)Z=-zrHo3}SDucdBzP`Q)5fp*}E4`^-0FS>q_zll|$%Pj7X3xy26#Uu`s>%P$p!4Cjlw8J+)=;=1YQhE zLd2ot9UL6&OkoJ#o7xXv>KR_zNx%b_i6gyn?DN~XsQzXL02RfZUWC70)W?tGuH)7u z^EBelp8=*KBQK!6kl;B3@Ru~L^vj0J^JClNE#1i~2XqY$jcnCSG^h_8fSw!2#|8Q= zZIQrLB^aXmwh>-+K#4;60rccTXTOd;dZ(7^1gt1wO`64D`cFPXMMWJR0c@ca=`p&Z>5zfA zo>>tA2uF}XBdw=b7pE$qEfwfD%BgrgM(qkOdU3hCHORc1o?iKwZGAA<;AHv4)(;UW zxPIx)z};^c^C@rUSa%#8T6(?j7&9NvxXXnU?SPde86V$nY7&P(WJ?iDkDa=W;7Yvw zSH%Vno_4TG#2*^uC#MU7W$YwDN0%$Kc7$?=C>~e`ZDC?d{kKTv2R6Eum4)DE=Y(^Y zoq;H*r#}7rKPgAmTL5%MJ|nDo@v)~5#p8PX-6T?{MB#IhZ;t_V-uOUC<)OJK%eyGpBU*~--(?@yN&mLg8StQqtf3obIN@pi0*=hG%ktQ5W(;u^K`Wb4Yp zVhf#3^F~RK*K%YIfxYkRVZX}7VHy4t^rHLhY4&_;i`VS-LU0v3vYfiY<#ds|^pCP3 zvg4+0U8dF-SVhxi&UZxW+_F;87YIK|N#d1g6}X))3Rvlic(;}Jeg=rK>28~GsfUgo zXI02T32|DBN8dG3qM9b~j6&zOht|{NptF@c&3VTJo1-)ofb7^6NF15W=sytvmqTUnAxQD&h7|S zy2YT$mHVN4q`%Qxy2i&mY)18)FYk#BrL1VcQvi`IP=l?(TDJNEjbN?v|k6mSIK+T z*1LNf-Zdg7H3>2Bbe^zx_V5gyUf}Ie^QCI^**QL?wNSb&A-2}p{(Rj`G4GTkDBc=$ zDD^#7BE4mt@)kE{-by!4mqy8J?lOj8pAlvKBLQeVSElbYP8XcI{t+B zfVa^vKR@4D{Ek10GL4V7B3>-3CdYS{lYL|A9e41q#vkR8w3x~RB1)=ukbTQ5ZkS9!?V^Eu%tmjT$R1bhf*uzc@kwv%NE&)pcY>)58%u~$Tfzw1k%5u z@S&jX;9aKwHrv51f{7G%+*R5V2;u$HELLStqMX@Cl|8~@aXkKKkQdkcK?sas`_TDr)~&HA3_q^&@h{h#oijYftG zjT(XRqayX~EB(inNMAP-ny4Mj7fZfVx2ik5^%lqw+2wwPkS8~!dK^tmO#q_O(*5*; zG;YoQN$A!y2`>$$H=#NAs=w%j^BRiOdqs4kyBdvliF5T>_^d}bz7+cQ&{R;zQfl7< z@Qd$I3BH77&CeK?VE$}=_$JRYN5Ike97DO{$;mU?hhF4I9QdH872*2((mu03+KgTU zjF0PN%Z_b@&jXwPM71xiDrz@INpP--hl*yUNwl=$bg4ceR}Mz`-@xNHGp|Y4xBtYm z@3KC`pN^#ZqTHL}TxXedQa<^^%(hHsM0lc#K#9)+Tkw!@sq@R($46WpsZ`b!Q%vP2Ax04;#KOgv^np16cePkFvs&PQqJKjof zPZPWU$T1J)xy{D#y?TfBfJv|f-0BlJZNE8LKje4D{?pyHvoK`B47OQ`31nxqK z-^#cstJezjxP9A2pXi~(Xo=q?Xaqye3?2CK@hdTP>C*ry-?&=!5>7F63eRo1@v<8a zqg?nMc_sQn>Y-OKk~v`dc9sU-6cuiqt)LYdoUQFDk~iw}d_kzXryZOZj}%2?^r|R3 zM1eew%w@X$W4ozEz$;y*`Br}qXPYTFyaaum5k0|bJSnHHC!Yv?m8;C{YMkvA$hW^P zT$C4((br(c3OeN)Ir!)DQw9ASE3` zr-Yy&9V#syk~4Idg&;75fT%P`H-acB2+|Ew0@C@d0pD|e=Q`&*?{{6_pUlPHd+oKJ z75llL`@S{M(s)f&#n;#Rr`Meti7vv%swb?erRp4)X`VxS}NGT#2u*q@{7) z5&Y4&`()Gp4WW_Q+-QiTN8`exwd|XHQV0&-7!@D|2 z8hByT=2NjMaF@ZeX}v@(lJn)7r(Ed{N$83K!*8w&kjqUuv!NPt9HXfrdKG^5g}`c? zhYjMGI;|0@%ZHVX+2Ej2!((RFPi1YdYHzL3}XXx`Ao%Ox+0bApT5!BYT1?1 zChm_2>KTvJnRO)9*8VX9s1FhN0YEIwhdeEl+Y1dJr0QIxdR*sp)6kLj5JdRAU;6ph zNjv9^35IzyC`nyjvu3l}0AHWNKrD)Zx-dC;4pB(D`veIpDO;zen1GV>TpX4Oq5+o> zHB{|-c)Z0TBO#vkrPdSHf|_iL=t|_(EQxvFU)1y?k%db%K*CMbDY~z>m;crS&Guux zDGWk9eqJL=b7tw*u+2$b?-9NQ`%xWJ(|a-46}0&-q51TptfPImNW2M!HK-{;O0o0G51B5z?| zL%B>hu9gI6B9W*cID8Hxtj&{*i#ENK`(J9@i7;n2*MIqVu8?u}q($hz*P9>fN-7ZZ zF0$yj!Hr4yJ8ObjfAF0KXXCg^+;v=f`ZU`%e?x|uhwbPiaHK3a0?+lu7Wb24U8lg~ zdf>acwYtia(E5i z-)IWjQ(ZUIV`R4~K-UZ`qvSt%>8`SyuY@Z7m33@?T3=Y;b*RPwV`2Aim0DFnvc3#+ zkzWKLFFpxL3+kqqJ&;jaQki2%o`n-^9qdu6to$*Rj6O?qnVzAXV_+e7ycPpM zSrW;L^jWSyQV4DtsuXp7av^BqHTv-h*(44dc?W=Z&t;fU&v7Wr;K9g3+Ym8+@Rit4 z`Z^M4f8kq1cm!z-;~wd9ZxpT|!L>N;w+Oo=*3vmvawLFoqc=ziAYWdOeC=Q#5GzYRL>;U9Cf@mu>DIYSLhDaEOFS$ zUWjL32Ki~i&1JZzC2spbp&G@WTF6KQB+4bCcKq{S;aDVmp(G0}Vh!@O@$={@SM8OK_Z;hcgeEvWz%6eJf zG2(&p%WISUs2&&14FkeGBht9CYIeynx_m~@zmCt8MzI-h7Mm0c1^$sxlO6=~x&q_I zY{(9Q&e5NRW?!)88K3g9%r&3tR?4wp%nFU@nQk2SkP|(ADMmImvZc2(_J&dHaTAW- z19O#CVY^{w+`zx&b>|Dr8`%a9LwawuQ2n@8kKg30hT{jGk z+<_Z{u?b>iuQR!IKy!ygyMutHAXa52Wwu;CE+}LnQt|pudBJGPvXt;NUAf5rM*E?z zm|C$a3-uTb(zU?dpHSR*_^hT%4> zkIC;!yG+(le9qHSdNugYGWm9?r~C*BDm6?B$FS^VN#XY$kp!(&IsIZ;)@ z(yEe@$o&PlrZ~PKgHBhwg(y0weh+-8vftDy(u}~j)nwS(KE^kiiGS;E(!Tp+z_0rT ziV&;>5{wE4yjTXK6^Qpre7m$%)a8XxJl0N^PU;76*TZj4sn*T8rd9$+e{J~l_IYn6 z<0ix5bBaa&9Rp5>h4>1Jb@QOUO!0@-d`JA9j#oc6gWlDq_t%dHkro(eJJZB0Y-`*u zEN=2~l;zxU9|A?yDceph_Ka&Ol>w4k84EkutWCO zy^EU_$oR3&7whcG61+0VkT3AJXPY3HuZjqLEac700Oi5z$J)oHW$A%sz>{N0~mYZ zAZ%=Z)==?&f~em?(=FffMN2GunP~+#GP#1|x$WL@*W}sD>@u8XA{007`QR+RupRgI zqC6kyX-H|b@exSeoo=qEV@wmU#BUQn`X+w@=S1TLnaYkvJY7yz&S!ERe0X`eO^%qd>qW#Z17%JFgWMiU0n5C# zc*ev1S!_2^JJ$Ayt{ZvJvEQ6C%ggO2bs+IHULoA@AAK?;XX@=O`0VZc!q=J-kD7<< z8DVQC3YO`;*u~O{-{s6#N@3XOU(+Q3eO=P&8GYDQ5p*XGo*-G{{_ZCTg&B zA=M)W@ueNJ6|T~5mtSF7mxSv0xwiaycn;&ntG}m*>O2$mQd5Mp2(wvbf))L+_@vjL z2Ck8eokj`Uk#w{rsx#P5I)sa16?0R5$eiNO@#(S5f)2}dZv5UuP_BJ~a_PrBQ14f9 z{p)N#q2Q}v&0?nW-G9;7RQK~DED1kRi(KDfh%HzQ1!X?g9*cVWEIL}zqcDs)3ZH7t zqq*p1o06bXw>t_QE&9bO>oQg(gskh<(oh>xVD?&7{5r^_kVPGF5#*>d zY#pB}ztu!BKnq>JB}*re>2WQ?9p@0yj5j_xj@g-n9%b#^;&4SIYcfsAV7J*YQrFzp zXQ3=TdS56c;W`uD_T)c`Ar#%(8ERr8PoetM@tfcNw#W}0dRPz3jCQ$H6E2yw4 z)G&*hh^6BKfEx%bFe#7b;-V;&;;|%dz||n{O&BF8Wr~UlY8~HnHNc&8;)B=k*t1V# ztf^}3R$)P}={w(wyi2K5({7a&Pt5229Awo$p&`ZxBzWE&bOg5g3_e~LP_JSp7A?$v ztmpCf5;DA@)US?|psjoEwJm+)3f0C1iX^V~uv5Onre&`f#c)bl^Rjz-9(1*u>3cGQ zY|#qNLzcoB6Rz#OFBQnILs>Oj|AgwvV5keLvXJVp7@on?Cc8{aVn>zo#a!%Tl4%C5 zDhz9m$y*6mu9vyWG#L*St9ulv|5VBR;d#d4Na0MQ^D;CN+aq;$X6YttTSFlk+pxpY z&|LF{J4WcTT+Cx%HjF6VB%&KPb46i^fArN2?2r)brI?GnQgnp|R}-?3OwUS#$l6Aw z&K8t^yn<>DjuRK?q+k%paJwRQH2N9v&Glz7E1L5{pYMiaaeKI-fZVffzX;Dq1YtTn z>p9nR1Ie~7vGpEo{gqvUIDFk z&LU|&W2z1Yy&E3H$T3VaIT15+dJ#0+0r${{9*6>;EjP$p*$Z>SmZ^rWdu6#@)lHn< zPX|;%N=By6&KjGL6eVAL=-{!-^*x#fuVZ97MVLS?BZF1Va+R1)QLuH(64#?AL@8#= zr|0_V)`5y24NcOb`TZ#dY3?Z>xU+mggHxJBlDQ@ja<*)np|)*rx6shjSeT1#Fvaa_!g6=EJqLedbuT%; zkd`DKJUeJmU?Hf0=Y^Fe6A@f%nVOp0Ae}wpdZTvluovX7<&xIWu(ch?FzJj%mN#)@ zU8ln1atLS^WiF-7W}ABaq4M4zvcF`u;TRkJK^IJ9vsBMg?V=opA7Itf_Ww+p4HQ5w za5hileeHW|4Dby20lLuU#JY=#Jr<$df%JZQu)Vc((>1paY!k+8r4KHp(A>9hObiw1%3ko4?E)<;Gm@QDxiz5*(0 zHG=)F@K{c;gh<`08bU+ED^S1q7ERHnhB#$UM5RNvD;oJYfL9*NA1@ z%PI>rnCcrIF=>eveFa{oD9@@Q!> zcn}vFq3ve$d*|0XhZwZMI4SnN!jyL*WRmSTl!{k4I+=vmOR(tSqG8aMkYbmShYUK0 zI2H$c35a!t#*gXenOU<1Dd9=lmOGeGVS^$5Hc*R$Q18elMBUjos$c0}LW z@AT!mbPTpSQ^3CI?SHqv!R{4Jz!iFRYcj`m4sdHibU(LpB7!Gv(5>$)TI0L||Ic#U z%>*zXU*Uu=MHk=^-s`{IrEgrJmH8>7yc{U}tL|e(l|DyzAG$~@ zIuk1z7d*l$B7QxAW3oNcY2Keo&SaKepSB1&f>L9Ihl`5ue}0`+cgL|U81 zk^PBeuHt(&;7mmDUVVP#^mLnX{1vg{$+>%yc+BI9jsJiN?CNEsoB;UR4qaG#gLrk- zoF9S*)smn&?N^XHO+wyY?`}ks>{PnMjZ;Bk(TK) zTg|MkMrUGTcHJw~uP=)EHVVArXdfD}=DfUbl6U%y5NcTVhyW~Uq!bj-A8mwXCUCBt zi}pU^8|K?U_y+plaEy8NDdPJ>w}RrB4Wakl6qdW&8+!hv$Ke1Kxsm_G2om-^OGHntc^lTs50(6wo8Y?ad;*^BJ z`$o-xj8fec5IlH4wexe~qf$}`Ve>BXv4GXSz@yLHlul_=<}zp=1#z6<4UJ6b5BwPH zsI1NrK}=rutMCZpb9)VjIFbEw$kjwXynZs>P`_onsO~e_l+e#C+rQG4gdZqlSWkOi zY(5{B&RART7cumEup_4JOetpOWDeK7L>)ch z>!Og1oQp53c1|6v5<7;2VeP$^f8_g3*fKw9{FKiJ6djom0jWuItG4=0E`x?UZ?&8w zo>fWN4m*U`_PnD=&35@cRt*YiiK^cEq?XAPGyF|2sExRMBV;SZ>%;L<+FR%NGb>C}oR;A4%Oj|tdH0DDgJt(Y!8 zj)!crD!j>IjvbScH&duyf_r2wI4}LYHGx^K+Iu6#u;A;fuL04s39}#ABC!h{a!%IM zGHGlhJky+#@0Tm02Y`eU;f0n6jU1g4d`}~Ii-OI`JzHaJEG#MtD}GlO6WZSX00K(ng2 zL_0;ajJo@Qa~Stt-Q3ulbky@3&mWlGPr&Y>;J#1qp;GF$xBYXYQOrxbZmTkNU!mcr zLQX6l@6^F3`WQ5MNADKiXH9ZW!m-^9+yhbV5;yf*EFO0@`5$b-N)>Jh>iF&YMV$Xt zGJnf>VDU3gWiBwsm&wGNY>$>U_lqq3mz?_vc3j2{cVrs;;ul4Fbio(Jfb33BUnlm< zRWIsRyq=9aLK3L)l9yff58(@{uz@hbsB3{!nWdhtLHf=U8jKI%e;uw60*>nG>Jse= zSwN0*E9zwQ)--o5!)Y=)pI#&~-8W`uQ8%FHddfmy z!e0T9^p`};Tp>0kjGWxwPkNwEep9u$ z#eN4mkNRORu1h|UI+gTV3!kp_RM>pd6Z&cT+w1C~I??M4Qjxn)p7?lf!AyRBqR4zn zD+!oi`|(hLZb@k=(v{sc z!Vy~g*Jmv7CRd){*!1UN$sF!tt8pac&oloH%;8Ev3{%=BB-!>GQCnSoYC4qws0JOa z8^1y{fIuk z&6uh{nce?*r3^n%YxN;5?|I5cnFg|Rh3gJRi3ut0nx}78*;_1D$I?L78)VeroRM{d`lSvAdH1me0Eg zz_T07qmgV(quKrP;?771gqIO7p8-EUVuY2y*`Q&DLxzdjEOOE-qT#QA{{ur>vJH_R zr2Gzl*(xz8)5Tn-k8sw1#zwng!# zZ`-S;b9`EP;CLop>0D}9@jT%0UXGeGC702|)2Jn~bSsqg;Ve-)L&2|#+pC|?g$Oh6 z>y1ZNpF^;zBm173Rm4Si;Qv=GKqs@;sodH!N|CM6W|8EGY4e#7&`{TZ;g<-vmpR@M$$rm%4P zx3(-xbIxZc#7p6MO?PBmvGSCn+a*x!to=`)X_lU`w6q+$orqw`22m5aM1Q5p$2&I% zVW%@pZwk1Ic!9?!A8--#o~w~RuyG~5!$(5!#B`fvBxL-;J@WEc-)6Zq1(s~mx!T;! zdy6d)Mn$Ibf#dEFCy5y|0J0hFo#SY~eQNT)+x_f`r6m#+^F=~JWyQ%%A;InIQuj1J zyDoiXZIsM|jzfe^a|m4Fm5G(p{FvvB87p_5umzqIe*rXePzL~g@o`9zp?Lr3_-3~} z@70sqD~XEL0HIiWq1%7~80DNqwkyzy+WyTtKYgP4qide_Y%&;YA3 zCxf1>O}3xWQW&#DJ$)6w1uYOgMYbmtv@MwR1gda%>LlsZsRUn+^W-t=cL}xcwB2(!_TN@1phRVxhgvMom zy}T|f)6JS1-s!olHIXVo^-%qFxLTwC+y+j?44=4fh3{^%F#{^_1(U2kYN&xXuz0gun!%r*${9`k5qB~;g=VQOKLuM z3({V-c9#I-&P_=Ftz)i_Io}c=&*ip{(;OsD^&6)DmI1kDDD6EN1Q->R%NvQqrL`~s z?qW4?k|eZaNwJ_(*=*gZE%LblSg@@TPp(}l~z zDJf6d{^A)Zs^UfAmPTQh?nNZ;etJ7-R3BIZc!I1_tDBWcXyc z3P=y=JM=TkAp_^!39WTsinkSJImU_D_AhDYsyT3N&Tic(6N4Z80u_I9oWfn&%YXe` z_M6=RpVQ>+Bak%_pFUf^NzpHc8DL6m%fKMojI*1FXbfa-zX3TtKB73FxlPSx^uHl3 zUOe~j2Zn`4hPj4`ySMjBEpm*SXNQUM7L&#&Krc2X5{-QH+G28ue;Oj7z z9UJeeiT8T*XHw?GIyDLw8AU#UbcQZ{6oL$+K#>}ciRU3!H%nrrm6DH`WX{x7gYIga zE`wnSeBma3P;jvS)JwJqd^T894WQQh8e|1 z2Quf|_VR>y4@Jv!x)kKXsEgWp{S7g1Ky%50Y4}Fk=}Ewu9u_|Uc%byVA_RnYvgBv! zDT&Ncsy7@>CrDq2nD12uZ&@=M2X*xxNp-CCQ99`g2#R!6D=jM;;!#Ni$Omg5DpeJp z$8?I$d_RA;>TnY$gnF6%iQ~f|(%YFPG z6LmfyozN(B2fYTJFxWXKu?hfenNt(+|=U7W98bvOZo#$r(d# zMPfV;0(%4ODb{fu1swUwG>Q{x*x7TBI2KEAjij-S7}Mp(iMK1Ox<-8Utz} zMwCN~Qk-z*1;!2WTsO}Zox|$d?A5ZztOrmAzOBRc%=IO^cO~R~gCu#m?%$~GJN1{^mTuOrw=c37 z;4T=kFBnbFGhL_#RY|NK$A~g3^7JN`ceZUTz!%TILDqUIGId4VSDNtUM;5^Wy(p)V zG?|fHG1QzQk#D!ZGe~r&>*zd*9w7H{?jz;@>X>C_`=KaDsVFO{)R3X`GubUdmTt!k zHnZaIF`r~#z>}f)_sZ1nJ2p5r>5n|=vW+IDW6MCXu!KPGCyOP%xWUy-v%3@nQr@~> zLKPvt*f=*n>k}|UIi)RCeO~r+y&c*V0X25EDq4Q~KXOK>;uuU`#U4{DEF*2Wc@wGr z9HL9y<@_|r64&p5RpV->4fL`}ybn6WG6X(A$XFGKZLsDk3+nZh1!&!G;F>7R43 zbp6m{zp0td5~8Y1EAtUi^sK~|oT>O>2=n>5?VmZ;veOUUX*nt#^rS0y@hd+&QK*r= zZ)b;?Fp3aAP4YVZO+rX1A{Rw*cV(e@v@{zcA)fer2SR*$JLy-{HIf<63vg5jiuZUU z^x;HqmSREW@SfFz>Wep&uWp=2WHLY1Oo2`H!vib)wlL_*Y*hMR->w$%7n{ly&uOd} zYz-R-x3oPYK`k?Ce(z;tdnc^dc|Os*mw56k` ztXH1t=Gz2*R(75j9|n{esJy)@Vgx=S__9C#t+3Y0BKDv(zi(@E^6Tsy`RY5izZD9f zxL@QWlIL}^*e0m#D`PxqGS-t782bPI{<`^_3(ZaNn5iDugmIuJ%?vppalp=T4o_fD zo_N$E%8uwC_d!BDEQ>KIVuCYeJFwnRcdPTU)8YUg=x5o`<(x+xV=I1E-b?}YGSPZ$ zZ#3B&7uf_P`^UO&=E-zato6(x_~KX(?%V|V_QV&$HGCW>TpS+(6mCefDd20#BbrDr z8Dk(*x8|rcW7H!z_75eX8!yE#^ADthlkBTn*#{&gyrXRN=Zp=fntd2y1IEV zE4Oxbw+sgA}yla>_8;!%1 z;&?n{hj_1@wXtNNKV1*9!@)cJPh8|}a`PSh*060-=A%U90hJM2XrM)Swm({RCVqx$sp1c$Kec?m8KTDma_VsPaoRCSC$L zI;mo)>#M-!$ZCDG6wMoa#b;VLLx(a!Lpz(J<;%Y6=u0I^)~P8}G0>M{2seg;OFJlZ z3dP{q%(S$TnAq25e&lP9mfMhi@9CkEWqyH!rh3JRoD|}NG1O`2F9$T#XpA6cRZA;2 z(CQR84wgDKo<592JA-G+#bvIYz|hcei&z&Q3_hh`|9YMq{+1P7+`1=25Lu1Cbs%RF zdgy+6p*}!-LIvJPcya?$NHkDF-x2=ruL6Tu+Jj`0_Kkdw4NMqj-)Cc>fg_8=UrLYB z!BO@lt>H0NDLDwT3}!G-`H_bdGtaRv1pe;rDFHtlOB5Z=dZ}RZ$U|lfwp#LXxFInm^^D`{W*uq-ZyeFnSbI zt-sv?s3RL1bri9hFbBO-fdnt8t1{asd!Yzst-#X>F(+Eq&5&@kjtfhP2`_~zdX-SU zxii5>(JzB_8h{G0Zk0yU$WOqcMdld`VGu(AWh^&a@DUyVrXimDkcuZ(9x6|vWXywh_%(L+IWydty!R0~^mNtf^>1m;NXfMV=v z&r?&rjV@0*^(h6sZr`gm82#b*22&DPJzavQv8k60K#MjgGA;rF4I&u|6mbm*^x&m3 zWoTg6rgIMvB+;mAqnlMz3Ih5M8nyT$a|sHfTaH(#|2`NzGY71TQgXW~AWnRxs@GEm zuVB)f&sqI?aCZMuW|{Rzi7w~ZWw&*8{`y4+#1TV2e7L2}D2>KWGf4UC@};ht+s)R+ zB!qd!a*1;NCZ*d)_a6rtT8x0T?DtsZEufhbQEJ_}Q!(HU-eKhkquj6i9{?>Cye>e| z&wPcTo+nyrqaji7PhH=U6r5TX?UCd@=chHX;^Ql5o~MUDM$HNOkb6ng=J*aeb@Lyo z`ZdnMTl8aL+6Zk(Xz30>GQd_P=70%O4z@g_0F!@<%DSg+A2FRBq5*g92wZ(uwq=Ux;K?KPq(0;J&V`1Ki z!3K-ATwjK?ZncXgFpAP5XzP&)zalN7_lrwT_&;$Cz%@qG*eRvJ+yF~26fC_KU`>*} zIHj=u659e8_*9bT>RxZbs+%BlQuO-Y#Zagu;K0jYU5qc{g)UeMqX0`j+?h26XjlT8 zX)I7v&XNncMwp4&?U~tuxfn0)SCs$ndP)i)#YK5V(bU5c_Cn7pu zhmrzYy@#hRx+0*TUr3*?H!%Gp!z(DRLn(BKuZW@oGo!j7vMBGur#q0qf6)riY)d|W zrm@5U{x_k3^`G7OHk}mx>U}u>-K&FQyP4Ux2r-u#jQx%A2w}TH6fi+r8UI}D!-h%2 zfx0AU;{JDcQPpy_0K9RudRdk8bVhFd+1Z)vY%A_tX+LV<4kqv7!Vfr_Vd&!k*oxK0 zs15RD(F=cO=_BnF1nd%D>ooj7hXXrk9B_|1-5W7?@$^&zqGql~JCBbb%GS?-%^r{t z!4rKtfhaE*0vl2&cuJO5x&&*B1rYk6xG!t|kr;#wm-X9U8n{<5yWVBd#mYbG?;ng( z1x?s>tgI}S>Uj_Rj&Gu&S-KpRXqbFTswr-c>iP}I&mfrs)2NaHXb((%9DI#4Fp>~% z0c^5UQIF#duhqi**&;Sj2xLvq(slE=rR!vSj{${ynydDFW?=yb91Vf`N@V&OIFfxC zTLuU-TC1ektRXlpjUJR6A%PCa91t0h$LBJu7X#YCY@yenSOqfJ*ddCpGchk!z(%(i zC+_!g`Ck+<&;a3xU7)`K6dm$MN=9pNK*{)jkR)GSKvTt;9unp284EnCEH4=OJAaFZ zZko2uDFLRk*-Bu4t18A;;G*w%8?dEF(b%4Ole~a~>J1#+L(AT({B8T&hkmd;QZ9;j z#s9vaOD0dn%=X^L{xOrrI*Fs;cbeqQz5ANQ%##OAeH_`~IgK%C4ccE>vRgM2*W4X| zd9M0YCh1s1?`mxrhaTK!W@TT%Q|^FuS;jtF5i=_hj7T5prTd0ru?OPB))*-AjAklu{3Sa!*F;DNZmK=cN+PwCc z9u1UEhJR8_D%+dcI5La9+abX?Obl^OgB15U5BN>w`M3`Z;ii(|0oW`UoH6IXPE20w zWNFC~fANDL82Hb&3=L5}dGdr`R8$4%-XsCv*9D*r2z*~DvvR=400&rGQEIg&q7BX$2HQxrG{bLmyyKo=7PQdGq#Z6(aW|3j?u zu>LNyajsVSHDl`iuoHTk!;?T{RuTa)vWgHKHj3_WHkoTSNbnY`@VstTZ9*Z;J>;11 zv%x0%B5!{Ano2GQ!9RN1B7Em%tbEL1xGM&e@3v1Z#6>g)?16WzvNAVJo0%}hhWH# zcHvt_rvkV)xJ9Tlj{?s^H#G2O+txZk!Dz*bJCJ;A|B4)yiP2!F{^O5@W?cz5~43-aj0X9ZczvC8Iho6 zXJ>#Sr>~_yAc;v;Zz4n*Y+9OOcg}zDGkx;aRQR>}sU!F$^$&+99%Pbjl%$zx&RhxT zqY?mOWKKydL?DUevIzc6Z06jX?6pFwOZNO9O9SdqWr1FzrOi2}hw6J0^JgDax)NJ( z^4DLZ)X$#cpDu+Mza~Wi`Y<4ODo0{hpfdi~f&rHR4=PYGK;;R=hAP;LG9%{emS%pO zECc>-f^#O}$@3FxW&B${Cv$vevQ5})_bO+WH;~dFR8WRB*U*Lgq2wk)Inq33%M`(( zg=qENqZoZ?psDx7E$T%y%d9$J^#CNw`)fxuS-wxydFh90B#M!Z+km9GjM-+2Y$2pu z-w9cE$7mj{KT6IJ+Vn~+n$PrqV+WD|l{@&9{G!WAw;aoP{~zH8|C1jmR4EIJciUZ$ za+g5S4M36i-~(lQi4u2>TuGu>*=Inru%^tI`x~dFw!HyeZkUC9AkK66cyjAB>$w<< zoTeKU*G-&+^%_Eg#?rX2c}ER!@f(EH9|-5P|Gv`V6W$cNw&+l$calv2JdZ-rehxs@ z9G`}U=J5D2&Jbr<>y z#HKrzA;o-NZFZbTzZ+sks~#bIEv0I-L6YO%Lmuk)6SW?2o}z1aOZ9GVL7@-e`4CmZ zmE&c%BwOwKTu%e`cD)LIePk|MB-UtDk^Bizb2zB$==f7%aeCTvKfMpW3mCdAti-8h zG~YNxDCCvCG5sj93)E(Tn26Ov07ExgkMu?EhRT*VC=bF0{G_lqVN^@R#YK=f)CAV} zYm($%ad_~xy_5NIn`&2AW_g7`tCd)6UH|LlGafGzL!AT7I|TfKm`PoeQGgRJ&z~Zh z9WHiLr@{4hX(Y!NZdX`UpA`k3e#`|O{buxAJigC;mYq8+hYqD|n!Ik&Z^>SWX8$?- zG|&r&x~}Yo6_0SfElJ}HO;O%ZdGC}%fw!Vyzr4_>1bEdHoSLd8+@_Ub$F-xagxT~C z!wiQzN0qe!(8wamG0WrQI{EfUJ55!FF7s+uP+i5lMAt)dY}_(;UpM~t zncOjaON>f48B@w{M^@xGNmi6~k(0^%Lb*P1uh^id5@OQp#WadFmnaob@5OwHFW0%a zF_O>;s&^DK5Y$1M)I`b>`%gbVp^Sd}gcpU&7Dg319cBRJOXm?X`HUNY zXj9mMDWRn$x#h7g>;HZr_p_>zD)AF%Pq{+awOamrUnsXmP^(7W?9gkzjVJF6^ZtoQ|)#W@fRhZ}hM zoBGdVViB;5bxcN6d)pzi!>p8yDmoer5!oF#!|r{AZ_A^qslD^X2sW<=vm+A&*NCV6 zE_^t)Si#BD;ql9bbV>JK=gs=wb-yQ1XOzlrL^h-?6*|%Ly%)NLR4C(8e_$6q)`O=E zY#4|$q#JWGw#+5U>Dz2Fb&d6M71<#;e;jI-mNTe^MG|6v5#s*tD;|q`U@Xd0N~FOImnpX5H<(%|usbgvOFy2w#OBheNGR<(GeG6bXG8;;$4r1#5a zsRXblwPi<1_Ihq)4|s}D-Nng}Z78NTl2&%s@%vW%b~3*gO6CfO&@v2h6dMUY%oxM- z{>BRNU@TSUjptqLDq6IUX)T%7E|it74#3sJ3y5OQ-@Jd)%Bw_OkW2@QS(q)?nt!!y z_x<`L>qH3AQHjqqScSOb4y*5LrNDbM9{k9_aOx3^ z$bZ{lf2%I9CtER#_~A~D(r5FKtt3tI&tAB|^eyPcS$(B1w`@g#rT>E(wL}@Q63;BF ziJ+ZL{y%2ljBnMRR$Tur)HH2qupJ@yc&r;Oh$#Jo6?a@g8w%@WG+Q z-=2HLz6D{aQY4>wVwJcOJzRdJ@t*WNCci~=+^oH?;>{bvx_BLB6v7vo*(}F+&wFD~ z_dC;BoUc&^uk+2l!aD46>bd-17lmgF$t|%%7Nln(Wr(;-<3aG06&1H65(t2@k=CCOlP`GJ}odE51nj z9!|u0Q5ub%d>_^-{~@F4!`b=H&{WwcFd#g0h)BpQ&cB02j`WGRhZQV)TSp@SI`ypv zt0hfoOq>FTBW;D+3sPYLX&3Nv<~*2rsl9rE+QLl1DgE#}N-%liyFDe}hhTItiz~oN z;MU}bF;bJz-8Va|x>hQYsGap~CrUm&?I=Ryi_}md6%nhhShfdA$`Hv@zgZU#`t8q9 z8>|>n%P4H>tenRObX}&^P_t*U_l>Qh4ID$ajzi}V0!?kSFE@ZsLkXkCPerWK;LCZ$ z_W(zU6BsebL?SlDMk-HWoIbiAIbDvvGx5BcGIb(PRBGah^^2P%9`SfM%;Hxz^HO^9 z)x8|oV%YoT2rnMphgfBt4Kp@(20s3!e9Vov%sAd~@64DwGj9CCangZL+xxL4chGB5 zD4@nY?{AtKJX!pLC_ls;c`bzQJ~zoDF(qIhmbF zwzZjFs8S`QT7}@TO4xXadraS61U@~_T-y>QLZ!12y*qtRK>D!g?| zM$+5y7Pa=;b)elNI-}j8-D{f2%lt~lZrD1{UOT$}jXdP~i%%S{`XnJn!P;dsUj|HX z4ZXpiOJESY8IW9TI}8hejYRf?S1aPQ<|2OEbALuN4LcFFr0mutNSmc@;u39!oe z>rVeL?WxOt^TLQ>ZY*wKiC7k|$<@Pa{7o%8ZQyfsD)81+2uO38c)}Lmc5?(go2&y7Qv_ygU`W*@xz5XsXGDR9mPGIrA9!sg(Y^A1=!P?xhis8&(xH-h$-M9g5p#Qtb)M=4b5c zFI3OO#Kd608xbh)>C1w|NCZgj+{;zP1SeA<>&gnmZdF0q9gsVSh^1ScQI00o4P;9x z0fE4=?h)?jc=Za_Kde01Ex;dO;q4u*pk+jxW7Tt7IJm<24c?DWPcsAEv@qbpe!RWC z-Lf`P3?vUV>V3Ac`Z8rBUcaURa*yFayK){Varzu?v20G(MF9Ktf~Lp4Z|NSEJ(Bwm zXEut4U#I~{)d>9GZ?_D;c&hR?NBr7JgA9lGN3#aB1pj$C2jVz$VbHqfO z%c0S>;SUBt#vciah~|OVznI_tM<^XYD}sSjRu(XV<1($S=oSHfYK&mOJh%qJan`}I zgx>ArL9nTFb8CGB5 z0kf5b1vuaLbKqSCoXT;5hZB}KL#fkv70{r2ek@7^+`nd| z;aJz$F85I(TBdLXtUR!(f>VtqZbA#%^zsz~@SB^{YppvW-`|Q;0MB;#pQ(m{4+gh? z?2BUuX(Q$^{@_6QX|EM!F_bXuFDgJe?(z$6#i6S2O7d-eFsYxudY9Q(Hq*oU{>k_yJP5d{*E`+ufM#j6uL z7{DTsl9NgIE(-s0;50+yvyu_BSE1F0X$1#?#!f~E#`hv*u<-sZ)um>T-}EU?L4fB` zcnB&8(_B5TW<|gV%1?duJRXVI@XP0M!d+*AOhD=`Nz~bl$GD1k@!RWVUTYT+(!^6O z#C`r(-L%Bk6{*%NcGXR;UHnk=GJ&5478H~tT1qpHUepStGwPj)ft4kqMKWdfO~Tb& z@W-JOAfS!73|b)wow7XIEQ=D4)txj~<23|KT}7a!$?8t$v=Iv$EV;&CtARyM-k-j( zHl{ShBn$lFBO5LsYq}oyA_MJzh}gjAcZacm191U!U;2*Dh;P8l^9)rGbMTLAPzgmW zW|hk==fHJp%O0G%Q?5?kR)W-3m;Pu2O5i_L2Z8-cifoX~XKKZfyR-@$z}Ajs%kdRX zcJyV#`3g@Tp{?k2wc*}xL0cL+9WUKCtunw%CkD*S&LRN}$&iteAuuhs3+qOE`UzHi zOu?}Vzq^5T+!YhhgmlK$n@FcrL|r6EW`UJVnr(3eX~WJ)~w zDQu9#hoe*bf0yn~!LFgb2bD;fIIY#cQ&KVzN+2nQpK+ppzmr)y3Y{;S8;5uV@ZjQV{l9_J80gTZDWam#?uLL zl)WCDibo#;b9%Esu%i?%pPB!I!wJ0!a$bfqjbu))0s2}CW32DsspY^Yl=hB2W#NNQ z_QsY1hALNsMyg*e#09%AJ)dI`DDqxCzjCU~>vGd%1aFA55wX;hmSa1R z4b~ly(uDbvKL$T5(iuI_#1gn_KaG%X$$fBzG1=uwsA?<96TOnd9WUpibOl(!J^oBF z8WfpCZA>a(IKshF(Z@K5eQYvLEEs(-Dd?Uri$JldBU$buKL(dapiBKs*$}0}8(3U>QJx#3?Pm1%@*L z1sFq%RxO*ZFC14JlnAe~3KsegutCM%jTt~MAkBVQPblyf?&2s!KsrDniGXD*j{b)S zai4?LK;ZJ*bqYDoLoY*x?lmPF8|FO4Mi9SO`0twHUAVU63nG3EXo74$OL zI~346GfjaKYnDXf5G!AGG`|#a1dgWCWk`qg4Uvc2 z^FU4Ff$ezJCto(;GinIt%1pz5vpvH-qOr@L^t7VOcwA=X({=)nc5VQ(zOaq)&(>hh z`utrYSsnfm5&r*B_m**0wO!k44=J(pbf``pjo``PckKkjc{ziYAPTyvf^{^K~txKEL3@YRXWHZqYc6s*n5M~w8e z2;hdmFpw<*Kj=i^@6AneVt9bYjADov$eEIq6OHaXU{OQE!NDP9)xrjK@JC&_2nWDy zScW&a^*DM6p%IM&NuvyjV9Y#?eEb6K(r7SVeKoEdC;pSoj*otC>D+9l(FyT9a=c78 zRV}vXE0WyeCj^dHkEW~U_E;jHI*JkRA)!G!K_Pw6KNpKZ;k}~BBq!*;P`8hQjJyDj zNp(KgLhxd%AX#Nl{~CjY(?~-?3|>DKq^KDmCp=V@7$pGD)R(IBxR^)gyKt#l ziOp+L#Vu_lMiy^)yOLMqm*pXw=|7~4NwI>{T4d;(m2qg$u>oB!~?VgkLM zCv!XPue|o!H=k#x?QL@Hc{q_k!l@Qx7~B(1WEc9gBq-rsgSASH?!#a$P)87A6dd{X zLFLk(2&SZ_#?nb+*45QP!ND20PkDcSc@_zp@e~$ku`%E#B7YFXO?<7(kI{MWZZpLW zYJC9jeJFJ|Vtd)OBoS#87bdf&b$EOqKH;R)a3SQJM?Y@4mcPE-rl2XhbY=h$Ce-hq zFQ|S+a=)1(+--lUFn9aZ&PWBzst*!I1EU2cY$El(`GB$`vaZFgt*_8DL>k72+TXW4 zFzuYF3?qRdm^;>+zBHaWdJ-NK9{1U!W9Ok^6JzwYZm!rP0%JjQ44Ezkm>i2nbdAWKE!E5i%9FCXpiazn&!!NB=@bH>_ zg;=q|f*L8Xa5Al0t>fdhOa9>c3`OtwL--SEme7G#{A6@meDz0b8fP^_2HDpPwGq=p zYm8b$t{vyg)1w65;TavRU11cQX%_}*3rYFIn4vYC-C3U6v6sJ|TF!2|Zd%~Cuq`#b z)w9^1C*rw$xWDvejk(cdU*lN5V^=0$h~Ct}eC^Se-f8oV)%}JH%deE=Ah`{g8;-5H;Uit%x98?35)#5e` zt(G&(v1i{3!Siy143NY8ibSTHTk6Rm)cI&JfCl%*w{C!l`e8&uaVha+g+MBIBe&k` z)DVsJ3R*`srfM+=8>G=^JvcaCn0b0`xn`;57LhB%-i1Lajp2R_ zQ!nZg7#&yCwkm>j|CnTc!8b>TX}aoNqt3{MCBIIQvj#6 zTLt1tGtUHBwI0HL7~Y%gc{Ih@+zaQOA=?G_8|X$2c|i~q?;!(dUGt@JDvO1s)4%m? zsa`cEsI%S!YE&<5Z@&eRq8U(_Ox+B}fU`3=g5=Q+c*sLT?$#00huC}y;Bba?w|%2> zlw;zbJw!aH(k0&c)}RP_NPP?#VT@d{aeun>>!)!+3(fp;w4a~rpU37OFGLz`QG~~D zzNGC$E-WNGD)z|ai2N-){@wJVtaPLu=5k?EkoIh#J45kUD=eKW)}x=}SYdkmg0r)@ zFxPQ^grkaPFL$b4C39&!Z`RANUMa0{08!HmXNU%mQc5nS2u)CBXPW|6WGFy6-@qO^ zv`J#6@6YHgwm~+jYg!U0G>@(26VwGQ13cO7K2d3o+*sF-lVH`#SFnWA^Bcv$1?i3N z#zm-~T+;A2)-qBBp-X<|3_2AUFW*}ESABHKvQwyaAVao;o_$1mZxhbv{Tr&?cCdHo z9lS^t$zlZOi@=Ytt8m5jI;Y^J;;B=fiUM-T*wm>J=%z<3yFwHKL}D6f_z?msIZugf zX= zXn+oJ_d%yIao_r*RSc(z&sp)2PtB6>(*(+VHxa9L&V@@1g+jJ>)JK19H%b>S4KBXb z;mcw=AS|KmJWWCAU)t7(83;A$V<1#G>^ycOvvW_=B11_HVe9$K=RTr-(VnxJXPu<9 zoAL9!#Z@1#1Qtik>9W#m{6H>wwfT(r=Ehzk4C&_^0M`qG9i_0xIH`k?^hpXlK*hns=%^GX8=6!|+7!T}h zu?lRE@zA9x)}u( zFfBNJ@q2Rp-g}RZ4%ue|R&Py4$UJ@zyc6*&WXOg?>%vkiR;ertks)!v6Z%{)M~+5; z56C6Q4hr&?Ts7eIsb~8)YH>YrG?}Zj-#@!?soI7aQ|Ud_r(^gf4&kXv_;D2*NRWF! z|J{c1vDhSevA=+$!^VOhA%`Z~cqyg!Mw#ZI}LB&*?RF zUtEcZk&ympkhB=RIS1Lhr9UOBW3@zK+Xo-L9aiU? zNS|Dn390N3G0ztkW2u>!HgCDMIBy$Q7mGbYV^H|K{F$+ze-a<6+`oz|(I4E?7{FJvmN7ndh=tW6r?h=f((LLkk=Ql^?w&ik_VFD z*=EDwDNZSJ=2tww)$}>=g&4wP{J9c+Fb!6|AsxKIRCpf{th^9~hxcNoRo-3FpRkXz zSG_Jys7kd~>a+&Yu*FgqQ5Aj;&>(nu&4ByuZpj2KDZOcJ_bvRg;9M>Vj@Z$2< z=1g0ByUUs_m&iRLw31&pA@DW3GOg;i?JY|EkN-*;sFikk-W;XjKU&#Fk7Yjif-E>} zTvEMNJl-VAhPpdrk3D|}hQNeb>97SpI>26l2S=CvUjqg?=mT_} zPJ{sp3e)yboL9gz9;&j;D+5qND|%BesT>sd@3EC?5rBya&b!~)!th5BPy$6}qJ<($;`|_Y>{2PYiWY#t)PF!=RGiUY z5NM0xRV0ARZ)dO?bHxu7n;T5v1S$7{ixzlXMwH_i>}@(v$FzC4|z1jOIp(hj774sdB~ zlc)AIJG|}RCtlVHi=k-HsE*EcXQnralLnZeh^+;Ga$nMehj4QdMIA3zcQ@`k0lYM6 zxkIqmai7=VPx-TQ>-!}?pTg(*&7P-_e=I_9HmQ)r0gOyo0Jbx()xZW;CC!Zs< z(Z~Jjj9V#1z#piZ7XTlhsI{kYTIux%LRz?rI8UpXyde>}3T&}IWTYkW%W&>?*vqg_ zMqoe(0l)#^QS2i)#{tMuBpfN@xCivkD|6Y@vYDw@(CBX1c}qAMEL&=u*FBLWMIDjGe| zHWLF|g4*p%TK@$wH}}$@2!VHmnG74JgBC&k4zYd-=rI8kRZ*c;Zb@uGX{Ou^4UV<} zNPzRDKcj_7uP#6MhXQ_kZx{RjkiUpx0r`s~fM?)MB1#Ot2E@|KX8uiJSff%WfGISG z$6x{)@xhaNfs4-t7))614#*mNq-A1sruwS*Xb2@?`tLj<<6t$kxnU5B<3>cSp6vq_D%06#)nZBj6({B@&!!@gh`4 zApKk~)~gOMhbVq}+&6OSryH0nU^13wF%tq-382{EVS9)wgZSVMrYrhI(ZZ4Ky+Jkz z=wb=)RH;*AL)M?fIn>Z*4o7iC9b_f@cOxZAISMb%&+%>sFUOgh!w1S93=vdF32Gnw z$OF%q6&W_XuMO@4^o&b4l*a%7KY0FuES;2|0Rf;uc*44yv8xw8_rY$o;0XmRY2vFh z)4@t#hyH)YI&l;HCV9|;WRf@xV+5UF9&Jx2)rN+{uc&Ilqv4GE_lh3_$+H*`%>KDz zc4yu{SKRY#ot>oud}nP8G5$W}KjNeJc+l4yxDvSVN%ErIEyjX*+)wej z9^CCpich2d<|Q6Ng5KbCzNH`mOJbvlq7+zJ!iADEJ_X*T7br(a88JxX?^crsj55-9 zS)hPBhQ@RyFpD8iAk9JTSnsYKYMMh zgYc{U?~Fvq|Ce|686=6}^B<%l`u}4h4(0Oi2*t?)Zk4^gy@lfEkKk`@Y!Q3>?C!1o zrO>TlO8{xxR7ZL4NAS0HMuSk9F8HI--I);dLjaA@7j6HuKm05JMdq`c2?J^LWng4# zWc+V?!vD)Y@PXI4bzlGs7~*>B9G8ETJ(rw=JOKHd)}YkyF>n8oeAwtkMI%Rn|A6NT zl8LjxsSS$Y9Ek*MtTF)qo`oP1;2?v+yyF#Or+Bv*{!I;-K<*a9zoRC`ZxZ7JxASL^ z0(Nk6Y6rHGZ*$jQqSKfB@*C3IC_uOdzs;~HQUZ(*!FMkmi@#eQ?3H$iFY(drfMFB> zUwW{;eh_s2@>Mu(IB@BnJ>vhkfRBwJUwIj~omTV5<;n#LI4;TbwlB{hJZbhdoLpsB zLs4S>rB~|i)`O(YNv+yqF{pQstda;*)ws36bmS5$5`PsqE{Q2HfM`cuY2jI@M#e5qm{Q0h(0K9ESQBLM&#{tY)h@+ z81N%Uf#hgsmI4VC`^S2VxcEr8!kNs@Bc(muyKuN* zO>u?UVA9HWdkK$nelz-zsU}Nq@*?l>o5`ky{rs89Xo|mprwx`AG)ArT*dd|l=ZID1 z$xwPsg9-`=JJqr;wogWRCMq56vPn*V_ENxTqP7h1**yovz9X(`G>mgnbrcP=kF+dz zM}|_K4`gnw6&I)Lr!=@ZF%du8jEX({F55pJ7R7W@=#QV+ECY%o&^;QxFfhGZzcdBt z@`?G7HcKzB49WjJW%DeO>}?bf6!`65e!%+_bvG2u&qz5iNI@1CKK}_RQO)!afpW-9 zr-$y3sFesH)MP<{Y+?ito<{LCt+mpI(?{O_5~ znU8+aa@?-${9S58h)4;XGoiDg(b~APU;6z)v zjRekRp9?9G z6!U`tL31SP{9BFn3*RXFNKbywg`86Y0S5Vyvweb0hmZAnqg%4Q1UHjrdYPMHymCp* z_F-q^?BwBa;b>>D`S*O4wPf0dDocmZH?c%$FQY9Bt}K6fz6m?I(dD56@;j~OZsiy` z_NY|DU(2BxtckWdH@!<**Rv|g+jb|$*s*e90G$`n@`ZHl_$ z;-b|#o+GC{Y4jgr;T2DPFFEr5Ezp0JJDt2cJMEQWc=jG&*EvaSDO;)Em^m)7+Cf#xhF*iU)5~s{|slL{mjAo#7j_!_9Qiu4<2RQ z=cSKaw6(0A(u%WVnU;|C^``aix`Y{$yK{Q&R{7ez4M^W(E z4$*sdNiSIwpwnW>@YB3Eyj1vESvNOeK?#NDhB1Ub3cvlrkiw}_%WPK(<$ia>kjWBC zGN0NT2Bh3CbMHEXPGkPQnL!15--;cDI#Qr+eRXNV!i_O#g`5{3-j@+vKqw%k|#}j1`}HUvcEZ1 z@&8fs)NO?rhX9TA5@Z{y(5Vq%!|MJQ%Z5?SFPEgTxRO^WUQ>_N1lSgumkV>wJidaX zijCk7pl*!S>#PBg8Xp_6mg=J|3P7c079EsZdi?6_QLAN5a5fcEgi_=ZQdD{Ev1y^E{}qY7VF=xk zSi;f*5+<;)cKP$uG1nJQL5)1d$m=n9vZB%J26_I(`xaBA4;7cT1BkAsaJB~#);A-n zg)=X-XGgEO3KU*#zQKEy<@K%R;b~Xx26pgsD$Gz^w4fIK=>W@L77!;QHv6}{EM5M( zl`o&qH+$>UIb=NRnEs0qo2kn1s?r;(NC~a zCN9fF^7v}UJHkt6sWoFek~Z`l1qe!Hl1q@q#%Lq2iInG{KAzsTF1L_d_DA1tYf$_Rr1=W-W=50E~Kg*;SjT)6g`E z6Djbcae&ip`Bcw#^Py2W-bT%9EGKQL5FxniQzEqya43F;18K{cmAy{F z5;EEV{~Y8wE$B3t5{=%w)s|yOY#hydffyEVzQPz>9iW0I@HTEKOKaF0_$=h`+UfeC zBUE8$=_x~jS}9Neh-Je5BsA08n`@AsMTuin_>#5- zA}pbqePUc(KmA&}MW98f^EhUCA|m3r{*%$5(M=Tt0cbvC0Ub&F0hRC@0eSw;(%Gs8 zai!2KxI)&l>sz-RbmJ~~%$zf;0pTL9=HK(Sp~iV1NNrUhf2W)SJ&5`X;x}<@4E=0( z)Va;ORW~yNfhR%J0#nYTnQcW{3jNAM-2IVJwgD1&$|(B;0y?MX1L|8r7zE4HK8Fi2 z0%Xy^cgswub2h->)#fVO=bHVYqLAYq<4U#E!j>CD^IxY{>vlsLuQdG+5{$_bBXfg!`gQ%PFR*TQ)qmckbg~BXeoevdc@@GCt+as)6BMSU z^IZ{ZE}tD>rEw-^vk-AmzM3dAAG~Bo2xeVOJXNpeH?FwH#@JX(qyu3foOWztp_hDr zWjVa9y;%)*aU~jq-0#7bCmn=xpIXo%{zH^y8kAry`|$`(iV`d#z6LE`*x!6UV1G3} zvAv-W5`fyX#%Mo&bNq8p25=JGBMw@2E`TBLcCdo+=;dG-2+rvN!s$&D5e9S#;j1Wc5IZWn+=5FnmV?L;b zd#sNm3vvv_zVRKzjKRh=YQ4k}ZNG2|hhAUdJr|#wg62G*9^R<(5%d-Js_HZi@2OZb z_DR}`NcVXdZGSmf*&HrLJX|SLD+a19K+fZfalM&t0uOs?nTKiJ@P=QmAMI?k!u~fv z$1mXBD!DKj<}`#SpImgF_iLnsVkG*?!v)S-NP^SL(~->{V*gT zuI&Iip~W?l5m#+(?Zg{SGUWdPt#DO%_R~bneDz$Jsj648qTU{bmPCjww?I+}=ztiZ z|BGq{T=l!4mYWD|8jzqfx4SInvWV$@1`}aKC(?O}d2P8^7PGv>i{_J%zeu0DDtEem z%RPz4VjNrC$Z>7$P_4;BnRva1rQ-JX{Ff(y_XQOgbn2qrH11D1O~V>(#pg_M8*K~) z$z1JIB!QQSZuuwLG-{(~ZfBbOHB9y+d9iAN)gev^>t3+h?sRQ4>0J3kk9X4r5bp#h zo71OTlL8b?K6Ch)RNzOe_1L#uhd3^G=WpHX!;guBiX)~7xjYrQwxIQ&kO9BTW)trM zXiM(ok*Bdcdrn|XlK!JWV`!~?QiN-sX)-SPDU}Bc;MS!Aq1W@Pv;8`UMMR+2Rc&}=b_5HI82Q`$An~OMW@e+=CZE$&FF}AUX(;RZ90z!LJ1uRNCfE;$leM zDVYtA+`ehSuLV3h@M~ex=rY04ZYLk2DkDT^DPf$fe=z_=DbwI26=o|U;VZX5f497f z{-iPj(lK~48{iB>?2z^^03^ymr_iOU!A1PbXz{}s0J4llmT|@p+rAqtLN74Kegy)<015EzUA60?!@Pd}-RF1MX!|0Zx}L=eAs^X^Slc#IVDV z74P&!AJM5K;UJS6+$w+>@o?z5!hf%WAE>+GMv#4UZaRD}@U-ChDr%>w1Rx?Dgk#FD zr{AcAyAYgDVOjjpyU&7r8v{Pq{kzYSJ7B{OUm7Kio0akL+$N@Nk($!ADN(;D3DimykVbAi<14`m%j7K~o1r zTXjC`q?m~XRGpKTwH$3jr$mp|*vPGL5fFAAoNmciIJKyN2e9WxtYe>x4RsME2OjNJ zTKm0Oxys6t-9PyKY}Ikv&67tnSg0z_dBSSA>Hg%&QgJPc}oAPJIY}& zWpZQO7XhKJ!3^zZ0(0P*e0<18=`L`#&iBb7$u!I-%`<_?*~(`osyh z%U#c3Ja5a6wjA-nPEq?3`NIfXstU*&f-Bxqa8f(1a7I?7*UErxu&QL77c&x}3&Akp z#LU(E*y@RhGwNEdvaE}%Yc}&~i|2Y@I6+;x9hF^)@w=^OpFW$Ui;)kBjt&0=k6xjz8qY-=sOdg~SP zAC3!`?Ore^z|4f}qRBiya)*Fr_ug)#jsmnOc)yTjXm4j$pTYHJK7O=STuO`5ZTXVC zLx@=XRCP1f0V=#SLN^=kRbfU=4h(L1fE>OFr>3xSRzI&gSSWLvzXQgORY^Jo@FiSOpk?VG`Jp* zQQ^LM44RZkfb>T!o9?6S7#EW3`ZrVef5rTjXv0B`@XnL^8&xniy$D4Qjo7f33^#px zZrBg^FLFrKvirlvM0{m8umv@q-hO$py!Z1GgEEdmvakP{YL=t@G4kfwoTk9nTYCn@ z1mvXK*{rlW{wq5MfGV?0cBZe;YD!Xy(z-4BG%2aqPV{-u%!S+9`%)7OOC*Tkf)Q&p zg9bO`0iVra5QAolg>yRm{()-!TMX2p;%;W&nQb&V=JmM>dT8*4!;1G(b=3PG#o-Y3 zY{-Ap9CDd#h%z`U1p~q3BJe`P)e->HVZv$QY&yN;#)1$s@>!O{dcEb|tXyX#iE(u; zh{^y&rP?Q8kbnp8Ei`BEW2X)`GUKN=x;l+4eyh;4w0hXnpB&%%7% z=4kn9N5%0y?X88yP{GlStNq-<==StOVKY=UVm4CR=p=r-g}P)4kn>Ntnyt?edq-VB ztvfL{U5#aL+$>ceL9{5af%>>F4wWkP%5x_)m zQn?VciP_sX8(c@Vg6Zt+~odIKvl1fkVW9?>aV%lnsgXU=p)Zsi(?c3mGPDyD2BZzV#%{XSNJWa zbJKWu-1VEY*)KgQ>0p%k^fK!661@!6Fz4yUeA>YK#hH=(y^|vi?)NEN2#}JJjmpqa z$RcP1URPSSl@st{Z&urn_7sSF|zz_sYWh=DEl?zlOZZ8z!*jZ?=hVehp<(wo=$5I znVb)bT3&RR6SC-HdGdeLY!~li`4&^E!&@!)UWjGH3at;F6gYwV$892&D|{mooqd9` zuk>q(dIR){M}%*0?u(W-1=_HZju$>M_-QWF#VKsku{7-Fuoj_7i9ST5dldPZ>%U-? zBtURhZJd|aJtuj7bT2kDG)8|5)Y;-a`A!9w)dHXFKeUKw8o;8F<9~->iUBPzPi335 zBvrF*yun#2m_l2L#eX??oSrRpj_N!@OR}RTtNjI(35qN7kGSEf2vQPm+vPJT^3J3XN%`L$KQ-3$^h$9ZeHRMTBO4 zBhEFpT|^L#-drMzc+zFW6YT8FgkKrfixJmVyjX~zs!mjS^YY5AvCfC8y2k#Un&X1X zP+DD!Jq;!fC3;eOu;X!Qi%OX+PPIVG+2yVSvz|t(y#q+^<#tDjFGrHZK78>sOfI&! z3nk~lv-XaTP{-%p*pK&*5}vqJ*OA0#O(??_$G$u(NK=bv((m#gcim|{U01g$(&O!| z3E(#}(urjyi2$7!2vIN>0CI8L+_oh`Yd>O=#_W1}CBWwZwc4J@_z*ttl-4%ud(#Gc zmdrvUwIU$$rU)Om6UIzYs43}*Z;$jZEkFo4Z_Ht`{oclRD&({C;7=c)Z+iBP9Y6c{ z2n8f2R1p1VBq*tGgwp_4UE8H@ku{h#f{sqQ|UBg z&Az%L1M053xj1ExlR60nQ;59e$5jzX^uJTpm4~&z-{5gJ(PTZ_6%_DxpwjwA6_oxv zMYU7POQ6yDRgeXwH~w&VwqLInrI+9&SX~H3&>70+o1I!#Lj>n$FSvUNZb3?F>ED!{ zEw}nW50!SMFdMi2qv8^K<>u&GeXwnQRkG#@W1<7y>KV0buXs36G^)2K{O(G%wg7te z7nHM2hzPq^!|rvSLD*7?u~AVcNH?96S^8yLwP_B?j@TGfalo21EI{D6@I9I;Pr$cX z5323#s;!HB`es*4xvhiMi7WkyV`+mgEN%_kJ2JwYws#WI33q;yS$*$YN2n_BD4tvo zAi{e1=Yuxt*(PXcU?s$dXhNE(ZtzLWCaB7&6-tCY!=_9OCpB#e$j*(nSKN{4h|Ia{ols@v9_8IpNArI|X= zxpr=qq;cQ#E$?AyrdyXx-t^CBww0ulUm5;FLO;pBkkF3M&Op$A(yUn=2u@=;)Qz4r zF8d>6Q2lV;`hrfT1Nx{OZHluc4`TtrEGQ3<0KHjPI*_cS5cLvR=^0!-2tcvIW0CM< zi?J>;F75Q!r{Md%6o9gr8Jl|`dwGg;?APR1YhV8L<+H@8^!nGY&RV=MblGa_Jhj

=Hc>Sh3wOHTL>yJwHs+cLesK?_J=QXX&bP?i@ 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

`rikAED+#IR?IKr6g_#NZj?iL~Ar_^NP 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 GJO2;l{w9V1 literal 0 HcmV?d00001 diff --git a/lib/src/voip/utils.dart b/lib/src/voip/utils.dart new file mode 100644 index 00000000..4eb6f223 --- /dev/null +++ b/lib/src/voip/utils.dart @@ -0,0 +1,32 @@ +import 'package:random_string/random_string.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; + +void stopMediaStream(MediaStream? stream) async { + stream?.getTracks().forEach((element) async { + await element.stop(); + }); +} + +void setTracksEnabled(List tracks, bool enabled) { + tracks.forEach((element) { + element.enabled = enabled; + }); +} + +Future hasAudioDevice() async { + //TODO(duan): implement this, check if there is any audio device + return true; +} + +Future 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); +} diff --git a/lib/src/voip/voip.dart b/lib/src/voip/voip.dart new file mode 100644 index 00000000..90eb6f47 --- /dev/null +++ b/lib/src/voip/voip.dart @@ -0,0 +1,690 @@ +import 'dart:async'; +import 'dart:core'; + +import 'package:webrtc_interface/webrtc_interface.dart'; +import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; + +import '../../matrix.dart'; + +/// Delegate WebRTC basic functionality. +abstract class WebRTCDelegate { + MediaDevices get mediaDevices; + Future createPeerConnection( + Map configuration, + [Map constraints = const {}]); + VideoRenderer createRenderer(); + void playRingtone(); + void stopRingtone(); + void handleNewCall(CallSession session); + void handleCallEnded(CallSession session); + + void handleNewGroupCall(GroupCall groupCall); + void handleGroupCallEnded(GroupCall groupCall); + + bool get isBackgroud; + bool get isWeb; +} + +class VoIP { + TurnServerCredentials? _turnServerCredentials; + Map calls = {}; + Map groupCalls = {}; + final StreamController onIncomingCall = + StreamController.broadcast(); + String? currentCID; + String? currentGroupCID; + String? get localPartyId => client.deviceID; + final Client client; + final WebRTCDelegate delegate; + + void _handleEvent( + Event event, + Function(String roomId, String senderId, Map content) + func) => + func(event.roomId!, event.senderId, event.content); + + VoIP(this.client, this.delegate) : super() { + client.onCallInvite.stream + .listen((event) => _handleEvent(event, onCallInvite)); + client.onCallAnswer.stream + .listen((event) => _handleEvent(event, onCallAnswer)); + client.onCallCandidates.stream + .listen((event) => _handleEvent(event, onCallCandidates)); + client.onCallHangup.stream + .listen((event) => _handleEvent(event, onCallHangup)); + client.onCallReject.stream + .listen((event) => _handleEvent(event, onCallReject)); + client.onCallNegotiate.stream + .listen((event) => _handleEvent(event, onCallNegotiate)); + client.onCallReplaces.stream + .listen((event) => _handleEvent(event, onCallReplaces)); + client.onCallSelectAnswer.stream + .listen((event) => _handleEvent(event, onCallSelectAnswer)); + client.onSDPStreamMetadataChangedReceived.stream.listen( + (event) => _handleEvent(event, onSDPStreamMetadataChangedReceived)); + client.onAssertedIdentityReceived.stream + .listen((event) => _handleEvent(event, onAssertedIdentityReceived)); + + client.onGroupCallRequest.stream.listen((event) { + Logs().v('[VOIP] onGroupCallRequest: type ${event.toJson()}.'); + onRoomStateChanged(event); + }); + + client.onToDeviceEvent.stream.listen((event) { + Logs().v('[VOIP] onToDeviceEvent: type ${event.toJson()}.'); + + if (event.type == 'org.matrix.call_duplicate_session') { + Logs().v('[VOIP] onToDeviceEvent: duplicate session.'); + return; + } + + final confId = event.content['conf_id']; + final groupCall = groupCalls[confId]; + if (groupCall == null) { + Logs().e('[VOIP] onToDeviceEvent: groupCall is null.'); + return; + } + final roomId = groupCall.room.id; + final senderId = event.senderId; + final content = event.content; + switch (event.type) { + case EventTypes.CallInvite: + onCallInvite(roomId, senderId, content); + break; + case EventTypes.CallAnswer: + onCallAnswer(roomId, senderId, content); + break; + case EventTypes.CallCandidates: + onCallCandidates(roomId, senderId, content); + break; + case EventTypes.CallHangup: + onCallHangup(roomId, senderId, content); + break; + case EventTypes.CallReject: + onCallReject(roomId, senderId, content); + break; + case EventTypes.CallNegotiate: + onCallNegotiate(roomId, senderId, content); + break; + case EventTypes.CallReplaces: + onCallReplaces(roomId, senderId, content); + break; + case EventTypes.CallSelectAnswer: + onCallSelectAnswer(roomId, senderId, content); + break; + case EventTypes.CallSDPStreamMetadataChanged: + case EventTypes.CallSDPStreamMetadataChangedPrefix: + onSDPStreamMetadataChangedReceived(roomId, senderId, content); + break; + case EventTypes.CallAssertedIdentity: + onAssertedIdentityReceived(roomId, senderId, content); + break; + } + }); + } + + Future onCallInvite( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + + Logs().v( + '[VOIP] onCallInvite $senderId => ${client.userID}, \ncontent => ${content.toString()}'); + + final String callId = content['call_id']; + final String partyId = content['party_id']; + final int lifetime = content['lifetime']; + final String? confId = content['conf_id']; + final String? deviceId = content['device_id']; + final call = calls[callId]; + + if (call != null && call.state == CallState.kEnded) { + // Session already exist. + Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.'); + return; + } + + if (content['invitee'] != null && content['invitee'] != client.userID) { + return; // This invite was meant for another user in the room + } + + if (content['capabilities'] != null) { + final capabilities = CallCapabilities.fromJson(content['capabilities']); + Logs().v( + '[VOIP] CallCapabilities: dtmf => ${capabilities.dtmf}, transferee => ${capabilities.transferee}'); + } + + var callType = CallType.kVoice; + SDPStreamMetadata? sdpStreamMetadata; + if (content[sdpStreamMetadataKey] != null) { + sdpStreamMetadata = + SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); + sdpStreamMetadata.sdpStreamMetadatas + .forEach((streamId, SDPStreamPurpose purpose) { + Logs().v( + '[VOIP] [$streamId] => purpose: ${purpose.purpose}, audioMuted: ${purpose.audio_muted}, videoMuted: ${purpose.video_muted}'); + + if (!purpose.video_muted) { + callType = CallType.kVideo; + } + }); + } else { + callType = getCallType(content['offer']['sdp']); + } + + final room = client.getRoomById(roomId); + + final opts = CallOptions() + ..voip = this + ..callId = callId + ..groupCallId = confId + ..dir = CallDirection.kIncoming + ..type = callType + ..room = room! + ..localPartyId = localPartyId! + ..iceServers = await getIceSevers(); + + final newCall = createNewCall(opts); + newCall.remotePartyId = partyId; + newCall.remoteUser = await room.requestUser(senderId); + newCall.opponentDeviceId = deviceId; + newCall.opponentSessionId = content['sender_session_id']; + + final offer = RTCSessionDescription( + content['offer']['sdp'], + content['offer']['type'], + ); + await newCall + .initWithInvite(callType, offer, sdpStreamMetadata, lifetime) + .then((_) { + // Popup CallingPage for incoming call. + if (!delegate.isBackgroud && confId == null) { + delegate.handleNewCall(newCall); + } + onIncomingCall.add(newCall); + }); + currentCID = callId; + + if (delegate.isBackgroud) { + /// Forced to enable signaling synchronization until the end of the call. + client.backgroundSync = true; + + ///TODO: notify the callkeep that the call is incoming. + } + // Play ringtone + delegate.playRingtone(); + } + + void onCallAnswer( + String roomId, String senderId, Map content) async { + Logs().v('[VOIP] onCallAnswer => ${content.toString()}'); + final String callId = content['call_id']; + final String partyId = content['party_id']; + + final call = calls[callId]; + if (call != null) { + if (senderId == client.userID) { + // Ignore messages to yourself. + if (!call.answeredByUs) { + delegate.stopRingtone(); + } + if (call.state == CallState.kRinging) { + call.onAnsweredElsewhere(); + } + return; + } + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call answer for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + call.remotePartyId = partyId; + call.remoteUser = await call.room.requestUser(senderId); + + final answer = RTCSessionDescription( + content['answer']['sdp'], content['answer']['type']); + + SDPStreamMetadata? metadata; + if (content[sdpStreamMetadataKey] != null) { + metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); + } + call.onAnswerReceived(answer, metadata); + } else { + Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!'); + } + } + + void onCallCandidates( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + Logs().v('[VOIP] onCallCandidates => ${content.toString()}'); + final String callId = content['call_id']; + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call candidates for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + call.onCandidatesReceived(content['candidates']); + } else { + Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!'); + } + } + + void onCallHangup(String roomId, String _ /*senderId unused*/, + Map content) async { + // stop play ringtone, if this is an incoming call + if (!delegate.isBackgroud) { + delegate.stopRingtone(); + } + Logs().v('[VOIP] onCallHangup => ${content.toString()}'); + final String callId = content['call_id']; + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call hangup for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + // hangup in any case, either if the other party hung up or we did on another device + call.terminate(CallParty.kRemote, + content['reason'] ?? CallErrorCode.UserHangup, true); + } else { + Logs().v('[VOIP] onCallHangup: Session [$callId] not found!'); + } + currentCID = null; + } + + void onCallReject( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('Reject received for call ID ' + callId); + + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call reject for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + call.onRejectReceived(content['reason']); + } else { + Logs().v('[VOIP] onCallHangup: Session [$callId] not found!'); + } + } + + void onCallReplaces( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('onCallReplaces received for call ID ' + callId); + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call replace for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + //TODO: handle replaces + } + } + + void onCallSelectAnswer( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('SelectAnswer received for call ID ' + callId); + final call = calls[callId]; + final String selectedPartyId = content['selected_party_id']; + + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call select answer for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + call.onSelectAnswerReceived(selectedPartyId); + } + } + + void onSDPStreamMetadataChangedReceived( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('SDP Stream metadata received for call ID ' + callId); + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call sdp metadata change for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + + if (content[sdpStreamMetadataKey] == null) { + Logs().d('SDP Stream metadata is null'); + return; + } + call.onSDPStreamMetadataReceived( + SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey])); + } + } + + void onAssertedIdentityReceived( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('Asserted identity received for call ID ' + callId); + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call asserted identity for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + + if (content['asserted_identity'] == null) { + Logs().d('asserted_identity is null '); + return; + } + call.onAssertedIdentityReceived( + AssertedIdentity.fromJson(content['asserted_identity'])); + } + } + + void onCallNegotiate( + String roomId, String senderId, Map content) async { + if (senderId == client.userID) { + // Ignore messages to yourself. + return; + } + final String callId = content['call_id']; + Logs().d('Negotiate received for call ID ' + callId); + final call = calls[callId]; + if (call != null) { + if (call.room.id != roomId) { + Logs().w( + 'Ignoring call negotiation for room $roomId claiming to be for call in room ${call.room.id}'); + return; + } + + final description = content['description']; + try { + SDPStreamMetadata? metadata; + if (content[sdpStreamMetadataKey] != null) { + metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); + } + call.onNegotiateReceived(metadata, + RTCSessionDescription(description['sdp'], description['type'])); + } catch (err) { + Logs().e('Failed to complete negotiation ${err.toString()}'); + } + } + } + + CallType getCallType(String sdp) { + try { + final session = sdp_transform.parse(sdp); + if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) { + return CallType.kVideo; + } + } catch (err) { + Logs().e('Failed to getCallType ${err.toString()}'); + } + + return CallType.kVoice; + } + + Future requestTurnServerCredentials() async { + return true; + } + + Future>> getIceSevers() async { + if (_turnServerCredentials == null) { + try { + _turnServerCredentials = await client.getTurnServer(); + } catch (e) { + Logs().v('[VOIP] getTurnServerCredentials error => ${e.toString()}'); + } + } + + if (_turnServerCredentials == null) { + return []; + } + + return [ + { + 'username': _turnServerCredentials!.username, + 'credential': _turnServerCredentials!.password, + 'url': _turnServerCredentials!.uris[0] + } + ]; + } + + /// Make a P2P call to room + /// + /// [roomId] The room id to call + /// + /// [type] The type of call to be made. + Future inviteToCall(String roomId, CallType type) async { + final room = client.getRoomById(roomId); + if (room == null) { + Logs().v('[VOIP] Invalid room id [$roomId].'); + return Null as CallSession; + } + final callId = 'cid${DateTime.now().millisecondsSinceEpoch}'; + final opts = CallOptions() + ..callId = callId + ..type = type + ..dir = CallDirection.kOutgoing + ..room = room + ..voip = this + ..localPartyId = localPartyId! + ..iceServers = await getIceSevers(); + + final newCall = createNewCall(opts); + currentCID = callId; + await newCall.initOutboundCall(type).then((_) { + if (!delegate.isBackgroud) { + delegate.handleNewCall(newCall); + } + }); + currentCID = callId; + return newCall; + } + + CallSession createNewCall(CallOptions opts) { + final call = CallSession(opts); + calls[opts.callId] = call; + return call; + } + + /// Create a new group call in an existing room. + /// + /// [roomId] The room id to call + /// + /// [type] The type of call to be made. + /// + /// [intent] The intent of the call. + /// + /// [dataChannelsEnabled] Whether data channels are enabled. + /// + /// [dataChannelOptions] The data channel options. + Future newGroupCall(String roomId, String type, String intent, + [bool? dataChannelsEnabled, + RTCDataChannelInit? dataChannelOptions]) async { + final room = client.getRoomById(roomId); + if (room == null) { + Logs().v('[VOIP] Invalid room id [$roomId].'); + return null; + } + final groupId = genCallID(); + final groupCall = GroupCall( + groupCallId: groupId, + client: client, + voip: this, + room: room, + type: type, + intent: intent, + dataChannelsEnabled: dataChannelsEnabled ?? false, + dataChannelOptions: dataChannelOptions ?? RTCDataChannelInit(), + ).create(); + groupCalls[groupId] = groupCall; + return groupCall; + } + + GroupCall? getGroupCallForRoom(String roomId) { + return groupCalls[roomId]; + } + + GroupCall? getGroupCallById(String groupCallId) { + return groupCalls[groupCallId]; + } + + void startGroupCalls() async { + final rooms = client.rooms; + rooms.forEach((element) { + createGroupCallForRoom(element); + }); + } + + void stopGroupCalls() { + groupCalls.forEach((_, groupCall) { + groupCall.terminate(); + }); + groupCalls.clear(); + } + + /// Create a new group call in an existing room. + Future createGroupCallForRoom(Room room) async { + final events = await client.getRoomState(room.id); + events.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); + events.forEach((element) async { + if (element.type == EventTypes.GroupCallPrefix) { + if (element.content['m.terminated'] != null) { + return; + } + await createGroupCallFromRoomStateEvent(element); + } + }); + return; + } + + /// Create a new group call from a room state event. + Future createGroupCallFromRoomStateEvent( + MatrixEvent event) async { + final roomId = event.roomId; + final content = event.content; + + final room = client.getRoomById(roomId!); + + if (room == null) { + Logs().w('Couldn\'t find room $roomId for GroupCall'); + return null; + } + + final groupCallId = event.stateKey; + + final callType = content['m.type']; + + if (callType != GroupCallType.Video && callType != GroupCallType.Voice) { + Logs().w('Received invalid group call type $callType for room $roomId.'); + return null; + } + + final callIntent = content['m.intent']; + + if (callIntent != GroupCallIntent.Prompt && + callIntent != GroupCallIntent.Room && + callIntent != GroupCallIntent.Ring) { + Logs() + .w('Received invalid group call intent $callType for room $roomId.'); + return null; + } + + final dataChannelOptionsMap = content['m.data_channel_options']; + + var dataChannelsEnabled = false; + final dataChannelOptions = RTCDataChannelInit(); + + if (dataChannelOptionsMap != null) { + dataChannelsEnabled = + dataChannelOptionsMap['dataChannelsEnabled'] as bool; + dataChannelOptions.ordered = dataChannelOptionsMap['ordered'] as bool; + dataChannelOptions.maxRetransmits = + dataChannelOptionsMap['maxRetransmits'] as int; + dataChannelOptions.maxRetransmits = + dataChannelOptionsMap['maxRetransmits'] as int; + dataChannelOptions.protocol = dataChannelOptionsMap['protocol'] as String; + } + + final groupCall = GroupCall( + client: client, + voip: this, + room: room, + groupCallId: groupCallId, + type: callType, + intent: callIntent, + dataChannelsEnabled: dataChannelsEnabled, + dataChannelOptions: dataChannelOptions); + + groupCalls[groupCallId!] = groupCall; + groupCalls[room.id] = groupCall; + delegate.handleNewGroupCall(groupCall); + return groupCall; + } + + void onRoomStateChanged(MatrixEvent event) { + final eventType = event.type; + final roomId = event.roomId; + if (eventType == EventTypes.GroupCallPrefix) { + final groupCallId = event.content['groupCallId']; + final content = event.content; + final currentGroupCall = groupCalls[groupCallId]; + if (currentGroupCall == null && content['m.terminated'] == null) { + createGroupCallFromRoomStateEvent(event); + } else if (currentGroupCall != null && + currentGroupCall.groupCallId == groupCallId) { + if (content['m.terminated'] != null) { + currentGroupCall.terminate(emitStateEvent: false); + } else if (content['m.type'] != currentGroupCall.type) { + // TODO: Handle the callType changing when the room state changes + Logs().w( + 'The group call type changed for room: $roomId. Changing the group call type is currently unsupported.'); + } + } else if (currentGroupCall != null && + currentGroupCall.groupCallId != groupCallId) { + // TODO: Handle new group calls and multiple group calls + Logs().w( + 'Multiple group calls detected for room: $roomId. Multiple group calls are currently unsupported.'); + } + } else if (eventType == EventTypes.GroupCallMemberPrefix) { + final groupCall = groupCalls[roomId]; + if (groupCall == null) { + return; + } + groupCall.onMemberStateChanged(event); + } + } +} diff --git a/lib/src/voip_content.dart b/lib/src/voip/voip_content.dart similarity index 100% rename from lib/src/voip_content.dart rename to lib/src/voip/voip_content.dart diff --git a/test/fake_voip_delegate.dart b/test/fake_voip_delegate.dart deleted file mode 100644 index 4e0ade42..00000000 --- a/test/fake_voip_delegate.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:matrix/src/voip.dart'; -import 'package:webrtc_interface/src/rtc_video_renderer.dart'; -import 'package:webrtc_interface/src/rtc_peerconnection.dart'; -import 'package:webrtc_interface/src/mediadevices.dart'; - -class FakeVoIPDelegate extends WebRTCDelegate { - @override - Future createPeerConnection( - Map configuration, - [Map constraints = const {}]) { - // TODO: implement createPeerConnection - throw UnimplementedError(); - } - - @override - VideoRenderer createRenderer() { - // TODO: implement createRenderer - throw UnimplementedError(); - } - - @override - void handleCallEnded(CallSession session) { - // TODO: implement handleCallEnded - } - - @override - void handleNewCall(CallSession session) { - // TODO: implement handleNewCall - } - - @override - // TODO: implement isBackgroud - bool get isBackgroud => throw UnimplementedError(); - - @override - // TODO: implement isWeb - bool get isWeb => throw UnimplementedError(); - - @override - // TODO: implement mediaDevices - MediaDevices get mediaDevices => throw UnimplementedError(); - - @override - void playRingtone() { - // TODO: implement playRingtone - } - - @override - void stopRingtone() { - // TODO: implement stopRingtone - } -} diff --git a/test/room_test.dart b/test/room_test.dart index e475566c..63d1f47e 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -25,19 +25,16 @@ import 'package:test/test.dart'; import 'fake_client.dart'; import 'fake_matrix_api.dart'; -import 'fake_voip_delegate.dart'; void main() { late Client matrix; late Room room; - late VoIP voip; /// All Tests related to the Event group('Room', () { Logs().level = Level.error; test('Login', () async { matrix = await getClient(); - voip = VoIP(matrix, FakeVoIPDelegate()); }); test('Create from json', () async { @@ -761,18 +758,19 @@ void main() { }); test('Test call methods', () async { - await voip.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp', + final call = CallSession(CallOptions()..room = room); + await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp', txid: '1234'); - await voip.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); - await voip.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); - await voip.sendSelectCallAnswer(room, '1234', 1234, '4567', '6789', + await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); + await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); + await call.sendSelectCallAnswer(room, '1234', 1234, '4567', '6789', txid: '1234'); - await voip.sendCallReject(room, '1234', 1234, '4567', txid: '1234'); - await voip.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', + await call.sendCallReject(room, '1234', 1234, '4567', txid: '1234'); + await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', txid: '1234'); - await voip.sendHangupCall(room, '1234', '4567', 'user_hangup', + await call.sendHangupCall(room, '1234', '4567', 'user_hangup', txid: '1234'); - await voip.sendAssertedIdentity( + await call.sendAssertedIdentity( room, '1234', '4567', @@ -780,9 +778,9 @@ void main() { ..displayName = 'name' ..id = 'some_id', txid: '1234'); - await voip.sendCallReplaces(room, '1234', '4567', CallReplaces(), + await call.sendCallReplaces(room, '1234', '4567', CallReplaces(), txid: '1234'); - await voip.sendSDPStreamMetadataChanged( + await call.sendSDPStreamMetadataChanged( room, '1234', '4567', SDPStreamMetadata({}), txid: '1234'); });