diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java index 8f31f0a49..dbc942517 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java @@ -266,6 +266,9 @@ public class CallActivity extends CallBaseActivity { private CallActivitySignalingMessageReceiver signalingMessageReceiver = new CallActivitySignalingMessageReceiver(); + private Map callParticipantMessageListeners = + new HashMap<>(); + private SignalingMessageReceiver.OfferMessageListener offerMessageListener = new SignalingMessageReceiver.OfferMessageListener() { @Override public void onOffer(String sessionId, String roomType, String sdp, String nick) { @@ -1675,14 +1678,6 @@ public class CallActivity extends CallBaseActivity { private void processMessage(NCSignalingMessage ncSignalingMessage) { if ("video".equals(ncSignalingMessage.getRoomType()) || "screen".equals(ncSignalingMessage.getRoomType())) { - String type = ncSignalingMessage.getType(); - - if ("unshareScreen".equals(type)) { - endPeerConnection(ncSignalingMessage.getFrom(), true); - - return; - } - signalingMessageReceiver.process(ncSignalingMessage); } else { Log.e(TAG, "unexpected RoomType while processing NCSignalingMessage"); @@ -2028,6 +2023,15 @@ public class CallActivity extends CallBaseActivity { peerConnectionWrapperList.add(peerConnectionWrapper); + // Currently there is no separation between call participants and peer connections, so any video peer + // connection (except the own publisher connection) is treated as a call participant. + if (!publisher && "video".equals(type)) { + SignalingMessageReceiver.CallParticipantMessageListener callParticipantMessageListener = + new CallActivityCallParticipantMessageListener(sessionId); + callParticipantMessageListeners.put(sessionId, callParticipantMessageListener); + signalingMessageReceiver.addListener(callParticipantMessageListener, sessionId); + } + if (publisher) { startSendingNick(); } @@ -2060,6 +2064,11 @@ public class CallActivity extends CallBaseActivity { } } } + + if (!justScreen) { + SignalingMessageReceiver.CallParticipantMessageListener listener = callParticipantMessageListeners.remove(sessionId); + signalingMessageReceiver.removeListener(listener); + } } private void removeMediaStream(String sessionId, String videoStreamType) { @@ -2642,6 +2651,20 @@ public class CallActivity extends CallBaseActivity { } } + private class CallActivityCallParticipantMessageListener implements SignalingMessageReceiver.CallParticipantMessageListener { + + private final String sessionId; + + public CallActivityCallParticipantMessageListener(String sessionId) { + this.sessionId = sessionId; + } + + @Override + public void onUnshareScreen() { + endPeerConnection(sessionId, true); + } + } + private class MicrophoneButtonTouchListener implements View.OnTouchListener { @SuppressLint("ClickableViewAccessibility") diff --git a/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java new file mode 100644 index 000000000..f06e72629 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java @@ -0,0 +1,95 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2022 Daniel Calviño Sánchez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.nextcloud.talk.signaling; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Helper class to register and notify CallParticipantMessageListeners. + * + * This class is only meant for internal use by SignalingMessageReceiver; listeners must register themselves against + * a SignalingMessageReceiver rather than against a CallParticipantMessageNotifier. + */ +class CallParticipantMessageNotifier { + + /** + * Helper class to associate a CallParticipantMessageListener with a session ID. + */ + private static class CallParticipantMessageListenerFrom { + public final SignalingMessageReceiver.CallParticipantMessageListener listener; + public final String sessionId; + + private CallParticipantMessageListenerFrom(SignalingMessageReceiver.CallParticipantMessageListener listener, + String sessionId) { + this.listener = listener; + this.sessionId = sessionId; + } + } + + private final List callParticipantMessageListenersFrom = new ArrayList<>(); + + public synchronized void addListener(SignalingMessageReceiver.CallParticipantMessageListener listener, String sessionId) { + if (listener == null) { + throw new IllegalArgumentException("CallParticipantMessageListener can not be null"); + } + + if (sessionId == null) { + throw new IllegalArgumentException("sessionId can not be null"); + } + + removeListener(listener); + + callParticipantMessageListenersFrom.add(new CallParticipantMessageListenerFrom(listener, sessionId)); + } + + public synchronized void removeListener(SignalingMessageReceiver.CallParticipantMessageListener listener) { + Iterator it = callParticipantMessageListenersFrom.iterator(); + while (it.hasNext()) { + CallParticipantMessageListenerFrom listenerFrom = it.next(); + + if (listenerFrom.listener == listener) { + it.remove(); + + return; + } + } + } + + private List getListenersFor(String sessionId) { + List callParticipantMessageListeners = + new ArrayList<>(callParticipantMessageListenersFrom.size()); + + for (CallParticipantMessageListenerFrom listenerFrom : callParticipantMessageListenersFrom) { + if (listenerFrom.sessionId.equals(sessionId)) { + callParticipantMessageListeners.add(listenerFrom.listener); + } + } + + return callParticipantMessageListeners; + } + + public synchronized void notifyUnshareScreen(String sessionId) { + for (SignalingMessageReceiver.CallParticipantMessageListener listener : getListenersFor(sessionId)) { + listener.onUnshareScreen(); + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java index 439448ee2..6d1a90d45 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java @@ -44,6 +44,19 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; */ public abstract class SignalingMessageReceiver { + /** + * Listener for call participant messages. + * + * The messages are bound to a specific call participant (or, rather, session), so each listener is expected to + * handle messages only for a single call participant. + * + * Although "unshareScreen" is technically bound to a specific peer connection it is instead treated as a general + * message on the call participant. + */ + public interface CallParticipantMessageListener { + void onUnshareScreen(); + } + /** * Listener for WebRTC offers. * @@ -70,10 +83,29 @@ public abstract class SignalingMessageReceiver { void onEndOfCandidates(); } + private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier(); + private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier(); private final WebRtcMessageNotifier webRtcMessageNotifier = new WebRtcMessageNotifier(); + /** + * Adds a listener for call participant messages. + * + * A listener is expected to be added only once. If the same listener is added again it will no longer be notified + * for the messages from the previous session ID. + * + * @param listener the CallParticipantMessageListener + * @param sessionId the ID of the session that messages come from + */ + public void addListener(CallParticipantMessageListener listener, String sessionId) { + callParticipantMessageNotifier.addListener(listener, sessionId); + } + + public void removeListener(CallParticipantMessageListener listener) { + callParticipantMessageNotifier.removeListener(listener); + } + /** * Adds a listener for all offer messages. * @@ -116,6 +148,43 @@ public abstract class SignalingMessageReceiver { String sessionId = signalingMessage.getFrom(); String roomType = signalingMessage.getRoomType(); + // "unshareScreen" messages are directly sent to the screen peer connection when the internal signaling + // server is used, and to the room when the external signaling server is used. However, the (relevant) data + // of the received message ("from" and "type") is the same in both cases. + if ("unshareScreen".equals(type)) { + // Message schema (external signaling server): + // { + // "type": "message", + // "message": { + // "sender": { + // ... + // }, + // "data": { + // "roomType": "screen", + // "type": "unshareScreen", + // "from": #STRING#, + // }, + // }, + // } + // + // Message schema (internal signaling server): + // { + // "type": "message", + // "data": { + // "to": #STRING#, + // "sid": #STRING#, + // "broadcaster": #STRING#, + // "roomType": "screen", + // "type": "unshareScreen", + // "from": #STRING#, + // }, + // } + + callParticipantMessageNotifier.notifyUnshareScreen(sessionId); + + return; + } + if ("offer".equals(type)) { // Message schema (external signaling server): // { diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java new file mode 100644 index 000000000..01963682e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java @@ -0,0 +1,233 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2022 Daniel Calviño Sánchez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.nextcloud.talk.signaling; + +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class SignalingMessageReceiverCallParticipantTest { + + private SignalingMessageReceiver signalingMessageReceiver; + + @Before + public void setUp() { + // SignalingMessageReceiver is abstract to prevent direct instantiation without calling the appropriate + // protected methods. + signalingMessageReceiver = new SignalingMessageReceiver() { + }; + } + + @Test + public void testAddCallParticipantMessageListenerWithNullListener() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + signalingMessageReceiver.addListener(null, "theSessionId"); + }); + } + + @Test + public void testAddCallParticipantMessageListenerWithNullSessionId() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, null); + }); + } + + @Test + public void testCallParticipantMessageUnshareScreen() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener, only()).onUnshareScreen(); + } + + @Test + public void testCallParticipantMessageSeveralListenersSameFrom() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener1 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener2 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener1, "theSessionId"); + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener2, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener1, only()).onUnshareScreen(); + verify(mockedCallParticipantMessageListener2, only()).onUnshareScreen(); + } + + @Test + public void testCallParticipantMessageNotMatchingSessionId() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("notMatchingSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verifyNoInteractions(mockedCallParticipantMessageListener); + } + + @Test + public void testCallParticipantMessageAfterRemovingListener() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId"); + signalingMessageReceiver.removeListener(mockedCallParticipantMessageListener); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verifyNoInteractions(mockedCallParticipantMessageListener); + } + + @Test + public void testCallParticipantMessageAfterRemovingSingleListenerOfSeveral() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener1 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener2 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener3 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener1, "theSessionId"); + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener2, "theSessionId"); + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener3, "theSessionId"); + signalingMessageReceiver.removeListener(mockedCallParticipantMessageListener2); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener1, only()).onUnshareScreen(); + verify(mockedCallParticipantMessageListener3, only()).onUnshareScreen(); + verifyNoInteractions(mockedCallParticipantMessageListener2); + } + + @Test + public void testCallParticipantMessageAfterAddingListenerAgainForDifferentFrom() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId"); + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId2"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verifyNoInteractions(mockedCallParticipantMessageListener); + + signalingMessage.setFrom("theSessionId2"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener, only()).onUnshareScreen(); + } + + @Test + public void testAddCallParticipantMessageListenerWhenHandlingCallParticipantMessage() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener1 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener2 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener2, "theSessionId"); + return null; + }).when(mockedCallParticipantMessageListener1).onUnshareScreen(); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener1, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener1, only()).onUnshareScreen(); + verifyNoInteractions(mockedCallParticipantMessageListener2); + } + + @Test + public void testRemoveCallParticipantMessageListenerWhenHandlingCallParticipantMessage() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener1 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener2 = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.removeListener(mockedCallParticipantMessageListener2); + return null; + }).when(mockedCallParticipantMessageListener1).onUnshareScreen(); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener1, "theSessionId"); + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener2, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("unshareScreen"); + signalingMessage.setRoomType("theRoomType"); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + InOrder inOrder = inOrder(mockedCallParticipantMessageListener1, mockedCallParticipantMessageListener2); + + inOrder.verify(mockedCallParticipantMessageListener1).onUnshareScreen(); + inOrder.verify(mockedCallParticipantMessageListener2).onUnshareScreen(); + } +}