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 7a84def82..e8472e973 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -64,6 +64,8 @@ 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 @@ -1728,7 +1730,14 @@ class CallActivity : CallBaseActivity() { callParticipantList = CallParticipantList(signalingMessageReceiver) callParticipantList!!.addObserver(callParticipantListObserver) - localStateBroadcaster = LocalStateBroadcaster(localCallParticipantModel, messageSender) + 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( @@ -2429,6 +2438,9 @@ class CallActivity : CallBaseActivity() { callParticipantEventDisplayers[sessionId] = callParticipantEventDisplayer callParticipantModel.addObserver(callParticipantEventDisplayer, callParticipantEventDisplayersHandler) runOnUiThread { addParticipantDisplayItem(callParticipantModel, "video") } + + localStateBroadcaster!!.handleCallParticipantAdded(callParticipant.callParticipantModel) + return callParticipant } @@ -2454,6 +2466,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) diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java index 9ad0093f1..037631297 100644 --- a/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcaster.java @@ -17,8 +17,12 @@ import java.util.Objects; * 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 class LocalStateBroadcaster { +public abstract class LocalStateBroadcaster { private final LocalCallParticipantModel localCallParticipantModel; @@ -73,7 +77,10 @@ public class LocalStateBroadcaster { this.localCallParticipantModel.removeObserver(localCallParticipantModelObserver); } - private DataChannelMessage getDataChannelMessageForAudioState() { + 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"; @@ -82,7 +89,7 @@ public class LocalStateBroadcaster { return new DataChannelMessage(type); } - private DataChannelMessage getDataChannelMessageForSpeakingState() { + protected DataChannelMessage getDataChannelMessageForSpeakingState() { String type = "stoppedSpeaking"; if (localCallParticipantModel.isSpeaking() != null && localCallParticipantModel.isSpeaking()) { type = "speaking"; @@ -91,7 +98,7 @@ public class LocalStateBroadcaster { return new DataChannelMessage(type); } - private DataChannelMessage getDataChannelMessageForVideoState() { + protected DataChannelMessage getDataChannelMessageForVideoState() { String type = "videoOff"; if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) { type = "videoOn"; 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 000000000..628b61537 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterMcu.java @@ -0,0 +1,72 @@ +/* + * 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.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. + */ +public class LocalStateBroadcasterMcu extends LocalStateBroadcaster { + + private final MessageSender messageSender; + + 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(); + } + } + + @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()); + } + + @Override + public void handleCallParticipantRemoved(CallParticipantModel callParticipantModel) { + } + + private void sendState() { + messageSender.sendToAll(getDataChannelMessageForAudioState()); + messageSender.sendToAll(getDataChannelMessageForSpeakingState()); + messageSender.sendToAll(getDataChannelMessageForVideoState()); + } +} 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 000000000..2a1bf04ea --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcu.java @@ -0,0 +1,119 @@ +/* + * 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. + */ +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); + } +} 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 9f2a90f85..8929af1c0 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -329,22 +329,6 @@ public class PeerConnectionWrapper { 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 +416,6 @@ public class PeerConnectionWrapper { } pendingDataChannelMessages.clear(); } - - if (dataChannel.state() == DataChannel.State.OPEN) { - sendInitialMediaStatus(); - } } } @@ -523,11 +503,6 @@ public class PeerConnectionWrapper { 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/LocalStateBroadcasterMcuTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterMcuTest.kt new file mode 100644 index 000000000..aafeb2e51 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterMcuTest.kt @@ -0,0 +1,439 @@ +/* + * 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 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) + } + + @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") + + 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.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.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.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.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.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.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") + + 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.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.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isAudioEnabled = false + + val expectedAudioOff = DataChannelMessage("audioOff") + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedAudioOff) + + 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.verifyNoMoreInteractions(mockedMessageSender) + + localCallParticipantModel!!.isVideoEnabled = false + + val expectedVideoOff = DataChannelMessage("videoOff") + + // Changing the state causes the normal state update to be sent, independently of the initial state + Mockito.verify(mockedMessageSender!!, times(1)).sendToAll(expectedVideoOff) + + 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.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) + + 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.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateSentWithExponentialBackoffRestartedWhenAnotherParticipantAdded() { + 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") + + 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.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.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.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.verifyNoMoreInteractions(mockedMessageSender) + + val callParticipantModel2 = MutableCallParticipantModel("theSessionId2") + + localStateBroadcasterMcu!!.handleCallParticipantAdded(callParticipantModel2) + + testScheduler.advanceTimeBy(0, 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.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(1, 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.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(2, TimeUnit.SECONDS) + + messageCount = 7 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(4, TimeUnit.SECONDS) + + messageCount = 8 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(8, TimeUnit.SECONDS) + + messageCount = 9 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(16, TimeUnit.SECONDS) + + messageCount = 10 + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedAudioOn) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedSpeaking) + Mockito.verify(mockedMessageSender!!, times(messageCount)).sendToAll(expectedVideoOn) + Mockito.verifyNoMoreInteractions(mockedMessageSender) + + testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) + + Mockito.verifyNoMoreInteractions(mockedMessageSender) + } + + @Test + fun testStateStillSentWithExponentialBackoffWhenParticipantRemoved() { + // For simplicity the exponential backoff is not aborted when the participant that triggered it 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") + + 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.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.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.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.verifyNoMoreInteractions(mockedMessageSender) + + localStateBroadcasterMcu!!.handleCallParticipantRemoved(callParticipantModel) + + 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.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.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") + + 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") + + 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.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.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.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 000000000..2075785fd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterNoMcuTest.kt @@ -0,0 +1,304 @@ +/* + * 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 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) + } + + @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") + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "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") + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "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") + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "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") + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "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") + + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedAudioOn, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedSpeaking, "theSessionId") + Mockito.verify(mockedMessageSenderNoMcu!!).send(expectedVideoOn, "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.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 index a070ef946..29b205cda 100644 --- a/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterTest.kt +++ b/app/src/test/java/com/nextcloud/talk/call/LocalStateBroadcasterTest.kt @@ -14,6 +14,20 @@ 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