Эх сурвалжийг харах

Add listener for participant list messages

For now only the same participant list messages that were already
handled are taken into account, but at a later point further messages,
like participants joining or leaving the conversation, could be added
too.

Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
Daniel Calviño Sánchez 2 жил өмнө
parent
commit
e0c676bb35

+ 68 - 0
app/src/main/java/com/nextcloud/talk/signaling/ParticipantListMessageNotifier.java

@@ -0,0 +1,68 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Daniel Calviño Sánchez
+ * Copyright (C) 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.signaling;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Helper class to register and notify ParticipantListMessageListeners.
+ *
+ * This class is only meant for internal use by SignalingMessageReceiver; listeners must register themselves against
+ * a SignalingMessageReceiver rather than against a ParticipantListMessageNotifier.
+ */
+class ParticipantListMessageNotifier {
+
+    private final Set<SignalingMessageReceiver.ParticipantListMessageListener> participantListMessageListeners = new LinkedHashSet<>();
+
+    public synchronized void addListener(SignalingMessageReceiver.ParticipantListMessageListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("participantListMessageListeners can not be null");
+        }
+
+        participantListMessageListeners.add(listener);
+    }
+
+    public synchronized void removeListener(SignalingMessageReceiver.ParticipantListMessageListener listener) {
+        participantListMessageListeners.remove(listener);
+    }
+
+    public synchronized void notifyUsersInRoom(List<Participant> participants) {
+        for (SignalingMessageReceiver.ParticipantListMessageListener listener : new ArrayList<>(participantListMessageListeners)) {
+            listener.onUsersInRoom(participants);
+        }
+    }
+
+    public synchronized void notifyParticipantsUpdate(List<Participant> participants) {
+        for (SignalingMessageReceiver.ParticipantListMessageListener listener : new ArrayList<>(participantListMessageListeners)) {
+            listener.onParticipantsUpdate(participants);
+        }
+    }
+
+    public synchronized void notifyAllParticipantsUpdate(long inCall) {
+        for (SignalingMessageReceiver.ParticipantListMessageListener listener : new ArrayList<>(participantListMessageListeners)) {
+            listener.onAllParticipantsUpdate(inCall);
+        }
+    }
+}

+ 267 - 0
app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java

@@ -19,10 +19,16 @@
  */
 package com.nextcloud.talk.signaling;
 
+import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter;
+import com.nextcloud.talk.models.json.participants.Participant;
 import com.nextcloud.talk.models.json.signaling.NCIceCandidate;
 import com.nextcloud.talk.models.json.signaling.NCMessagePayload;
 import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
 /**
  * Hub to register listeners for signaling messages of different kinds.
  *
@@ -44,6 +50,74 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
  */
 public abstract class SignalingMessageReceiver {
 
+    /**
+     * Listener for participant list messages.
+     *
+     * The messages are implicitly bound to the room currently joined in the signaling server; listeners are expected
+     * to know the current room.
+     */
+    public interface ParticipantListMessageListener {
+
+        /**
+         * List of all the participants in the room.
+         *
+         * This message is received only when the internal signaling server is used.
+         *
+         * The message is received periodically, and the participants may not have been modified since the last message.
+         *
+         * Only the following participant properties are set:
+         * - inCall
+         * - lastPing
+         * - sessionId
+         * - userId (if the participant is not a guest)
+         *
+         * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the
+         * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is
+         * ignored.
+         *
+         * @param participants all the participants (users and guests) in the room
+         */
+        void onUsersInRoom(List<Participant> participants);
+
+        /**
+         * List of all the participants in the call or the room (depending on what triggered the event).
+         *
+         * This message is received only when the external signaling server is used.
+         *
+         * The message is received when any participant changed, although what changed is not provided and should be
+         * derived from the difference with previous messages. The list of participants may include only the
+         * participants in the call (including those that just left it and thus triggered the event) or all the
+         * participants currently in the room (participants in the room but not currently active, that is, without a
+         * session, are not included).
+         *
+         * Only the following participant properties are set:
+         * - inCall
+         * - lastPing
+         * - sessionId
+         * - type
+         * - userId (if the participant is not a guest)
+         *
+         * "nextcloudSessionId" is provided in the message (when the "inCall" property of any participant changed), but
+         * not currently set in the participant.
+         *
+         * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the
+         * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is
+         * ignored.
+         *
+         * @param participants all the participants (users and guests) in the room
+         */
+        void onParticipantsUpdate(List<Participant> participants);
+
+        /**
+         * Update of the properties of all the participants in the room.
+         *
+         * This message is received only when the external signaling server is used.
+         *
+         * @param inCall the new value of the inCall property
+         */
+        void onAllParticipantsUpdate(long inCall);
+    }
+
     /**
      * Listener for call participant messages.
      *
@@ -83,12 +157,29 @@ public abstract class SignalingMessageReceiver {
         void onEndOfCandidates();
     }
 
+    private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier();
+
     private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier();
 
     private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier();
 
     private final WebRtcMessageNotifier webRtcMessageNotifier = new WebRtcMessageNotifier();
 
+    /**
+     * Adds a listener for participant list 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 ParticipantListMessageListener
+     */
+    public void addListener(ParticipantListMessageListener listener) {
+        participantListMessageNotifier.addListener(listener);
+    }
+
+    public void removeListener(ParticipantListMessageListener listener) {
+        participantListMessageNotifier.removeListener(listener);
+    }
+
     /**
      * Adds a listener for call participant messages.
      *
@@ -139,6 +230,182 @@ public abstract class SignalingMessageReceiver {
         webRtcMessageNotifier.removeListener(listener);
     }
 
+    protected void processEvent(Map<String, Object> eventMap) {
+        if (!"update".equals(eventMap.get("type")) || !"participants".equals(eventMap.get("target"))) {
+            return;
+        }
+
+        Map<String, Object> updateMap;
+        try {
+            updateMap = (Map<String, Object>) eventMap.get("update");
+        } catch (RuntimeException e) {
+            // Broken message, this should not happen.
+            return;
+        }
+
+        if (updateMap == null) {
+            // Broken message, this should not happen.
+            return;
+        }
+
+        if (updateMap.get("all") != null && Boolean.parseBoolean(updateMap.get("all").toString())) {
+            processAllParticipantsUpdate(updateMap);
+
+            return;
+        }
+
+        if (updateMap.get("users") != null) {
+            processParticipantsUpdate(updateMap);
+
+            return;
+        }
+    }
+
+    private void processAllParticipantsUpdate(Map<String, Object> updateMap) {
+        // Message schema:
+        // {
+        //     "type": "event",
+        //     "event": {
+        //         "target": "participants",
+        //         "type": "update",
+        //         "update": {
+        //             "roomid": #STRING#,
+        //             "incall": 0,
+        //             "all": true,
+        //         },
+        //     },
+        // }
+
+        long inCall;
+        try {
+            inCall = Long.parseLong(updateMap.get("inCall").toString());
+        } catch (RuntimeException e) {
+            // Broken message, this should not happen.
+            return;
+        }
+
+        participantListMessageNotifier.notifyAllParticipantsUpdate(inCall);
+    }
+
+    private void processParticipantsUpdate(Map<String, Object> updateMap) {
+        // Message schema:
+        // {
+        //     "type": "event",
+        //     "event": {
+        //         "target": "participants",
+        //         "type": "update",
+        //         "update": {
+        //             "roomid": #INTEGER#,
+        //             "users": [
+        //                 {
+        //                     "inCall": #INTEGER#,
+        //                     "lastPing": #INTEGER#,
+        //                     "sessionId": #STRING#,
+        //                     "participantType": #INTEGER#,
+        //                     "userId": #STRING#, // Optional
+        //                     "nextcloudSessionId": #STRING#, // Optional
+        //                     "participantPermissions": #INTEGER#, // Talk >= 13
+        //                 },
+        //                 ...
+        //             ],
+        //         },
+        //     },
+        // }
+        //
+        // Note that "userId" in participants->update comes from the Nextcloud server, so it is "userId"; in other
+        // messages, like room->join, it comes directly from the external signaling server, so it is "userid" instead.
+
+        List<Map<String, Object>> users;
+        try {
+            users = (List<Map<String, Object>>) updateMap.get("users");
+        } catch (RuntimeException e) {
+            // Broken message, this should not happen.
+            return;
+        }
+
+        if (users == null) {
+            // Broken message, this should not happen.
+            return;
+        }
+
+        List<Participant> participants = new ArrayList<>(users.size());
+
+        for (Map<String, Object> user: users) {
+            try {
+                participants.add(getParticipantFromMessageMap(user));
+            } catch (RuntimeException e) {
+                // Broken message, this should not happen.
+                return;
+            }
+        }
+
+        participantListMessageNotifier.notifyParticipantsUpdate(participants);
+    }
+
+    protected void processUsersInRoom(List<Map<String, Object>> users) {
+        // Message schema:
+        // {
+        //     "type": "usersInRoom",
+        //     "data": [
+        //         {
+        //             "inCall": #INTEGER#,
+        //             "lastPing": #INTEGER#,
+        //             "roomId": #INTEGER#,
+        //             "sessionId": #STRING#,
+        //             "userId": #STRING#, // Always included, although it can be empty
+        //             "participantPermissions": #INTEGER#, // Talk >= 13
+        //         },
+        //         ...
+        //     ],
+        // }
+
+        List<Participant> participants = new ArrayList<>(users.size());
+
+        for (Map<String, Object> user: users) {
+            try {
+                participants.add(getParticipantFromMessageMap(user));
+            } catch (RuntimeException e) {
+                // Broken message, this should not happen.
+                return;
+            }
+        }
+
+        participantListMessageNotifier.notifyUsersInRoom(participants);
+    }
+
+    /**
+     * Creates and initializes a Participant from the data in the given map.
+     *
+     * Maps from internal and external signaling server messages can be used. Nevertheless, besides the differences
+     * between the messages and the optional properties, it is expected that the message is correct and the given data
+     * is parseable. Broken messages (for example, a string instead of an integer for "inCall" or a missing
+     * "sessionId") may cause a RuntimeException to be thrown.
+     *
+     * @param participantMap the map with the participant data
+     * @return the Participant
+     */
+    private Participant getParticipantFromMessageMap(Map<String, Object> participantMap) {
+        Participant participant = new Participant();
+
+        participant.setInCall(Long.parseLong(participantMap.get("inCall").toString()));
+        participant.setLastPing(Long.parseLong(participantMap.get("lastPing").toString()));
+        participant.setSessionId(participantMap.get("sessionId").toString());
+
+        if (participantMap.get("userId") != null && !participantMap.get("userId").toString().isEmpty()) {
+            participant.setUserId(participantMap.get("userId").toString());
+        }
+
+        // Only in external signaling messages
+        if (participantMap.get("participantType") != null) {
+            int participantTypeInt = Integer.parseInt(participantMap.get("participantType").toString());
+
+            EnumParticipantTypeConverter converter = new EnumParticipantTypeConverter();
+            participant.setType(converter.getFromInt(participantTypeInt));
+        }
+
+        return participant;
+    }
+
     protected void processSignalingMessage(NCSignalingMessage signalingMessage) {
         // Note that in the internal signaling server message "data" is the String representation of a JSON
         // object, although it is already decoded when used here.

+ 1 - 1
app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverOfferTest.java

@@ -49,7 +49,7 @@ public class SignalingMessageReceiverOfferTest {
     @Test
     public void testAddOfferMessageListenerWithNullListener() {
         Assert.assertThrows(IllegalArgumentException.class, () -> {
-            signalingMessageReceiver.addListener(null);
+            signalingMessageReceiver.addListener((SignalingMessageReceiver.OfferMessageListener) null);
         });
     }
 

+ 466 - 0
app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverParticipantListTest.java

@@ -0,0 +1,466 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Daniel Calviño Sánchez
+ * Copyright (C) 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.signaling;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InOrder;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+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 SignalingMessageReceiverParticipantListTest {
+
+    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 testAddParticipantListMessageListenerWithNullListener() {
+        Assert.assertThrows(IllegalArgumentException.class, () -> {
+            signalingMessageReceiver.addListener((SignalingMessageReceiver.ParticipantListMessageListener) null);
+        });
+    }
+
+    @Test
+    public void testInternalSignalingParticipantListMessageUsersInRoom() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+
+        List<Map<String, Object>> users = new ArrayList<>(2);
+        Map<String, Object> user1 = new HashMap<>();
+        user1.put("inCall", 7);
+        user1.put("lastPing", 4815);
+        user1.put("roomId", 108);
+        user1.put("sessionId", "theSessionId1");
+        user1.put("userId", "theUserId");
+        // If "participantPermissions" is set in any of the participants all the other participants in the message
+        // would have it too. But for test simplicity, and as it is not relevant for the processing, in this test it
+        // is included only in one of the participants.
+        user1.put("participantPermissions", 42);
+        users.add(user1);
+        Map<String, Object> user2 = new HashMap<>();
+        user2.put("inCall", 0);
+        user2.put("lastPing", 162342);
+        user2.put("roomId", 108);
+        user2.put("sessionId", "theSessionId2");
+        user2.put("userId", "");
+        users.add(user2);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        List<Participant> expectedParticipantList = new ArrayList<>();
+        Participant expectedParticipant1 = new Participant();
+        expectedParticipant1.setInCall(Participant.InCallFlags.IN_CALL | Participant.InCallFlags.WITH_AUDIO | Participant.InCallFlags.WITH_VIDEO);
+        expectedParticipant1.setLastPing(4815);
+        expectedParticipant1.setSessionId("theSessionId1");
+        expectedParticipant1.setUserId("theUserId");
+        expectedParticipantList.add(expectedParticipant1);
+
+        Participant expectedParticipant2 = new Participant();
+        expectedParticipant2.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant2.setLastPing(162342);
+        expectedParticipant2.setSessionId("theSessionId2");
+        expectedParticipantList.add(expectedParticipant2);
+
+        verify(mockedParticipantListMessageListener, only()).onUsersInRoom(expectedParticipantList);
+    }
+
+    @Test
+    public void testInternalSignalingParticipantListMessageAfterRemovingListener() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+        signalingMessageReceiver.removeListener(mockedParticipantListMessageListener);
+
+        List<Map<String, Object>> users = new ArrayList<>(1);
+        Map<String, Object> user = new HashMap<>();
+        user.put("inCall", 0);
+        user.put("lastPing", 4815);
+        user.put("roomId", 108);
+        user.put("sessionId", "theSessionId");
+        user.put("userId", "");
+        users.add(user);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        verifyNoInteractions(mockedParticipantListMessageListener);
+    }
+
+    @Test
+    public void testInternalSignalingParticipantListMessageAfterRemovingSingleListenerOfSeveral() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener3 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener3);
+        signalingMessageReceiver.removeListener(mockedParticipantListMessageListener2);
+
+        List<Map<String, Object>> users = new ArrayList<>(1);
+        Map<String, Object> user = new HashMap<>();
+        user.put("inCall", 0);
+        user.put("lastPing", 4815);
+        user.put("roomId", 108);
+        user.put("sessionId", "theSessionId");
+        user.put("userId", "");
+        users.add(user);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        List<Participant> expectedParticipantList = new ArrayList<>();
+        Participant expectedParticipant = new Participant();
+        expectedParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant.setLastPing(4815);
+        expectedParticipant.setSessionId("theSessionId");
+        expectedParticipantList.add(expectedParticipant);
+
+        verify(mockedParticipantListMessageListener1, only()).onUsersInRoom(expectedParticipantList);
+        verify(mockedParticipantListMessageListener3, only()).onUsersInRoom(expectedParticipantList);
+        verifyNoInteractions(mockedParticipantListMessageListener2);
+    }
+
+    @Test
+    public void testInternalSignalingParticipantListMessageAfterAddingListenerAgain() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+
+        List<Map<String, Object>> users = new ArrayList<>(1);
+        Map<String, Object> user = new HashMap<>();
+        user.put("inCall", 0);
+        user.put("lastPing", 4815);
+        user.put("roomId", 108);
+        user.put("sessionId", "theSessionId");
+        user.put("userId", "");
+        users.add(user);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        List<Participant> expectedParticipantList = new ArrayList<>();
+        Participant expectedParticipant = new Participant();
+        expectedParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant.setLastPing(4815);
+        expectedParticipant.setSessionId("theSessionId");
+        expectedParticipantList.add(expectedParticipant);
+
+        verify(mockedParticipantListMessageListener, only()).onUsersInRoom(expectedParticipantList);
+    }
+
+    @Test
+    public void testAddParticipantListMessageListenerWhenHandlingInternalSignalingParticipantListMessage() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        List<Participant> expectedParticipantList = new ArrayList<>();
+        Participant expectedParticipant = new Participant();
+        expectedParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant.setLastPing(4815);
+        expectedParticipant.setSessionId("theSessionId");
+        expectedParticipantList.add(expectedParticipant);
+
+        doAnswer((invocation) -> {
+            signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+            return null;
+        }).when(mockedParticipantListMessageListener1).onUsersInRoom(expectedParticipantList);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+
+        List<Map<String, Object>> users = new ArrayList<>(1);
+        Map<String, Object> user = new HashMap<>();
+        user.put("inCall", 0);
+        user.put("lastPing", 4815);
+        user.put("roomId", 108);
+        user.put("sessionId", "theSessionId");
+        user.put("userId", "");
+        users.add(user);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        verify(mockedParticipantListMessageListener1, only()).onUsersInRoom(expectedParticipantList);
+        verifyNoInteractions(mockedParticipantListMessageListener2);
+    }
+
+    @Test
+    public void testRemoveParticipantListMessageListenerWhenHandlingInternalSignalingParticipantListMessage() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        List<Participant> expectedParticipantList = new ArrayList<>();
+        Participant expectedParticipant = new Participant();
+        expectedParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant.setLastPing(4815);
+        expectedParticipant.setSessionId("theSessionId");
+        expectedParticipantList.add(expectedParticipant);
+
+        doAnswer((invocation) -> {
+            signalingMessageReceiver.removeListener(mockedParticipantListMessageListener2);
+            return null;
+        }).when(mockedParticipantListMessageListener1).onUsersInRoom(expectedParticipantList);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+
+        List<Map<String, Object>> users = new ArrayList<>(1);
+        Map<String, Object> user = new HashMap<>();
+        user.put("inCall", 0);
+        user.put("lastPing", 4815);
+        user.put("roomId", 108);
+        user.put("sessionId", "theSessionId");
+        user.put("userId", "");
+        users.add(user);
+        signalingMessageReceiver.processUsersInRoom(users);
+
+        InOrder inOrder = inOrder(mockedParticipantListMessageListener1, mockedParticipantListMessageListener2);
+
+        inOrder.verify(mockedParticipantListMessageListener1).onUsersInRoom(expectedParticipantList);
+        inOrder.verify(mockedParticipantListMessageListener2).onUsersInRoom(expectedParticipantList);
+    }
+
+    @Test
+    public void testExternalSignalingParticipantListMessageParticipantsUpdate() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        List<Map<String, Object>> users = new ArrayList<>(2);
+        Map<String, Object> user1 = new HashMap<>();
+        user1.put("inCall", 7);
+        user1.put("lastPing", 4815);
+        user1.put("sessionId", "theSessionId1");
+        user1.put("participantType", 3);
+        user1.put("userId", "theUserId");
+        // If "nextcloudSessionId" or "participantPermissions" is set in any of the participants all the other
+        // participants in the message would have them too. But for test simplicity, and as it is not relevant for
+        // the processing, in this test they are included only in one of the participants.
+        user1.put("nextcloudSessionId", "theNextcloudSessionId");
+        user1.put("participantPermissions", 42);
+        users.add(user1);
+        Map<String, Object> user2 = new HashMap<>();
+        user2.put("inCall", 0);
+        user2.put("lastPing", 162342);
+        user2.put("sessionId", "theSessionId2");
+        user2.put("participantType", 4);
+        users.add(user2);
+        updateMap.put("users", users);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        List<Participant> expectedParticipantList = new ArrayList<>(2);
+        Participant expectedParticipant1 = new Participant();
+        expectedParticipant1.setInCall(Participant.InCallFlags.IN_CALL | Participant.InCallFlags.WITH_AUDIO | Participant.InCallFlags.WITH_VIDEO);
+        expectedParticipant1.setLastPing(4815);
+        expectedParticipant1.setSessionId("theSessionId1");
+        expectedParticipant1.setType(Participant.ParticipantType.USER);
+        expectedParticipant1.setUserId("theUserId");
+        expectedParticipantList.add(expectedParticipant1);
+
+        Participant expectedParticipant2 = new Participant();
+        expectedParticipant2.setInCall(Participant.InCallFlags.DISCONNECTED);
+        expectedParticipant2.setLastPing(162342);
+        expectedParticipant2.setSessionId("theSessionId2");
+        expectedParticipant2.setType(Participant.ParticipantType.GUEST);
+        expectedParticipantList.add(expectedParticipant2);
+
+        verify(mockedParticipantListMessageListener, only()).onParticipantsUpdate(expectedParticipantList);
+    }
+
+    @Test
+    public void testExternalSignalingParticipantListMessageAllParticipantsUpdate() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        verify(mockedParticipantListMessageListener, only()).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+    }
+
+    @Test
+    public void testExternalSignalingParticipantListMessageAfterRemovingListener() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+        signalingMessageReceiver.removeListener(mockedParticipantListMessageListener);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        HashMap<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        verifyNoInteractions(mockedParticipantListMessageListener);
+    }
+
+    @Test
+    public void testExternalSignalingParticipantListMessageAfterRemovingSingleListenerOfSeveral() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener3 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener3);
+        signalingMessageReceiver.removeListener(mockedParticipantListMessageListener2);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        HashMap<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        verify(mockedParticipantListMessageListener1, only()).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+        verify(mockedParticipantListMessageListener3, only()).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+        verifyNoInteractions(mockedParticipantListMessageListener2);
+    }
+
+    @Test
+    public void testExternalSignalingParticipantListMessageAfterAddingListenerAgain() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        HashMap<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        verify(mockedParticipantListMessageListener, only()).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+    }
+
+    @Test
+    public void testAddParticipantListMessageListenerWhenHandlingExternalSignalingParticipantListMessage() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        doAnswer((invocation) -> {
+            signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+            return null;
+        }).when(mockedParticipantListMessageListener1).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        HashMap<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        verify(mockedParticipantListMessageListener1, only()).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+        verifyNoInteractions(mockedParticipantListMessageListener2);
+    }
+
+    @Test
+    public void testRemoveParticipantListMessageListenerWhenHandlingExternalSignalingParticipantListMessage() {
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener1 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+        SignalingMessageReceiver.ParticipantListMessageListener mockedParticipantListMessageListener2 =
+            mock(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        doAnswer((invocation) -> {
+            signalingMessageReceiver.removeListener(mockedParticipantListMessageListener2);
+            return null;
+        }).when(mockedParticipantListMessageListener1).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener1);
+        signalingMessageReceiver.addListener(mockedParticipantListMessageListener2);
+
+        Map<String, Object> eventMap = new HashMap<>();
+        eventMap.put("type", "update");
+        eventMap.put("target", "participants");
+        HashMap<String, Object> updateMap = new HashMap<>();
+        updateMap.put("roomId", 108);
+        updateMap.put("all", true);
+        updateMap.put("inCall", 0);
+        eventMap.put("update", updateMap);
+        signalingMessageReceiver.processEvent(eventMap);
+
+        InOrder inOrder = inOrder(mockedParticipantListMessageListener1, mockedParticipantListMessageListener2);
+
+        inOrder.verify(mockedParticipantListMessageListener1).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+        inOrder.verify(mockedParticipantListMessageListener2).onAllParticipantsUpdate(Participant.InCallFlags.DISCONNECTED);
+    }
+}