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