Send current state to remote participants when they join

Note that this implicitly send the current state to remote participants
when the local participant joins, as in that case all the remote
participants already in the call join from the point of view of the
local participant

Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
This commit is contained in:
Daniel Calviño Sánchez 2024-12-11 05:53:15 +01:00 committed by Marcel Hibbe
parent ea4bccdaf7
commit 0ec5175c61
No known key found for this signature in database
GPG Key ID: C793F8B59F43CE7B
8 changed files with 975 additions and 30 deletions

View File

@ -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)

View File

@ -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.
* <p>
* 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";

View File

@ -0,0 +1,72 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* 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.
* <p>
* 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.
* <p>
* 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());
}
}

View File

@ -0,0 +1,119 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* 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.
* <p>
* 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.
* <p>
* 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<String, IceConnectionStateObserver> 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<IceConnectionStateObserver> 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);
}
}

View File

@ -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);
}

View File

@ -0,0 +1,439 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* 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)
}
}

View File

@ -0,0 +1,304 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* 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)
}
}

View File

@ -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