diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 84a7462259..7a4fecb256 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -63,6 +63,13 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.call.CallParticipant import com.nextcloud.talk.call.CallParticipantList import com.nextcloud.talk.call.CallParticipantModel +import com.nextcloud.talk.call.LocalStateBroadcaster +import com.nextcloud.talk.call.LocalStateBroadcasterMcu +import com.nextcloud.talk.call.LocalStateBroadcasterNoMcu +import com.nextcloud.talk.call.MessageSender +import com.nextcloud.talk.call.MessageSenderMcu +import com.nextcloud.talk.call.MessageSenderNoMcu +import com.nextcloud.talk.call.MutableLocalCallParticipantModel import com.nextcloud.talk.call.ReactionAnimator import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User @@ -242,6 +249,9 @@ class CallActivity : CallBaseActivity() { private var signalingMessageReceiver: SignalingMessageReceiver? = null private val internalSignalingMessageSender = InternalSignalingMessageSender() private var signalingMessageSender: SignalingMessageSender? = null + private var messageSender: MessageSender? = null + private val localCallParticipantModel: MutableLocalCallParticipantModel = MutableLocalCallParticipantModel() + private var localStateBroadcaster: LocalStateBroadcaster? = null private val offerAnswerNickProviders: MutableMap = HashMap() private val callParticipantMessageListeners: MutableMap = HashMap() private val selfPeerConnectionObserver: PeerConnectionObserver = CallActivitySelfPeerConnectionObserver() @@ -1119,6 +1129,7 @@ class CallActivity : CallBaseActivity() { localStream!!.addTrack(localVideoTrack) localVideoTrack!!.setEnabled(false) localVideoTrack!!.addSink(binding!!.selfVideoRenderer) + localCallParticipantModel.isVideoEnabled = false } private fun microphoneInitialization() { @@ -1129,12 +1140,12 @@ class CallActivity : CallBaseActivity() { localAudioTrack = peerConnectionFactory!!.createAudioTrack("NCa0", audioSource) localAudioTrack!!.setEnabled(false) localStream!!.addTrack(localAudioTrack) + localCallParticipantModel.isAudioEnabled = false } @SuppressLint("MissingPermission") private fun startMicInputDetection() { if (permissionUtil!!.isMicrophonePermissionGranted() && micInputAudioRecordThread == null) { - var isSpeakingLongTerm = false micInputAudioRecorder = AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, @@ -1151,13 +1162,8 @@ class CallActivity : CallBaseActivity() { micInputAudioRecorder.read(byteArr, 0, byteArr.size) val isCurrentlySpeaking = abs(byteArr[0].toDouble()) > MICROPHONE_VALUE_THRESHOLD - if (microphoneOn && isCurrentlySpeaking && !isSpeakingLongTerm) { - isSpeakingLongTerm = true - sendIsSpeakingMessage(true) - } else if (!isCurrentlySpeaking && isSpeakingLongTerm) { - isSpeakingLongTerm = false - sendIsSpeakingMessage(false) - } + localCallParticipantModel.isSpeaking = isCurrentlySpeaking + Thread.sleep(MICROPHONE_VALUE_SLEEP) } } @@ -1166,27 +1172,6 @@ class CallActivity : CallBaseActivity() { } } - @Suppress("Detekt.NestedBlockDepth") - private fun sendIsSpeakingMessage(isSpeaking: Boolean) { - val isSpeakingMessage: String = - if (isSpeaking) SIGNALING_MESSAGE_SPEAKING_STARTED else SIGNALING_MESSAGE_SPEAKING_STOPPED - - if (isConnectionEstablished && othersInCall) { - if (!hasMCU) { - for (peerConnectionWrapper in peerConnectionWrapperList) { - peerConnectionWrapper.send(DataChannelMessage(isSpeakingMessage)) - } - } else { - for (peerConnectionWrapper in peerConnectionWrapperList) { - if (peerConnectionWrapper.sessionId == webSocketClient!!.sessionId) { - peerConnectionWrapper.send(DataChannelMessage(isSpeakingMessage)) - break - } - } - } - } - } - private fun createCameraCapturer(enumerator: CameraEnumerator?): VideoCapturer? { val deviceNames = enumerator!!.deviceNames @@ -1330,12 +1315,9 @@ class CallActivity : CallBaseActivity() { } private fun toggleMedia(enable: Boolean, video: Boolean) { - var message: String if (video) { - message = SIGNALING_MESSAGE_VIDEO_OFF if (enable) { binding!!.cameraButton.alpha = OPACITY_ENABLED - message = SIGNALING_MESSAGE_VIDEO_ON startVideoCapture() } else { binding!!.cameraButton.alpha = OPACITY_DISABLED @@ -1349,6 +1331,7 @@ class CallActivity : CallBaseActivity() { } if (localStream != null && localStream!!.videoTracks.size > 0) { localStream!!.videoTracks[0].setEnabled(enable) + localCallParticipantModel.isVideoEnabled = enable } if (enable) { binding!!.selfVideoRenderer.visibility = View.VISIBLE @@ -1356,29 +1339,14 @@ class CallActivity : CallBaseActivity() { binding!!.selfVideoRenderer.visibility = View.INVISIBLE } } else { - message = SIGNALING_MESSAGE_AUDIO_OFF if (enable) { - message = SIGNALING_MESSAGE_AUDIO_ON binding!!.microphoneButton.alpha = OPACITY_ENABLED } else { binding!!.microphoneButton.alpha = OPACITY_DISABLED } if (localStream != null && localStream!!.audioTracks.size > 0) { localStream!!.audioTracks[0].setEnabled(enable) - } - } - if (isConnectionEstablished) { - if (!hasMCU) { - for (peerConnectionWrapper in peerConnectionWrapperList) { - peerConnectionWrapper.send(DataChannelMessage(message)) - } - } else { - for (peerConnectionWrapper in peerConnectionWrapperList) { - if (peerConnectionWrapper.sessionId == webSocketClient!!.sessionId) { - peerConnectionWrapper.send(DataChannelMessage(message)) - break - } - } + localCallParticipantModel.isAudioEnabled = enable } } } @@ -1618,6 +1586,15 @@ class CallActivity : CallBaseActivity() { signalingMessageReceiver!!.addListener(localParticipantMessageListener) signalingMessageReceiver!!.addListener(offerMessageListener) signalingMessageSender = internalSignalingMessageSender + + hasMCU = false + + messageSender = MessageSenderNoMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrapperList + ) + joinRoomAndCall() } } @@ -1755,6 +1732,15 @@ class CallActivity : CallBaseActivity() { callParticipantList = CallParticipantList(signalingMessageReceiver) callParticipantList!!.addObserver(callParticipantListObserver) + if (hasMCU) { + localStateBroadcaster = LocalStateBroadcasterMcu(localCallParticipantModel, messageSender) + } else { + localStateBroadcaster = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + messageSender as MessageSenderNoMcu + ) + } + val apiVersion = ApiUtils.getCallApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) ncApi!!.joinCall( credentials, @@ -1903,6 +1889,26 @@ class CallActivity : CallBaseActivity() { signalingMessageReceiver!!.addListener(localParticipantMessageListener) signalingMessageReceiver!!.addListener(offerMessageListener) signalingMessageSender = webSocketClient!!.signalingMessageSender + + // If the connection with the signaling server was not established yet the value will be false, but it will + // be overwritten with the right value once the response to the "hello" message is received. + hasMCU = webSocketClient!!.hasMCU() + Log.d(TAG, "hasMCU is $hasMCU") + + if (hasMCU) { + messageSender = MessageSenderMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrapperList, + webSocketClient!!.sessionId + ) + } else { + messageSender = MessageSenderNoMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrapperList + ) + } } else { if (webSocketClient!!.isConnected && currentCallStatus === CallStatus.PUBLISHER_FAILED) { webSocketClient!!.restartWebSocket() @@ -1928,6 +1934,25 @@ class CallActivity : CallBaseActivity() { when (webSocketCommunicationEvent.getType()) { "hello" -> { Log.d(TAG, "onMessageEvent 'hello'") + + hasMCU = webSocketClient!!.hasMCU() + Log.d(TAG, "hasMCU is $hasMCU") + + if (hasMCU) { + messageSender = MessageSenderMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrapperList, + webSocketClient!!.sessionId + ) + } else { + messageSender = MessageSenderNoMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrapperList + ) + } + if (!webSocketCommunicationEvent.getHashMap()!!.containsKey("oldResumeId")) { if (currentCallStatus === CallStatus.RECONNECTING) { hangup(false, false) @@ -2076,6 +2101,9 @@ class CallActivity : CallBaseActivity() { private fun hangupNetworkCalls(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangupNetworkCalls. shutDownView=$shutDownView") val apiVersion = ApiUtils.getCallApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) + if (localStateBroadcaster != null) { + localStateBroadcaster!!.destroy() + } if (callParticipantList != null) { callParticipantList!!.removeObserver(callParticipantListObserver) callParticipantList!!.destroy() @@ -2136,8 +2164,6 @@ class CallActivity : CallBaseActivity() { unchanged: Collection ) { Log.d(TAG, "handleCallParticipantsChanged") - hasMCU = hasExternalSignalingServer && webSocketClient != null && webSocketClient!!.hasMCU() - Log.d(TAG, " hasMCU is $hasMCU") // The signaling session is the same as the Nextcloud session only when the MCU is not used. var currentSessionId = callSession @@ -2422,6 +2448,9 @@ class CallActivity : CallBaseActivity() { callParticipantEventDisplayers[sessionId] = callParticipantEventDisplayer callParticipantModel.addObserver(callParticipantEventDisplayer, callParticipantEventDisplayersHandler) runOnUiThread { addParticipantDisplayItem(callParticipantModel, "video") } + + localStateBroadcaster!!.handleCallParticipantAdded(callParticipant.callParticipantModel) + return callParticipant } @@ -2447,6 +2476,9 @@ class CallActivity : CallBaseActivity() { private fun removeCallParticipant(sessionId: String?) { val callParticipant = callParticipants.remove(sessionId) ?: return + + localStateBroadcaster!!.handleCallParticipantRemoved(callParticipant.callParticipantModel) + val screenParticipantDisplayItemManager = screenParticipantDisplayItemManagers.remove(sessionId) callParticipant.callParticipantModel.removeObserver(screenParticipantDisplayItemManager) val callParticipantEventDisplayer = callParticipantEventDisplayers.remove(sessionId) @@ -3264,12 +3296,5 @@ class CallActivity : CallBaseActivity() { private const val Y_POS_NO_CALL_INFO: Float = 20f private const val SESSION_ID_PREFFIX_END: Int = 4 - - private const val SIGNALING_MESSAGE_SPEAKING_STARTED = "speaking" - private const val SIGNALING_MESSAGE_SPEAKING_STOPPED = "stoppedSpeaking" - private const val SIGNALING_MESSAGE_VIDEO_ON = "videoOn" - private const val SIGNALING_MESSAGE_VIDEO_OFF = "videoOff" - private const val SIGNALING_MESSAGE_AUDIO_ON = "audioOn" - private const val SIGNALING_MESSAGE_AUDIO_OFF = "audioOff" } } diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java new file mode 100644 index 0000000000..b1dcececc4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java @@ -0,0 +1,114 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import android.os.Handler; + +import java.util.Objects; + +/** + * Read-only data model for local call participants. + *

+ * Clients of the model can observe it with LocalCallParticipantModel.Observer to be notified when any value changes. + * Getters called after receiving a notification are guaranteed to provide at least the value that triggered the + * notification, but it may return even a more up to date one (so getting the value again on the following notification + * may return the same value as before). + */ +public class LocalCallParticipantModel { + + protected final LocalCallParticipantModelNotifier localCallParticipantModelNotifier = + new LocalCallParticipantModelNotifier(); + + protected Data audioEnabled; + protected Data speaking; + protected Data speakingWhileMuted; + protected Data videoEnabled; + + public interface Observer { + void onChange(); + } + + protected class Data { + + private T value; + + public Data() { + } + + public Data(T value) { + this.value = value; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + if (Objects.equals(this.value, value)) { + return; + } + + this.value = value; + + localCallParticipantModelNotifier.notifyChange(); + } + } + + public LocalCallParticipantModel() { + this.audioEnabled = new Data<>(Boolean.FALSE); + this.speaking = new Data<>(Boolean.FALSE); + this.speakingWhileMuted = new Data<>(Boolean.FALSE); + this.videoEnabled = new Data<>(Boolean.FALSE); + } + + public Boolean isAudioEnabled() { + return audioEnabled.getValue(); + } + + public Boolean isSpeaking() { + return speaking.getValue(); + } + + public Boolean isSpeakingWhileMuted() { + return speakingWhileMuted.getValue(); + } + + public Boolean isVideoEnabled() { + return videoEnabled.getValue(); + } + + /** + * Adds an Observer to be notified when any value changes. + * + * @param observer the Observer + * @see LocalCallParticipantModel#addObserver(Observer, Handler) + */ + public void addObserver(Observer observer) { + addObserver(observer, null); + } + + /** + * Adds an observer to be notified when any value changes. + *

+ * The observer will be notified on the thread associated to the given handler. If no handler is given the + * observer will be immediately notified on the same thread that changed the value; the observer will be + * immediately notified too if the thread of the handler is the same thread that changed the value. + *

+ * An observer is expected to be added only once. If the same observer is added again it will be notified just + * once on the thread of the last handler. + * + * @param observer the Observer + * @param handler a Handler for the thread to be notified on + */ + public void addObserver(Observer observer, Handler handler) { + localCallParticipantModelNotifier.addObserver(observer, handler); + } + + public void removeObserver(Observer observer) { + localCallParticipantModelNotifier.removeObserver(observer); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java new file mode 100644 index 0000000000..b46f1f0a7e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java @@ -0,0 +1,73 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Helper class to register and notify LocalCallParticipantModel.Observers. + *

+ * This class is only meant for internal use by LocalCallParticipantModel; observers must register themselves against a + * LocalCallParticipantModel rather than against a LocalCallParticipantModelNotifier. + */ +class LocalCallParticipantModelNotifier { + + private final List localCallParticipantModelObserversOn = new ArrayList<>(); + + /** + * Helper class to associate a LocalCallParticipantModel.Observer with a Handler. + */ + private static class LocalCallParticipantModelObserverOn { + public final LocalCallParticipantModel.Observer observer; + public final Handler handler; + + private LocalCallParticipantModelObserverOn(LocalCallParticipantModel.Observer observer, Handler handler) { + this.observer = observer; + this.handler = handler; + } + } + + public synchronized void addObserver(LocalCallParticipantModel.Observer observer, Handler handler) { + if (observer == null) { + throw new IllegalArgumentException("LocalCallParticipantModel.Observer can not be null"); + } + + removeObserver(observer); + + localCallParticipantModelObserversOn.add(new LocalCallParticipantModelObserverOn(observer, handler)); + } + + public synchronized void removeObserver(LocalCallParticipantModel.Observer observer) { + Iterator it = localCallParticipantModelObserversOn.iterator(); + while (it.hasNext()) { + LocalCallParticipantModelObserverOn observerOn = it.next(); + + if (observerOn.observer == observer) { + it.remove(); + + return; + } + } + } + + public synchronized void notifyChange() { + for (LocalCallParticipantModelObserverOn observerOn : new ArrayList<>(localCallParticipantModelObserversOn)) { + if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) { + observerOn.observer.onChange(); + } else { + observerOn.handler.post(() -> { + observerOn.observer.onChange(); + }); + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java new file mode 100644 index 0000000000..1022d39e12 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java @@ -0,0 +1,170 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage; +import com.nextcloud.talk.models.json.signaling.NCMessagePayload; +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; + +import java.util.Objects; + +/** + * Helper class to send the local participant state to the other participants in the call. + *

+ * Once created, and until destroyed, the LocalStateBroadcaster will send the changes in the local participant state to + * all the participants in the call. Note that the LocalStateBroadcaster does not check whether the local participant + * is actually in the call or not; it is expected that the LocalStateBroadcaster will be created and destroyed when the + * local participant joins and leaves the call. + *

+ * The LocalStateBroadcaster also sends the current state to remote participants when they join (which implicitly + * sends it to all remote participants when the local participant joins the call) so they can set an initial state + * for the local participant. + */ +public abstract class LocalStateBroadcaster { + + private final LocalCallParticipantModel localCallParticipantModel; + + private final LocalCallParticipantModelObserver localCallParticipantModelObserver; + + private final MessageSender messageSender; + + private class LocalCallParticipantModelObserver implements LocalCallParticipantModel.Observer { + + private Boolean audioEnabled; + private Boolean speaking; + private Boolean videoEnabled; + + public LocalCallParticipantModelObserver(LocalCallParticipantModel localCallParticipantModel) { + audioEnabled = localCallParticipantModel.isAudioEnabled(); + speaking = localCallParticipantModel.isSpeaking(); + videoEnabled = localCallParticipantModel.isVideoEnabled(); + } + + @Override + public void onChange() { + if (!Objects.equals(audioEnabled, localCallParticipantModel.isAudioEnabled())) { + audioEnabled = localCallParticipantModel.isAudioEnabled(); + + messageSender.sendToAll(getDataChannelMessageForAudioState()); + messageSender.sendToAll(getSignalingMessageForAudioState()); + } + + if (!Objects.equals(speaking, localCallParticipantModel.isSpeaking())) { + speaking = localCallParticipantModel.isSpeaking(); + + messageSender.sendToAll(getDataChannelMessageForSpeakingState()); + } + + if (!Objects.equals(videoEnabled, localCallParticipantModel.isVideoEnabled())) { + videoEnabled = localCallParticipantModel.isVideoEnabled(); + + messageSender.sendToAll(getDataChannelMessageForVideoState()); + messageSender.sendToAll(getSignalingMessageForVideoState()); + } + } + } + + public LocalStateBroadcaster(LocalCallParticipantModel localCallParticipantModel, + MessageSender messageSender) { + this.localCallParticipantModel = localCallParticipantModel; + this.localCallParticipantModelObserver = new LocalCallParticipantModelObserver(localCallParticipantModel); + this.messageSender = messageSender; + + this.localCallParticipantModel.addObserver(localCallParticipantModelObserver); + } + + public void destroy() { + this.localCallParticipantModel.removeObserver(localCallParticipantModelObserver); + } + + public abstract void handleCallParticipantAdded(CallParticipantModel callParticipantModel); + public abstract void handleCallParticipantRemoved(CallParticipantModel callParticipantModel); + + protected DataChannelMessage getDataChannelMessageForAudioState() { + String type = "audioOff"; + if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) { + type = "audioOn"; + } + + return new DataChannelMessage(type); + } + + protected DataChannelMessage getDataChannelMessageForSpeakingState() { + String type = "stoppedSpeaking"; + if (localCallParticipantModel.isSpeaking() != null && localCallParticipantModel.isSpeaking()) { + type = "speaking"; + } + + return new DataChannelMessage(type); + } + + protected DataChannelMessage getDataChannelMessageForVideoState() { + String type = "videoOff"; + if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) { + type = "videoOn"; + } + + return new DataChannelMessage(type); + } + + /** + * Returns a signaling message with the common fields set (type and room type). + * + * @param type the type of the signaling message + * @return the signaling message + */ + private NCSignalingMessage createBaseSignalingMessage(String type) { + NCSignalingMessage ncSignalingMessage = new NCSignalingMessage(); + // "roomType" is not really relevant without a peer or when referring to the whole participant, but it is + // nevertheless expected in the message. As most of the signaling messages currently sent to all participants + // are related to audio/video state "video" is used as the room type. + ncSignalingMessage.setRoomType("video"); + ncSignalingMessage.setType(type); + + return ncSignalingMessage; + } + + /** + * Returns a signaling message to notify current audio state. + * + * @return the signaling message + */ + protected NCSignalingMessage getSignalingMessageForAudioState() { + String type = "mute"; + if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) { + type = "unmute"; + } + + NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type); + + NCMessagePayload ncMessagePayload = new NCMessagePayload(); + ncMessagePayload.setName("audio"); + ncSignalingMessage.setPayload(ncMessagePayload); + + return ncSignalingMessage; + } + + /** + * Returns a signaling message to notify current video state. + * + * @return the signaling message + */ + protected NCSignalingMessage getSignalingMessageForVideoState() { + String type = "mute"; + if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) { + type = "unmute"; + } + + NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type); + + NCMessagePayload ncMessagePayload = new NCMessagePayload(); + ncMessagePayload.setName("video"); + ncSignalingMessage.setPayload(ncMessagePayload); + + return ncSignalingMessage; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterMcu.java b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterMcu.java new file mode 100644 index 0000000000..911bf1bf39 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterMcu.java @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Observable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +/** + * Helper class to send the local participant state to the other participants in the call when an MCU is used. + *

+ * Sending the state when it changes is handled by the base class; this subclass only handles sending the initial + * state when a remote participant is added. + *

+ * When Janus is used data channel messages are sent to all remote participants (with a peer connection to receive from + * the local participant). Moreover, it is not possible to know when the remote participants open the data channel to + * receive the messages, or even when they establish the receiver connection; it is only possible to know when the + * data channel is open for the publisher connection of the local participant. Due to all that the state is sent + * several times with an increasing delay whenever a participant joins the call (which implicitly broadcasts the + * initial state when the local participant joins the call, as all the remote participants joined from the point of + * view of the local participant). If the state was already being sent the sending is restarted with each new + * participant that joins. + *

+ * Similarly, in the case of signaling messages it is not possible either to know when the remote participants have + * "seen" the local participant and thus are ready to handle signaling messages about the state. However, in the case + * of signaling messages it is possible to send them to a specific participant, so the initial state is sent several + * times with an increasing delay directly to the participant that was added. Moreover, if the participant is removed + * the state is no longer directly sent. + *

+ * In any case, note that the state is sent only when the remote participant joins the call. Even in case of + * temporary disconnections the normal state updates sent when the state changes are expected to be received by the + * other participant, as signaling messages are sent through a WebSocket and are therefore reliable. Moreover, even + * if the WebSocket is restarted and the connection resumed (rather than joining with a new session ID) the messages + * would be also received, as in that case they would be queued until the WebSocket is connected again. + *

+ * Data channel messages, on the other hand, could be lost if the remote participant restarts the peer receiver + * connection (although they would be received in case of temporary disconnections, as data channels use a reliable + * transport by default). Therefore, as the speaking state is sent only through data channels, updates of the speaking + * state could be not received by remote participants. + */ +public class LocalStateBroadcasterMcu extends LocalStateBroadcaster { + + private final MessageSender messageSender; + + private final Map sendStateWithRepetitionByParticipant = new HashMap<>(); + + private Disposable sendStateWithRepetition; + + public LocalStateBroadcasterMcu(LocalCallParticipantModel localCallParticipantModel, + MessageSender messageSender) { + super(localCallParticipantModel, messageSender); + + this.messageSender = messageSender; + } + + public void destroy() { + super.destroy(); + + if (sendStateWithRepetition != null) { + sendStateWithRepetition.dispose(); + } + + for (Disposable sendStateWithRepetitionForParticipant: sendStateWithRepetitionByParticipant.values()) { + sendStateWithRepetitionForParticipant.dispose(); + } + } + + @Override + public void handleCallParticipantAdded(CallParticipantModel callParticipantModel) { + if (sendStateWithRepetition != null) { + sendStateWithRepetition.dispose(); + } + + sendStateWithRepetition = Observable + .fromArray(new Integer[]{0, 1, 2, 4, 8, 16}) + .concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io())) + .subscribe(value -> sendState()); + + String sessionId = callParticipantModel.getSessionId(); + Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(sessionId); + if (sendStateWithRepetitionForParticipant != null) { + sendStateWithRepetitionForParticipant.dispose(); + } + + sendStateWithRepetitionByParticipant.put(sessionId, Observable + .fromArray(new Integer[]{0, 1, 2, 4, 8, 16}) + .concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io())) + .subscribe(value -> sendState(sessionId))); + } + + @Override + public void handleCallParticipantRemoved(CallParticipantModel callParticipantModel) { + String sessionId = callParticipantModel.getSessionId(); + Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(sessionId); + if (sendStateWithRepetitionForParticipant != null) { + sendStateWithRepetitionForParticipant.dispose(); + } + } + + private void sendState() { + messageSender.sendToAll(getDataChannelMessageForAudioState()); + messageSender.sendToAll(getDataChannelMessageForSpeakingState()); + messageSender.sendToAll(getDataChannelMessageForVideoState()); + } + + private void sendState(String sessionId) { + messageSender.send(getSignalingMessageForAudioState(), sessionId); + messageSender.send(getSignalingMessageForVideoState(), sessionId); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcu.java b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcu.java new file mode 100644 index 0000000000..1377e626b4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcu.java @@ -0,0 +1,128 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import org.webrtc.PeerConnection; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Helper class to send the local participant state to the other participants in the call when an MCU is not used. + *

+ * Sending the state when it changes is handled by the base class; this subclass only handles sending the initial + * state when a remote participant is added. + *

+ * The state is sent when a connection with another participant is first established (which implicitly broadcasts the + * initial state when the local participant joins the call, as a connection is established with all the remote + * participants). Note that, as long as that participant stays in the call, the initial state is not sent again, even + * after a temporary disconnection; data channels use a reliable transport by default, so even if the state changes + * while the connection is temporarily interrupted the normal state update messages should be received by the other + * participant once the connection is restored. + *

+ * Nevertheless, in case of a failed connection and an ICE restart it is unclear whether the data channel messages + * would be received or not (as the data channel transport may be the one that failed and needs to be restarted). + * However, the state (except the speaking state) is also sent through signaling messages, which need to be + * explicitly fetched from the internal signaling server, so even in case of a failed connection they will be + * eventually received once the remote participant connects again. + */ +public class LocalStateBroadcasterNoMcu extends LocalStateBroadcaster { + + private final MessageSenderNoMcu messageSender; + + private final Map iceConnectionStateObservers = new HashMap<>(); + + private class IceConnectionStateObserver implements CallParticipantModel.Observer { + + private final CallParticipantModel callParticipantModel; + + private PeerConnection.IceConnectionState iceConnectionState; + + public IceConnectionStateObserver(CallParticipantModel callParticipantModel) { + this.callParticipantModel = callParticipantModel; + + callParticipantModel.addObserver(this); + iceConnectionStateObservers.put(callParticipantModel.getSessionId(), this); + } + + @Override + public void onChange() { + if (Objects.equals(iceConnectionState, callParticipantModel.getIceConnectionState())) { + return; + } + + iceConnectionState = callParticipantModel.getIceConnectionState(); + + if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED || + iceConnectionState == PeerConnection.IceConnectionState.COMPLETED) { + remove(); + + sendState(callParticipantModel.getSessionId()); + } + } + + @Override + public void onReaction(String reaction) { + } + + public void remove() { + callParticipantModel.removeObserver(this); + iceConnectionStateObservers.remove(callParticipantModel.getSessionId()); + } + } + + public LocalStateBroadcasterNoMcu(LocalCallParticipantModel localCallParticipantModel, + MessageSenderNoMcu messageSender) { + super(localCallParticipantModel, messageSender); + + this.messageSender = messageSender; + } + + public void destroy() { + super.destroy(); + + // The observers remove themselves from the map, so a copy is needed to remove them while iterating. + List iceConnectionStateObserversCopy = + new ArrayList<>(iceConnectionStateObservers.values()); + for (IceConnectionStateObserver iceConnectionStateObserver : iceConnectionStateObserversCopy) { + iceConnectionStateObserver.remove(); + } + } + + @Override + public void handleCallParticipantAdded(CallParticipantModel callParticipantModel) { + IceConnectionStateObserver iceConnectionStateObserver = + iceConnectionStateObservers.get(callParticipantModel.getSessionId()); + if (iceConnectionStateObserver != null) { + iceConnectionStateObserver.remove(); + } + + iceConnectionStateObserver = new IceConnectionStateObserver(callParticipantModel); + iceConnectionStateObservers.put(callParticipantModel.getSessionId(), iceConnectionStateObserver); + } + + @Override + public void handleCallParticipantRemoved(CallParticipantModel callParticipantModel) { + IceConnectionStateObserver iceConnectionStateObserver = + iceConnectionStateObservers.get(callParticipantModel.getSessionId()); + if (iceConnectionStateObserver != null) { + iceConnectionStateObserver.remove(); + } + } + + private void sendState(String sessionId) { + messageSender.send(getDataChannelMessageForAudioState(), sessionId); + messageSender.send(getDataChannelMessageForSpeakingState(), sessionId); + messageSender.send(getDataChannelMessageForVideoState(), sessionId); + + messageSender.send(getSignalingMessageForAudioState(), sessionId); + messageSender.send(getSignalingMessageForVideoState(), sessionId); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/MessageSender.java b/app/src/main/java/com/nextcloud/talk/call/MessageSender.java new file mode 100644 index 0000000000..dd3eb149ad --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/MessageSender.java @@ -0,0 +1,93 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage; +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; +import com.nextcloud.talk.signaling.SignalingMessageSender; +import com.nextcloud.talk.webrtc.PeerConnectionWrapper; + +import java.util.List; +import java.util.Set; + +/** + * Helper class to send messages to participants in a call. + *

+ * A specific subclass has to be created depending on whether an MCU is being used or not. + *

+ * Note that recipients of signaling messages are not validated, so no error will be triggered if trying to send a + * message to a participant with a session ID that does not exist or is not in the call. + *

+ * Also note that, unlike signaling messages, data channel messages require a peer connection. Therefore data channel + * messages may not be received by a participant if there is no peer connection with that participant (for example, if + * neither the local and remote participants have publishing rights). Moreover, data channel messages are expected to + * be received only on peer connections with type "video", so data channel messages will not be sent on other peer + * connections. + */ +public abstract class MessageSender { + + private final SignalingMessageSender signalingMessageSender; + + private final Set callParticipantSessionIds; + + protected final List peerConnectionWrappers; + + public MessageSender(SignalingMessageSender signalingMessageSender, + Set callParticipantSessionIds, + List peerConnectionWrappers) { + this.signalingMessageSender = signalingMessageSender; + this.callParticipantSessionIds = callParticipantSessionIds; + this.peerConnectionWrappers = peerConnectionWrappers; + } + + /** + * Sends the given data channel message to all the participants in the call. + * + * @param dataChannelMessage the message to send + */ + public abstract void sendToAll(DataChannelMessage dataChannelMessage); + + /** + * Sends the given signaling message to the given session ID. + *

+ * Note that the signaling message will be modified to set the recipient in the "to" field. + * + * @param ncSignalingMessage the message to send + * @param sessionId the signaling session ID of the participant to send the message to + */ + public void send(NCSignalingMessage ncSignalingMessage, String sessionId) { + ncSignalingMessage.setTo(sessionId); + + signalingMessageSender.send(ncSignalingMessage); + } + + /** + * Sends the given signaling message to all the participants in the call. + *

+ * Note that the signaling message will be modified to set each of the recipients in the "to" field. + * + * @param ncSignalingMessage the message to send + */ + public void sendToAll(NCSignalingMessage ncSignalingMessage) { + for (String sessionId: callParticipantSessionIds) { + ncSignalingMessage.setTo(sessionId); + + signalingMessageSender.send(ncSignalingMessage); + } + } + + protected PeerConnectionWrapper getPeerConnectionWrapper(String sessionId) { + for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) { + if (peerConnectionWrapper.getSessionId().equals(sessionId) + && "video".equals(peerConnectionWrapper.getVideoStreamType())) { + return peerConnectionWrapper; + } + } + + return null; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/MessageSenderMcu.java b/app/src/main/java/com/nextcloud/talk/call/MessageSenderMcu.java new file mode 100644 index 0000000000..0b7d3eaeef --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/MessageSenderMcu.java @@ -0,0 +1,41 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage; +import com.nextcloud.talk.signaling.SignalingMessageSender; +import com.nextcloud.talk.webrtc.PeerConnectionWrapper; + +import java.util.List; +import java.util.Set; + +/** + * Helper class to send messages to participants in a call when an MCU is used. + *

+ * Note that when Janus is used it is not possible to send a data channel message to a specific participant. Any data + * channel message will be broadcast to all the subscribers of the publisher peer connection (the own peer connection). + */ +public class MessageSenderMcu extends MessageSender { + + private final String ownSessionId; + + public MessageSenderMcu(SignalingMessageSender signalingMessageSender, + Set callParticipantSessionIds, + List peerConnectionWrappers, + String ownSessionId) { + super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers); + + this.ownSessionId = ownSessionId; + } + + public void sendToAll(DataChannelMessage dataChannelMessage) { + PeerConnectionWrapper ownPeerConnectionWrapper = getPeerConnectionWrapper(ownSessionId); + if (ownPeerConnectionWrapper != null) { + ownPeerConnectionWrapper.send(dataChannelMessage); + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/MessageSenderNoMcu.java b/app/src/main/java/com/nextcloud/talk/call/MessageSenderNoMcu.java new file mode 100644 index 0000000000..d6c837bb73 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/MessageSenderNoMcu.java @@ -0,0 +1,47 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage; +import com.nextcloud.talk.signaling.SignalingMessageSender; +import com.nextcloud.talk.webrtc.PeerConnectionWrapper; + +import java.util.List; +import java.util.Set; + +/** + * Helper class to send messages to participants in a call when an MCU is not used. + */ +public class MessageSenderNoMcu extends MessageSender { + + public MessageSenderNoMcu(SignalingMessageSender signalingMessageSender, + Set callParticipantSessionIds, + List peerConnectionWrappers) { + super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers); + } + + /** + * Sends the given data channel message to the given signaling session ID. + * + * @param dataChannelMessage the message to send + * @param sessionId the signaling session ID of the participant to send the message to + */ + public void send(DataChannelMessage dataChannelMessage, String sessionId) { + PeerConnectionWrapper peerConnectionWrapper = getPeerConnectionWrapper(sessionId); + if (peerConnectionWrapper != null) { + peerConnectionWrapper.send(dataChannelMessage); + } + } + + public void sendToAll(DataChannelMessage dataChannelMessage) { + for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) { + if ("video".equals(peerConnectionWrapper.getVideoStreamType())){ + peerConnectionWrapper.send(dataChannelMessage); + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java new file mode 100644 index 0000000000..91bbbfc9ff --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java @@ -0,0 +1,51 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import java.util.Objects; + +/** + * Mutable data model for local call participants. + *

+ * Setting "speaking" will automatically set "speaking" or "speakingWhileMuted" as needed, depending on whether audio is + * enabled or not. Similarly, setting whether the audio is enabled or disabled will automatically switch between + * "speaking" and "speakingWhileMuted" as needed. + *

+ * There is no synchronization when setting the values; if needed, it should be handled by the clients of the model. + */ +public class MutableLocalCallParticipantModel extends LocalCallParticipantModel { + + public void setAudioEnabled(Boolean audioEnabled) { + if (Objects.equals(this.audioEnabled.getValue(), audioEnabled)) { + return; + } + + if (audioEnabled == null || !audioEnabled) { + this.speakingWhileMuted.setValue(this.speaking.getValue()); + this.speaking.setValue(Boolean.FALSE); + } + + this.audioEnabled.setValue(audioEnabled); + + if (audioEnabled != null && audioEnabled) { + this.speaking.setValue(this.speakingWhileMuted.getValue()); + this.speakingWhileMuted.setValue(Boolean.FALSE); + } + } + + public void setSpeaking(Boolean speaking) { + if (this.audioEnabled.getValue() != null && this.audioEnabled.getValue()) { + this.speaking.setValue(speaking); + } else { + this.speakingWhileMuted.setValue(speaking); + } + } + + public void setVideoEnabled(Boolean videoEnabled) { + this.videoEnabled.setValue(videoEnabled); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 9f2a90f859..70d59e11fa 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -62,9 +62,6 @@ public class PeerConnectionWrapper { private final List pendingDataChannelMessages = new ArrayList<>(); private final SdpObserver sdpObserver; - private final boolean hasInitiated; - - private final MediaStream localStream; private final boolean isMCUPublisher; private final String videoStreamType; @@ -113,14 +110,13 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, boolean isMCUPublisher, boolean hasMCU, String videoStreamType, SignalingMessageReceiver signalingMessageReceiver, SignalingMessageSender signalingMessageSender) { - this.localStream = localStream; this.videoStreamType = videoStreamType; this.sessionId = sessionId; this.mediaConstraints = mediaConstraints; sdpObserver = new SdpObserver(); - hasInitiated = sessionId.compareTo(localSession) < 0; + boolean hasInitiated = sessionId.compareTo(localSession) < 0; this.isMCUPublisher = isMCUPublisher; PeerConnection.RTCConfiguration configuration = new PeerConnection.RTCConfiguration(iceServerList); @@ -133,12 +129,12 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, this.signalingMessageSender = signalingMessageSender; if (peerConnection != null) { - if (this.localStream != null) { - List localStreamIds = Collections.singletonList(this.localStream.getId()); - for(AudioTrack track : this.localStream.audioTracks) { + if (localStream != null) { + List localStreamIds = Collections.singletonList(localStream.getId()); + for(AudioTrack track : localStream.audioTracks) { peerConnection.addTrack(track, localStreamIds); } - for(VideoTrack track : this.localStream.videoTracks) { + for(VideoTrack track : localStream.videoTracks) { peerConnection.addTrack(track, localStreamIds); } } @@ -329,22 +325,6 @@ public String getSessionId() { return sessionId; } - private void sendInitialMediaStatus() { - if (localStream != null) { - if (localStream.videoTracks.size() == 1 && localStream.videoTracks.get(0).enabled()) { - send(new DataChannelMessage("videoOn")); - } else { - send(new DataChannelMessage("videoOff")); - } - - if (localStream.audioTracks.size() == 1 && localStream.audioTracks.get(0).enabled()) { - send(new DataChannelMessage("audioOn")); - } else { - send(new DataChannelMessage("audioOff")); - } - } - } - public boolean isMCUPublisher() { return isMCUPublisher; } @@ -432,10 +412,6 @@ public void onStateChange() { } pendingDataChannelMessages.clear(); } - - if (dataChannel.state() == DataChannel.State.OPEN) { - sendInitialMediaStatus(); - } } } @@ -523,11 +499,6 @@ public void onSignalingChange(PeerConnection.SignalingState signalingState) { public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { Log.d("iceConnectionChangeTo: ", iceConnectionState.name() + " over " + peerConnection.hashCode() + " " + sessionId); - if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED) { - if (hasInitiated) { - sendInitialMediaStatus(); - } - } peerConnectionNotifier.notifyIceConnectionStateChanged(iceConnectionState); } diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt new file mode 100644 index 0000000000..2440fd0c1b --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt @@ -0,0 +1,168 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +class LocalCallParticipantModelTest { + private var localCallParticipantModel: MutableLocalCallParticipantModel? = null + private var mockedLocalCallParticipantModelObserver: LocalCallParticipantModel.Observer? = null + + @Before + fun setUp() { + localCallParticipantModel = MutableLocalCallParticipantModel() + mockedLocalCallParticipantModelObserver = Mockito.mock(LocalCallParticipantModel.Observer::class.java) + } + + @Test + fun testSetAudioEnabled() { + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetAudioEnabledWhileSpeakingWhileMuted() { + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioEnabledTwiceWhileSpeakingWhileMuted() { + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetAudioDisabledWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioDisabledTwiceWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetSpeakingWhileAudioEnabled() { + localCallParticipantModel!!.isAudioEnabled = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetNotSpeakingWhileAudioEnabled() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = false + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetSpeakingWhileAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = true + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetNotSpeakingWhileAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterMcuTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterMcuTest.kt new file mode 100644 index 0000000000..03e32457e9 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterMcuTest.kt @@ -0,0 +1,641 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.models.json.signaling.NCMessagePayload +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.TestScheduler +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.times +import java.util.concurrent.TimeUnit + +@Suppress("LongMethod") +class LocalStateBroadcasterMcuTest { + + private var localCallParticipantModel: MutableLocalCallParticipantModel? = null + private var mockedMessageSender: MessageSender? = null + + private var localStateBroadcasterMcu: LocalStateBroadcasterMcu? = null + + @Before + fun setUp() { + localCallParticipantModel = MutableLocalCallParticipantModel() + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + localCallParticipantModel!!.isVideoEnabled = true + mockedMessageSender = Mockito.mock(MessageSender::class.java) + } + + private fun getExpectedUnmuteAudio(): NCSignalingMessage { + val expectedUnmuteAudio = NCSignalingMessage() + expectedUnmuteAudio.roomType = "video" + expectedUnmuteAudio.type = "unmute" + + val payload = NCMessagePayload() + payload.name = "audio" + expectedUnmuteAudio.payload = payload + + return expectedUnmuteAudio + } + + private fun getExpectedMuteAudio(): NCSignalingMessage { + val expectedMuteAudio = NCSignalingMessage() + expectedMuteAudio.roomType = "video" + expectedMuteAudio.type = "mute" + + val payload = NCMessagePayload() + payload.name = "audio" + expectedMuteAudio.payload = payload + + return expectedMuteAudio + } + + private fun getExpectedUnmuteVideo(): NCSignalingMessage { + val expectedUnmuteVideo = NCSignalingMessage() + expectedUnmuteVideo.roomType = "video" + expectedUnmuteVideo.type = "unmute" + + val payload = NCMessagePayload() + payload.name = "video" + expectedUnmuteVideo.payload = payload + + return expectedUnmuteVideo + } + + private fun getExpectedMuteVideo(): NCSignalingMessage { + val expectedMuteVideo = NCSignalingMessage() + expectedMuteVideo.roomType = "video" + expectedMuteVideo.type = "mute" + + val payload = NCMessagePayload() + payload.name = "video" + expectedMuteVideo.payload = payload + + return expectedMuteVideo + } + + @Test + fun testStateSentWithExponentialBackoffWhenParticipantAdded() { + val testScheduler = TestScheduler() + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + + localStateBroadcasterMcu = LocalStateBroadcasterMcu( + localCallParticipantModel, + mockedMessageSender + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel) + + // Sending will be done in another thread, so just adding the participant does not send anything until that + // other thread could run. + Mockito.verifyNoInteractions(mockedMessageSender) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + var messageCount = 1 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + messageCount = 2 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + messageCount = 3 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + messageCount = 4 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(8, TimeUnit.SECONDS) + + messageCount = 5 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(16, TimeUnit.SECONDS) + + messageCount = 6 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateSentWithExponentialBackoffIsTheCurrentState() { + // This test could have been included in "testStateSentWithExponentialBackoffWhenParticipantAdded", but was + // kept separate for clarity. + + val testScheduler = TestScheduler() + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + + localStateBroadcasterMcu = LocalStateBroadcasterMcu( + localCallParticipantModel, + mockedMessageSender + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel) + + // Sending will be done in another thread, so just adding the participant does not send anything until that + // other thread could run. + Mockito.verifyNoInteractions(mockedMessageSender) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = false + + val expectedStoppedSpeaking = DataChannelMessage("stoppedSpeaking") + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedStoppedSpeaking) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + Mockito.verify(mockedMessageSender!!, times(2)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(2)).sendToAll(expectedStoppedSpeaking) + Mockito.verify(mockedMessageSender!!, times(2)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(2)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(2)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = false + + val expectedAudioOff = DataChannelMessage("audioOff") + val expectedMuteAudio = getExpectedMuteAudio() + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteAudio) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + Mockito.verify(mockedMessageSender!!, times(2)).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!, times(3)).sendToAll(expectedStoppedSpeaking) + Mockito.verify(mockedMessageSender!!, times(3)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteAudio) + Mockito.verify(mockedMessageSender!!, times(1)).send(expectedMuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(3)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = false + + val expectedVideoOff = DataChannelMessage("videoOff") + val expectedMuteVideo = getExpectedMuteVideo() + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedVideoOff) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteVideo) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + Mockito.verify(mockedMessageSender!!, times(3)).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!, times(4)).sendToAll(expectedStoppedSpeaking) + Mockito.verify(mockedMessageSender!!, times(2)).sendToAll(expectedVideoOff) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteAudio) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteVideo) + Mockito.verify(mockedMessageSender!!, times(2)).send(expectedMuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(1)).send(expectedMuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = true + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(4)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedUnmuteVideo) + + testScheduler.advanceTimeBy(8, TimeUnit.SECONDS) + + Mockito.verify(mockedMessageSender!!, times(4)).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!, times(5)).sendToAll(expectedStoppedSpeaking) + Mockito.verify(mockedMessageSender!!, times(5)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteAudio) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedMuteVideo) + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedUnmuteVideo) + Mockito.verify(mockedMessageSender!!, times(3)).send(expectedMuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(4)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateSentWithExponentialBackoffWhenAnotherParticipantAdded() { + // The state sent through data channels should be restarted, although the state sent through signaling + // messages should be independent for each participant. + + val testScheduler = TestScheduler() + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + + localStateBroadcasterMcu = LocalStateBroadcasterMcu( + localCallParticipantModel, + mockedMessageSender + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel) + + // Sending will be done in another thread, so just adding the participant does not send anything until that + // other thread could run. + Mockito.verifyNoInteractions(mockedMessageSender) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + var dataChannelMessageCount = 1 + var signalingMessageCount1 = 1 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + dataChannelMessageCount = 2 + signalingMessageCount1 = 2 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + dataChannelMessageCount = 3 + signalingMessageCount1 = 3 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + dataChannelMessageCount = 4 + signalingMessageCount1 = 4 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + val callParticipantModel2 = MutableCallParticipantModel("theSessionId2") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel2) + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + dataChannelMessageCount = 5 + var signalingMessageCount2 = 1 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + dataChannelMessageCount = 6 + signalingMessageCount2 = 2 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + dataChannelMessageCount = 7 + signalingMessageCount2 = 3 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + dataChannelMessageCount = 8 + signalingMessageCount2 = 4 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + // 0+1+2+4+1=8 seconds since last signaling messages for participant 1 + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + signalingMessageCount1 = 5 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + // 1+7=8 seconds since last data channel messages and signaling messages for participant 2 + testScheduler.advanceTimeBy(7, TimeUnit.SECONDS) + + dataChannelMessageCount = 9 + signalingMessageCount2 = 5 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + // 7+9=16 seconds since last signaling messages for participant 1 + testScheduler.advanceTimeBy(9, TimeUnit.SECONDS) + + signalingMessageCount1 = 6 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + // 9+7=16 seconds since last data channel messages and signaling messages for participant 2 + testScheduler.advanceTimeBy(7, TimeUnit.SECONDS) + + dataChannelMessageCount = 10 + signalingMessageCount2 = 6 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount1)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount2)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateSentWithExponentialBackoffWhenParticipantRemoved() { + // For simplicity the exponential backoff is not aborted when the participant that triggered it is removed. + // However, the signaling messages are stopped when the participant is removed. + + val testScheduler = TestScheduler() + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + + localStateBroadcasterMcu = LocalStateBroadcasterMcu( + localCallParticipantModel, + mockedMessageSender + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel) + + // Sending will be done in another thread, so just adding the participant does not send anything until that + // other thread could run. + Mockito.verifyNoInteractions(mockedMessageSender) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + var dataChannelMessageCount = 1 + var signalingMessageCount = 1 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + dataChannelMessageCount = 2 + signalingMessageCount = 2 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + dataChannelMessageCount = 3 + signalingMessageCount = 3 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + dataChannelMessageCount = 4 + signalingMessageCount = 4 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localStateBroadcasterMcu!!.handleCallParticipantRemoved(callParticipantModel) + + testScheduler.advanceTimeBy(8, TimeUnit.SECONDS) + + dataChannelMessageCount = 5 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(16, TimeUnit.SECONDS) + + dataChannelMessageCount = 6 + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(dataChannelMessageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(signalingMessageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateNoLongerSentOnceDestroyed() { + val testScheduler = TestScheduler() + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + + localStateBroadcasterMcu = LocalStateBroadcasterMcu( + localCallParticipantModel, + mockedMessageSender + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + val callParticipantModel2 = MutableCallParticipantModel("theSessionId2") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel) + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel2) + + // Sending will be done in another thread, so just adding the participant does not send anything until that + // other thread could run. + Mockito.verifyNoInteractions(mockedMessageSender) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + testScheduler.advanceTimeBy(0, TimeUnit.SECONDS) + + var messageCount = 1 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, TimeUnit.SECONDS) + + messageCount = 2 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + messageCount = 3 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSender!!, times(messageCount)).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + localStateBroadcasterMcu!!.destroy() + + testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcuTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcuTest.kt new file mode 100644 index 0000000000..f225ff7394 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcuTest.kt @@ -0,0 +1,357 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.models.json.signaling.NCMessagePayload +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.webrtc.PeerConnection + +class LocalStateBroadcasterNoMcuTest { + + private var localCallParticipantModel: MutableLocalCallParticipantModel? = null + private var mockedMessageSenderNoMcu: MessageSenderNoMcu? = null + + private var localStateBroadcasterNoMcu: LocalStateBroadcasterNoMcu? = null + + @Before + fun setUp() { + localCallParticipantModel = MutableLocalCallParticipantModel() + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + localCallParticipantModel!!.isVideoEnabled = true + mockedMessageSenderNoMcu = Mockito.mock(MessageSenderNoMcu::class.java) + } + + private fun getExpectedUnmuteAudio(): NCSignalingMessage { + val expectedUnmuteAudio = NCSignalingMessage() + expectedUnmuteAudio.roomType = "video" + expectedUnmuteAudio.type = "unmute" + + val payload = NCMessagePayload() + payload.name = "audio" + expectedUnmuteAudio.payload = payload + + return expectedUnmuteAudio + } + + private fun getExpectedUnmuteVideo(): NCSignalingMessage { + val expectedUnmuteVideo = NCSignalingMessage() + expectedUnmuteVideo.roomType = "video" + expectedUnmuteVideo.type = "unmute" + + val payload = NCMessagePayload() + payload.name = "video" + expectedUnmuteVideo.payload = payload + + return expectedUnmuteVideo + } + + @Test + fun testStateSentWhenIceConnected() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateSentWhenIceCompleted() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.COMPLETED) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceCompletedAfterConnected() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.COMPLETED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceConnectedAgain() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.COMPLETED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + // Completed -> Connected could happen with an ICE restart + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.DISCONNECTED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + // Failed -> Checking could happen with an ICE restart + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.FAILED) + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentToOtherParticipantsWhenIceConnected() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + val callParticipantModel2 = MutableCallParticipantModel("theSessionId2") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel2) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + callParticipantModel2.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteAudio = getExpectedUnmuteAudio() + val expectedUnmuteVideo = getExpectedUnmuteVideo() + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + + callParticipantModel2.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId2") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId2") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "theSessionId2") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteAudio, "theSessionId2") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedUnmuteVideo, "theSessionId2") + Mockito.verifyNoMoreInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceConnectedAfterParticipantIsRemoved() { + // This should not happen, as peer connections are expected to be ended when a call participant is removed, but + // just in case. + + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + localStateBroadcasterNoMcu!!.handleCallParticipantRemoved(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceCompletedAfterParticipantIsRemoved() { + // This should not happen, as peer connections are expected to be ended when a call participant is removed, but + // just in case. + + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + localStateBroadcasterNoMcu!!.handleCallParticipantRemoved(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.COMPLETED) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceConnectedAfterDestroyed() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + val callParticipantModel2 = MutableCallParticipantModel("theSessionId2") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel2) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + callParticipantModel2.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + localStateBroadcasterNoMcu!!.destroy() + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + callParticipantModel2.setIceConnectionState(PeerConnection.IceConnectionState.CONNECTED) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + } + + @Test + fun testStateNotSentWhenIceCompletedAfterDestroyed() { + localStateBroadcasterNoMcu = LocalStateBroadcasterNoMcu( + localCallParticipantModel, + mockedMessageSenderNoMcu + ) + + val callParticipantModel = MutableCallParticipantModel("theSessionId") + + localStateBroadcasterNoMcu!!.handleCallParticipantAdded(callParticipantModel) + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.CHECKING) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + + localStateBroadcasterNoMcu!!.destroy() + + callParticipantModel.setIceConnectionState(PeerConnection.IceConnectionState.COMPLETED) + + Mockito.verifyNoInteractions(mockedMessageSenderNoMcu) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterTest.kt new file mode 100644 index 0000000000..34ca59e7e7 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterTest.kt @@ -0,0 +1,324 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.models.json.signaling.NCMessagePayload +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +@Suppress("TooManyFunctions") +class LocalStateBroadcasterTest { + + private class LocalStateBroadcaster( + localCallParticipantModel: LocalCallParticipantModel?, + messageSender: MessageSender? + ) : com.nextcloud.talk.call.LocalStateBroadcaster(localCallParticipantModel, messageSender) { + + override fun handleCallParticipantAdded(callParticipantModel: CallParticipantModel) { + // Not used in base class tests + } + + override fun handleCallParticipantRemoved(callParticipantModel: CallParticipantModel) { + // Not used in base class tests + } + } + + private var localCallParticipantModel: MutableLocalCallParticipantModel? = null + private var mockedMessageSender: MessageSender? = null + + private var localStateBroadcaster: LocalStateBroadcaster? = null + + @Before + fun setUp() { + localCallParticipantModel = MutableLocalCallParticipantModel() + mockedMessageSender = Mockito.mock(MessageSender::class.java) + } + + @Test + fun testEnableAudio() { + localCallParticipantModel!!.isAudioEnabled = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = true + + val expectedAudioOn = DataChannelMessage("audioOn") + + val expectedUnmuteAudio = NCSignalingMessage() + expectedUnmuteAudio.roomType = "video" + expectedUnmuteAudio.type = "unmute" + val payload = NCMessagePayload() + payload.name = "audio" + expectedUnmuteAudio.payload = payload + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedUnmuteAudio) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testEnableAudioTwice() { + localCallParticipantModel!!.isAudioEnabled = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = true + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableAudio() { + localCallParticipantModel!!.isAudioEnabled = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = false + + val expectedAudioOff = DataChannelMessage("audioOff") + + val expectedMuteAudio = NCSignalingMessage() + expectedMuteAudio.roomType = "video" + expectedMuteAudio.type = "mute" + val payload = NCMessagePayload() + payload.name = "audio" + expectedMuteAudio.payload = payload + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedMuteAudio) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableAudioTwice() { + localCallParticipantModel!!.isAudioEnabled = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = false + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testEnableSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = true + + val expectedSpeaking = DataChannelMessage("speaking") + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedSpeaking) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testEnableSpeakingTwice() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = true + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testEnableSpeakingWithAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = true + + Mockito.verifyNoInteractions(mockedMessageSender) + } + + @Test + fun testEnableAudioWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = true + localCallParticipantModel!!.isAudioEnabled = true + + val expectedAudioOn = DataChannelMessage("audioOn") + val expectedSpeaking = DataChannelMessage("speaking") + + val expectedUnmuteAudio = NCSignalingMessage() + expectedUnmuteAudio.roomType = "video" + expectedUnmuteAudio.type = "unmute" + val payload = NCMessagePayload() + payload.name = "audio" + expectedUnmuteAudio.payload = payload + + val inOrder = Mockito.inOrder(mockedMessageSender) + + inOrder.verify(mockedMessageSender!!).sendToAll(expectedAudioOn) + inOrder.verify(mockedMessageSender!!).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedUnmuteAudio) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = false + + val expectedStoppedSpeaking = DataChannelMessage("stoppedSpeaking") + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedStoppedSpeaking) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableSpeakingTwice() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = false + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableAudioWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = false + + val expectedStoppedSpeaking = DataChannelMessage("stoppedSpeaking") + val expectedAudioOff = DataChannelMessage("audioOff") + + val expectedMuteAudio = NCSignalingMessage() + expectedMuteAudio.roomType = "video" + expectedMuteAudio.type = "mute" + val payload = NCMessagePayload() + payload.name = "audio" + expectedMuteAudio.payload = payload + + val inOrder = Mockito.inOrder(mockedMessageSender) + + inOrder.verify(mockedMessageSender!!).sendToAll(expectedStoppedSpeaking) + inOrder.verify(mockedMessageSender!!).sendToAll(expectedAudioOff) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedMuteAudio) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableSpeakingWithAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isSpeaking = false + + Mockito.verifyNoInteractions(mockedMessageSender) + } + + @Test + fun testEnableVideo() { + localCallParticipantModel!!.isVideoEnabled = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = true + + val expectedVideoOn = DataChannelMessage("videoOn") + + val expectedUnmuteVideo = NCSignalingMessage() + expectedUnmuteVideo.roomType = "video" + expectedUnmuteVideo.type = "unmute" + val payload = NCMessagePayload() + payload.name = "video" + expectedUnmuteVideo.payload = payload + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedVideoOn) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedUnmuteVideo) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testEnableVideoTwice() { + localCallParticipantModel!!.isVideoEnabled = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = true + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableVideo() { + localCallParticipantModel!!.isVideoEnabled = true + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = false + + val expectedVideoOff = DataChannelMessage("videoOff") + + val expectedMuteVideo = NCSignalingMessage() + expectedMuteVideo.roomType = "video" + expectedMuteVideo.type = "mute" + val payload = NCMessagePayload() + payload.name = "video" + expectedMuteVideo.payload = payload + + Mockito.verify(mockedMessageSender!!).sendToAll(expectedVideoOff) + Mockito.verify(mockedMessageSender!!).sendToAll(expectedMuteVideo) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testDisableVideoTwice() { + localCallParticipantModel!!.isVideoEnabled = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = false + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testChangeStateAfterDestroying() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = false + localCallParticipantModel!!.isVideoEnabled = false + + localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, mockedMessageSender) + + localStateBroadcaster!!.destroy() + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + localCallParticipantModel!!.isVideoEnabled = true + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/MessageSenderMcuTest.kt b/app/src/test/java/com/nextcloud/talk/call/MessageSenderMcuTest.kt new file mode 100644 index 0000000000..9fd8d6289e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/MessageSenderMcuTest.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.signaling.SignalingMessageSender +import com.nextcloud.talk.webrtc.PeerConnectionWrapper +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.never + +class MessageSenderMcuTest { + + private var peerConnectionWrappers: MutableList? = null + private var peerConnectionWrapper1: PeerConnectionWrapper? = null + private var peerConnectionWrapper2: PeerConnectionWrapper? = null + private var peerConnectionWrapper2Screen: PeerConnectionWrapper? = null + private var peerConnectionWrapper4Screen: PeerConnectionWrapper? = null + private var ownPeerConnectionWrapper: PeerConnectionWrapper? = null + private var ownPeerConnectionWrapperScreen: PeerConnectionWrapper? = null + + private var messageSender: MessageSenderMcu? = null + + @Before + fun setUp() { + val signalingMessageSender = Mockito.mock(SignalingMessageSender::class.java) + + val callParticipants = HashMap() + + peerConnectionWrappers = ArrayList() + + peerConnectionWrapper1 = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper1!!.sessionId).thenReturn("theSessionId1") + Mockito.`when`(peerConnectionWrapper1!!.videoStreamType).thenReturn("video") + peerConnectionWrappers!!.add(peerConnectionWrapper1) + + peerConnectionWrapper2 = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper2!!.sessionId).thenReturn("theSessionId2") + Mockito.`when`(peerConnectionWrapper2!!.videoStreamType).thenReturn("video") + peerConnectionWrappers!!.add(peerConnectionWrapper2) + + peerConnectionWrapper2Screen = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper2Screen!!.sessionId).thenReturn("theSessionId2") + Mockito.`when`(peerConnectionWrapper2Screen!!.videoStreamType).thenReturn("screen") + peerConnectionWrappers!!.add(peerConnectionWrapper2Screen) + + peerConnectionWrapper4Screen = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper4Screen!!.sessionId).thenReturn("theSessionId4") + Mockito.`when`(peerConnectionWrapper4Screen!!.videoStreamType).thenReturn("screen") + peerConnectionWrappers!!.add(peerConnectionWrapper4Screen) + + ownPeerConnectionWrapper = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(ownPeerConnectionWrapper!!.sessionId).thenReturn("ownSessionId") + Mockito.`when`(ownPeerConnectionWrapper!!.videoStreamType).thenReturn("video") + peerConnectionWrappers!!.add(ownPeerConnectionWrapper) + + ownPeerConnectionWrapperScreen = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(ownPeerConnectionWrapperScreen!!.sessionId).thenReturn("ownSessionId") + Mockito.`when`(ownPeerConnectionWrapperScreen!!.videoStreamType).thenReturn("screen") + peerConnectionWrappers!!.add(ownPeerConnectionWrapperScreen) + + messageSender = MessageSenderMcu( + signalingMessageSender, + callParticipants.keys, + peerConnectionWrappers, + "ownSessionId" + ) + } + + @Test + fun testSendDataChannelMessageToAll() { + val message = DataChannelMessage() + messageSender!!.sendToAll(message) + + Mockito.verify(ownPeerConnectionWrapper!!).send(message) + Mockito.verify(ownPeerConnectionWrapperScreen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } + + @Test + fun testSendDataChannelMessageToAllIfOwnScreenPeerConnection() { + peerConnectionWrappers!!.remove(ownPeerConnectionWrapper) + + val message = DataChannelMessage() + messageSender!!.sendToAll(message) + + Mockito.verify(ownPeerConnectionWrapper!!, never()).send(message) + Mockito.verify(ownPeerConnectionWrapperScreen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } + + @Test + fun testSendDataChannelMessageToAllWithoutOwnPeerConnection() { + peerConnectionWrappers!!.remove(ownPeerConnectionWrapper) + peerConnectionWrappers!!.remove(ownPeerConnectionWrapperScreen) + + val message = DataChannelMessage() + messageSender!!.sendToAll(message) + + Mockito.verify(ownPeerConnectionWrapper!!, never()).send(message) + Mockito.verify(ownPeerConnectionWrapperScreen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/MessageSenderNoMcuTest.kt b/app/src/test/java/com/nextcloud/talk/call/MessageSenderNoMcuTest.kt new file mode 100644 index 0000000000..303108ed97 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/MessageSenderNoMcuTest.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.signaling.SignalingMessageSender +import com.nextcloud.talk.webrtc.PeerConnectionWrapper +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.never + +class MessageSenderNoMcuTest { + + private var peerConnectionWrappers: MutableList? = null + private var peerConnectionWrapper1: PeerConnectionWrapper? = null + private var peerConnectionWrapper2: PeerConnectionWrapper? = null + private var peerConnectionWrapper2Screen: PeerConnectionWrapper? = null + private var peerConnectionWrapper4Screen: PeerConnectionWrapper? = null + + private var messageSender: MessageSenderNoMcu? = null + + @Before + fun setUp() { + val signalingMessageSender = Mockito.mock(SignalingMessageSender::class.java) + + val callParticipants = HashMap() + + peerConnectionWrappers = ArrayList() + + peerConnectionWrapper1 = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper1!!.sessionId).thenReturn("theSessionId1") + Mockito.`when`(peerConnectionWrapper1!!.videoStreamType).thenReturn("video") + peerConnectionWrappers!!.add(peerConnectionWrapper1) + + peerConnectionWrapper2 = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper2!!.sessionId).thenReturn("theSessionId2") + Mockito.`when`(peerConnectionWrapper2!!.videoStreamType).thenReturn("video") + peerConnectionWrappers!!.add(peerConnectionWrapper2) + + peerConnectionWrapper2Screen = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper2Screen!!.sessionId).thenReturn("theSessionId2") + Mockito.`when`(peerConnectionWrapper2Screen!!.videoStreamType).thenReturn("screen") + peerConnectionWrappers!!.add(peerConnectionWrapper2Screen) + + peerConnectionWrapper4Screen = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(peerConnectionWrapper4Screen!!.sessionId).thenReturn("theSessionId4") + Mockito.`when`(peerConnectionWrapper4Screen!!.videoStreamType).thenReturn("screen") + peerConnectionWrappers!!.add(peerConnectionWrapper4Screen) + + messageSender = MessageSenderNoMcu(signalingMessageSender, callParticipants.keys, peerConnectionWrappers) + } + + @Test + fun testSendDataChannelMessage() { + val message = DataChannelMessage() + messageSender!!.send(message, "theSessionId2") + + Mockito.verify(peerConnectionWrapper2!!).send(message) + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } + + @Test + fun testSendDataChannelMessageIfScreenPeerConnection() { + val message = DataChannelMessage() + messageSender!!.send(message, "theSessionId4") + + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } + + @Test + fun testSendDataChannelMessageIfNoPeerConnection() { + val message = DataChannelMessage() + messageSender!!.send(message, "theSessionId3") + + Mockito.verify(peerConnectionWrapper1!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2!!, never()).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } + + @Test + fun testSendDataChannelMessageToAll() { + val message = DataChannelMessage() + messageSender!!.sendToAll(message) + + Mockito.verify(peerConnectionWrapper1!!).send(message) + Mockito.verify(peerConnectionWrapper2!!).send(message) + Mockito.verify(peerConnectionWrapper2Screen!!, never()).send(message) + Mockito.verify(peerConnectionWrapper4Screen!!, never()).send(message) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/MessageSenderTest.kt b/app/src/test/java/com/nextcloud/talk/call/MessageSenderTest.kt new file mode 100644 index 0000000000..46915ef40a --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/MessageSenderTest.kt @@ -0,0 +1,134 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import com.nextcloud.talk.models.json.signaling.DataChannelMessage +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import com.nextcloud.talk.signaling.SignalingMessageSender +import com.nextcloud.talk.webrtc.PeerConnectionWrapper +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.times +import org.mockito.invocation.InvocationOnMock + +class MessageSenderTest { + + private class MessageSender( + signalingMessageSender: SignalingMessageSender?, + callParticipantSessionIds: Set?, + peerConnectionWrappers: List? + ) : com.nextcloud.talk.call.MessageSender( + signalingMessageSender, + callParticipantSessionIds, + peerConnectionWrappers + ) { + + override fun sendToAll(dataChannelMessage: DataChannelMessage?) { + // Not used in base class tests + } + } + + private var signalingMessageSender: SignalingMessageSender? = null + + private var callParticipants: MutableMap? = null + + private var messageSender: MessageSender? = null + + @Before + fun setUp() { + signalingMessageSender = Mockito.mock(SignalingMessageSender::class.java) + + callParticipants = HashMap() + + val callParticipant1: CallParticipant = Mockito.mock(CallParticipant::class.java) + callParticipants!!["theSessionId1"] = callParticipant1 + + val callParticipant2: CallParticipant = Mockito.mock(CallParticipant::class.java) + callParticipants!!["theSessionId2"] = callParticipant2 + + val callParticipant3: CallParticipant = Mockito.mock(CallParticipant::class.java) + callParticipants!!["theSessionId3"] = callParticipant3 + + val callParticipant4: CallParticipant = Mockito.mock(CallParticipant::class.java) + callParticipants!!["theSessionId4"] = callParticipant4 + + val peerConnectionWrappers = ArrayList() + + messageSender = MessageSender(signalingMessageSender, callParticipants!!.keys, peerConnectionWrappers) + } + + @Test + fun testSendSignalingMessage() { + val message: NCSignalingMessage = Mockito.mock(NCSignalingMessage::class.java) + messageSender!!.send(message, "theSessionId2") + + Mockito.verify(message).to = "theSessionId2" + Mockito.verify(signalingMessageSender!!).send(message) + } + + @Test + fun testSendSignalingMessageIfUnknownSessionId() { + val message: NCSignalingMessage = Mockito.mock(NCSignalingMessage::class.java) + messageSender!!.send(message, "unknownSessionId") + + Mockito.verify(message).to = "unknownSessionId" + Mockito.verify(signalingMessageSender!!).send(message) + } + + @Test + fun testSendSignalingMessageToAll() { + val sentTo: MutableList = ArrayList() + doAnswer { invocation: InvocationOnMock -> + val arguments = invocation.arguments + val message = (arguments[0] as NCSignalingMessage) + + sentTo.add(message.to) + null + }.`when`(signalingMessageSender!!).send(any()) + + val message = NCSignalingMessage() + messageSender!!.sendToAll(message) + + assertTrue(sentTo.contains("theSessionId1")) + assertTrue(sentTo.contains("theSessionId2")) + assertTrue(sentTo.contains("theSessionId3")) + assertTrue(sentTo.contains("theSessionId4")) + Mockito.verify(signalingMessageSender!!, times(4)).send(message) + Mockito.verifyNoMoreInteractions(signalingMessageSender) + } + + @Test + fun testSendSignalingMessageToAllWhenParticipantsWereUpdated() { + val callParticipant5: CallParticipant = Mockito.mock(CallParticipant::class.java) + callParticipants!!["theSessionId5"] = callParticipant5 + + callParticipants!!.remove("theSessionId2") + callParticipants!!.remove("theSessionId3") + + val sentTo: MutableList = ArrayList() + doAnswer { invocation: InvocationOnMock -> + val arguments = invocation.arguments + val message = (arguments[0] as NCSignalingMessage) + + sentTo.add(message.to) + null + }.`when`(signalingMessageSender!!).send(any()) + + val message = NCSignalingMessage() + messageSender!!.sendToAll(message) + + assertTrue(sentTo.contains("theSessionId1")) + assertTrue(sentTo.contains("theSessionId4")) + assertTrue(sentTo.contains("theSessionId5")) + Mockito.verify(signalingMessageSender!!, times(3)).send(message) + Mockito.verifyNoMoreInteractions(signalingMessageSender) + } +}