diff --git a/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java new file mode 100644 index 000000000..b22ed5195 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java @@ -0,0 +1,53 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2023 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.LinkedHashSet; +import java.util.Set; + +/** + * Helper class to register and notify LocalParticipantMessageListeners. + * + * This class is only meant for internal use by SignalingMessageReceiver; listeners must register themselves against + * a SignalingMessageReceiver rather than against a LocalParticipantMessageNotifier. + */ +class LocalParticipantMessageNotifier { + + private final Set localParticipantMessageListeners = new LinkedHashSet<>(); + + public synchronized void addListener(SignalingMessageReceiver.LocalParticipantMessageListener listener) { + if (listener == null) { + throw new IllegalArgumentException("localParticipantMessageListener can not be null"); + } + + localParticipantMessageListeners.add(listener); + } + + public synchronized void removeListener(SignalingMessageReceiver.LocalParticipantMessageListener listener) { + localParticipantMessageListeners.remove(listener); + } + + public synchronized void notifySwitchTo(String token) { + for (SignalingMessageReceiver.LocalParticipantMessageListener listener : new ArrayList<>(localParticipantMessageListeners)) { + listener.onSwitchTo(token); + } + } +} 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 fb2aaf9da..a8e201817 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java @@ -118,6 +118,26 @@ public abstract class SignalingMessageReceiver { void onAllParticipantsUpdate(long inCall); } + /** + * Listener for local participant messages. + * + * The messages are implicitly bound to the local participant (or, rather, its session); listeners are expected + * to know the local participant. + * + * The messages are related to the conversation, so the local participant may or may not be in a call when they + * are received. + */ + public interface LocalParticipantMessageListener { + /** + * Request for the client to switch to the given conversation. + * + * This message is received only when the external signaling server is used. + * + * @param token the token of the conversation to switch to. + */ + void onSwitchTo(String token); + } + /** * Listener for call participant messages. * @@ -160,6 +180,8 @@ public abstract class SignalingMessageReceiver { private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier(); + private final LocalParticipantMessageNotifier localParticipantMessageNotifier = new LocalParticipantMessageNotifier(); + private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier(); private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier(); @@ -181,6 +203,21 @@ public abstract class SignalingMessageReceiver { participantListMessageNotifier.removeListener(listener); } + /** + * Adds a listener for local participant messages. + * + * A listener is expected to be added only once. If the same listener is added again it will be notified just once. + * + * @param listener the LocalParticipantMessageListener + */ + public void addListener(LocalParticipantMessageListener listener) { + localParticipantMessageNotifier.addListener(listener); + } + + public void removeListener(LocalParticipantMessageListener listener) { + localParticipantMessageNotifier.removeListener(listener); + } + /** * Adds a listener for call participant messages. * @@ -232,17 +269,56 @@ public abstract class SignalingMessageReceiver { } protected void processEvent(Map eventMap) { - if (!"participants".equals(eventMap.get("target"))) { + if ("room".equals(eventMap.get("target")) && "switchto".equals(eventMap.get("type"))) { + processSwitchToEvent(eventMap); + return; } - if ("update".equals(eventMap.get("type"))) { + if ("participants".equals(eventMap.get("target")) && "update".equals(eventMap.get("type"))) { processUpdateEvent(eventMap); return; } } + private void processSwitchToEvent(Map eventMap) { + // Message schema: + // { + // "type": "event", + // "event": { + // "target": "room", + // "type": "switchto", + // "switchto": { + // "roomid": #STRING#, + // }, + // }, + // } + + Map switchToMap; + try { + switchToMap = (Map) eventMap.get("switchto"); + } catch (RuntimeException e) { + // Broken message, this should not happen. + return; + } + + if (switchToMap == null) { + // Broken message, this should not happen. + return; + } + + String token; + try { + token = switchToMap.get("roomid").toString(); + } catch (RuntimeException e) { + // Broken message, this should not happen. + return; + } + + localParticipantMessageNotifier.notifySwitchTo(token); + } + private void processUpdateEvent(Map eventMap) { Map updateMap; try { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 46076f29d..4a3d630db 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -201,12 +201,14 @@ class WebSocketInstance internal constructor( val target = eventOverallWebSocketMessage.eventMap!!["target"] as String? if (target != null) { when (target) { - Globals.TARGET_ROOM -> + Globals.TARGET_ROOM -> { if ("message" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomMessageMessage(eventOverallWebSocketMessage) } else if ("join" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomJoinMessage(eventOverallWebSocketMessage) } + signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) + } Globals.TARGET_PARTICIPANTS -> signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) else -> diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java new file mode 100644 index 000000000..4c6acee79 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java @@ -0,0 +1,193 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2023 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 org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; + +import java.util.HashMap; +import java.util.Map; + +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 SignalingMessageReceiverLocalParticipantTest { + + 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 testAddLocalParticipantMessageListenerWithNullListener() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + signalingMessageReceiver.addListener((SignalingMessageReceiver.LocalParticipantMessageListener) null); + }); + } + + @Test + public void testExternalSignalingLocalParticipantMessageSwitchTo() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + Map switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener, only()).onSwitchTo("theToken"); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterRemovingListener() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verifyNoInteractions(mockedLocalParticipantMessageListener); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterRemovingSingleListenerOfSeveral() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener3 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener3); + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener2); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener1, only()).onSwitchTo("theToken"); + verify(mockedLocalParticipantMessageListener3, only()).onSwitchTo("theToken"); + verifyNoInteractions(mockedLocalParticipantMessageListener2); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterAddingListenerAgain() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener, only()).onSwitchTo("theToken"); + } + + @Test + public void testAddLocalParticipantMessageListenerWhenHandlingExternalSignalingLocalParticipantMessage() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + return null; + }).when(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener1, only()).onSwitchTo("theToken"); + verifyNoInteractions(mockedLocalParticipantMessageListener2); + } + + @Test + public void testRemoveLocalParticipantMessageListenerWhenHandlingExternalSignalingLocalParticipantMessage() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener2); + return null; + }).when(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + InOrder inOrder = inOrder(mockedLocalParticipantMessageListener1, mockedLocalParticipantMessageListener2); + + inOrder.verify(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + inOrder.verify(mockedLocalParticipantMessageListener2).onSwitchTo("theToken"); + } +}