Browse Source

Merge pull request #2600 from nextcloud/split-call-participants-and-peer-connections

Split call participants and peer connections
Marcel Hibbe 2 năm trước cách đây
mục cha
commit
a87f2fb102

+ 236 - 330
app/src/main/java/com/nextcloud/talk/activities/CallActivity.java

@@ -60,6 +60,9 @@ import com.nextcloud.talk.adapters.ParticipantDisplayItem;
 import com.nextcloud.talk.adapters.ParticipantsAdapter;
 import com.nextcloud.talk.api.NcApi;
 import com.nextcloud.talk.application.NextcloudTalkApplication;
+import com.nextcloud.talk.call.CallParticipant;
+import com.nextcloud.talk.call.CallParticipantList;
+import com.nextcloud.talk.call.CallParticipantModel;
 import com.nextcloud.talk.data.user.model.User;
 import com.nextcloud.talk.databinding.CallActivityBinding;
 import com.nextcloud.talk.events.ConfigurationChangeEvent;
@@ -123,8 +126,8 @@ import org.webrtc.VideoTrack;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -231,7 +234,6 @@ public class CallActivity extends CallBaseActivity {
     private MediaStream localStream;
     private String credentials;
     private List<PeerConnectionWrapper> peerConnectionWrapperList = new ArrayList<>();
-    private Map<String, String> userIdsBySessionId = new HashMap<>();
 
     private boolean videoOn = false;
     private boolean microphoneOn = false;
@@ -263,31 +265,30 @@ public class CallActivity extends CallBaseActivity {
     private Map<String, SignalingMessageReceiver.CallParticipantMessageListener> callParticipantMessageListeners =
         new HashMap<>();
 
-    private Map<String, PeerConnectionWrapper.DataChannelMessageListener> dataChannelMessageListeners = new HashMap<>();
+    private PeerConnectionWrapper.PeerConnectionObserver selfPeerConnectionObserver = new CallActivitySelfPeerConnectionObserver();
 
-    private Map<String, PeerConnectionWrapper.PeerConnectionObserver> peerConnectionObservers = new HashMap<>();
+    private Map<String, CallParticipant> callParticipants = new HashMap<>();
 
-    private SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener = new SignalingMessageReceiver.ParticipantListMessageListener() {
+    private Map<String, ScreenParticipantDisplayItemManager> screenParticipantDisplayItemManagers = new HashMap<>();
 
-        @Override
-        public void onUsersInRoom(List<Participant> participants) {
-            processUsersInRoom(participants);
-        }
+    private Handler screenParticipantDisplayItemManagersHandler = new Handler(Looper.getMainLooper());
 
+    private CallParticipantList.Observer callParticipantListObserver = new CallParticipantList.Observer() {
         @Override
-        public void onParticipantsUpdate(List<Participant> participants) {
-            processUsersInRoom(participants);
+        public void onCallParticipantsChanged(Collection<Participant> joined, Collection<Participant> updated,
+                                              Collection<Participant> left, Collection<Participant> unchanged) {
+            handleCallParticipantsChanged(joined, updated, left, unchanged);
         }
 
         @Override
-        public void onAllParticipantsUpdate(long inCall) {
-            if (inCall == Participant.InCallFlags.DISCONNECTED) {
-                Log.d(TAG, "A moderator ended the call for all.");
-                hangup(true);
-            }
+        public void onCallEndedForAll() {
+            Log.d(TAG, "A moderator ended the call for all.");
+            hangup(true);
         }
     };
 
+    private CallParticipantList callParticipantList;
+
     private SignalingMessageReceiver.OfferMessageListener offerMessageListener = new SignalingMessageReceiver.OfferMessageListener() {
         @Override
         public void onOffer(String sessionId, String roomType, String sdp, String nick) {
@@ -382,6 +383,7 @@ public class CallActivity extends CallBaseActivity {
             requestBluetoothPermission();
         }
         basicInitialization();
+        callParticipants = new HashMap<>();
         participantDisplayItems = new HashMap<>();
         initViews();
         if (!isConnectionEstablished()) {
@@ -740,6 +742,10 @@ public class CallActivity extends CallBaseActivity {
                 }
             });
 
+        if (participantsAdapter != null) {
+            participantsAdapter.destroy();
+        }
+
         participantsAdapter = new ParticipantsAdapter(
             this,
             participantDisplayItems,
@@ -1235,7 +1241,6 @@ public class CallActivity extends CallBaseActivity {
 
     @Override
     public void onDestroy() {
-        signalingMessageReceiver.removeListener(participantListMessageListener);
         signalingMessageReceiver.removeListener(offerMessageListener);
 
         if (localStream != null) {
@@ -1369,7 +1374,6 @@ public class CallActivity extends CallBaseActivity {
                         setupAndInitiateWebSocketsConnection();
                     } else {
                         signalingMessageReceiver = internalSignalingMessageReceiver;
-                        signalingMessageReceiver.addListener(participantListMessageListener);
                         signalingMessageReceiver.addListener(offerMessageListener);
                         signalingMessageSender = internalSignalingMessageSender;
                         joinRoomAndCall();
@@ -1459,6 +1463,9 @@ public class CallActivity extends CallBaseActivity {
             inCallFlag += Participant.InCallFlags.WITH_VIDEO;
         }
 
+        callParticipantList = new CallParticipantList(signalingMessageReceiver);
+        callParticipantList.addObserver(callParticipantListObserver);
+
         int apiVersion = ApiUtils.getCallApiVersion(conversationUser, new int[]{ApiUtils.APIv4, 1});
 
         ncApi.joinCall(
@@ -1573,7 +1580,6 @@ public class CallActivity extends CallBaseActivity {
             // Although setupAndInitiateWebSocketsConnection could be called several times the web socket is
             // initialized just once, so the message receiver is also initialized just once.
             signalingMessageReceiver = webSocketClient.getSignalingMessageReceiver();
-            signalingMessageReceiver.addListener(participantListMessageListener);
             signalingMessageReceiver.addListener(offerMessageListener);
             signalingMessageSender = webSocketClient.getSignalingMessageSender();
         } else {
@@ -1715,12 +1721,21 @@ public class CallActivity extends CallBaseActivity {
             }
         }
 
-        List<String> sessionIdsToEnd = new ArrayList<String>(peerConnectionWrapperList.size());
+        List<String> peerConnectionIdsToEnd = new ArrayList<String>(peerConnectionWrapperList.size());
         for (PeerConnectionWrapper wrapper : peerConnectionWrapperList) {
-            sessionIdsToEnd.add(wrapper.getSessionId());
+            peerConnectionIdsToEnd.add(wrapper.getSessionId());
+        }
+        for (String sessionId : peerConnectionIdsToEnd) {
+            endPeerConnection(sessionId, "video");
+            endPeerConnection(sessionId, "screen");
+        }
+
+        List<String> callParticipantIdsToEnd = new ArrayList<String>(peerConnectionWrapperList.size());
+        for (CallParticipant callParticipant : callParticipants.values()) {
+            callParticipantIdsToEnd.add(callParticipant.getCallParticipantModel().getSessionId());
         }
-        for (String sessionId : sessionIdsToEnd) {
-            endPeerConnection(sessionId, false);
+        for (String sessionId : callParticipantIdsToEnd) {
+            removeCallParticipant(sessionId);
         }
 
         hangupNetworkCalls(shutDownView);
@@ -1731,6 +1746,9 @@ public class CallActivity extends CallBaseActivity {
         Log.d(TAG, "hangupNetworkCalls. shutDownView=" + shutDownView);
         int apiVersion = ApiUtils.getCallApiVersion(conversationUser, new int[]{ApiUtils.APIv4, 1});
 
+        callParticipantList.removeObserver(callParticipantListObserver);
+        callParticipantList.destroy();
+
         ncApi.leaveCall(credentials, ApiUtils.getUrlForCall(apiVersion, baseUrl, roomToken))
             .subscribeOn(Schedulers.io())
             .observeOn(AndroidSchedulers.mainThread())
@@ -1768,11 +1786,9 @@ public class CallActivity extends CallBaseActivity {
         }
     }
 
-    private void processUsersInRoom(List<Participant> participants) {
-        Log.d(TAG, "processUsersInRoom");
-        List<String> newSessions = new ArrayList<>();
-        Set<String> oldSessions = new HashSet<>();
-        userIdsBySessionId = new HashMap<>();
+    private void handleCallParticipantsChanged(Collection<Participant> joined, Collection<Participant> updated,
+                                               Collection<Participant> left, Collection<Participant> unchanged) {
+        Log.d(TAG, "handleCallParticipantsChanged");
 
         hasMCU = hasExternalSignalingServer && webSocketClient != null && webSocketClient.hasMCU();
         Log.d(TAG, "   hasMCU is " + hasMCU);
@@ -1785,58 +1801,49 @@ public class CallActivity extends CallBaseActivity {
 
         Log.d(TAG, "   currentSessionId is " + currentSessionId);
 
+        List<Participant> participantsInCall = new ArrayList<>();
+        participantsInCall.addAll(joined);
+        participantsInCall.addAll(updated);
+        participantsInCall.addAll(unchanged);
+
         boolean isSelfInCall = false;
+        Participant selfParticipant = null;
 
-        for (Participant participant : participants) {
+        for (Participant participant : participantsInCall) {
             long inCallFlag = participant.getInCall();
             if (!participant.getSessionId().equals(currentSessionId)) {
                 Log.d(TAG, "   inCallFlag of participant "
                     + participant.getSessionId().substring(0, 4)
                     + " : "
                     + inCallFlag);
-
-                boolean isInCall = inCallFlag != 0;
-                if (isInCall) {
-                    newSessions.add(participant.getSessionId());
-                }
-
-                userIdsBySessionId.put(participant.getSessionId(), participant.getUserId());
             } else {
                 Log.d(TAG, "   inCallFlag of currentSessionId: " + inCallFlag);
                 isSelfInCall = inCallFlag != 0;
-                if (inCallFlag == 0 && currentCallStatus != CallStatus.LEAVING && ApplicationWideCurrentRoomHolder.getInstance().isInCall()) {
-                    Log.d(TAG, "Most probably a moderator ended the call for all.");
-                    hangup(true);
-
-                    return;
-                }
+                selfParticipant = participant;
             }
         }
 
-        for (PeerConnectionWrapper peerConnectionWrapper : peerConnectionWrapperList) {
-            if (!peerConnectionWrapper.isMCUPublisher()) {
-                oldSessions.add(peerConnectionWrapper.getSessionId());
-            }
+        if (!isSelfInCall && currentCallStatus != CallStatus.LEAVING && ApplicationWideCurrentRoomHolder.getInstance().isInCall()) {
+            Log.d(TAG, "Most probably a moderator ended the call for all.");
+            hangup(true);
+
+            return;
         }
 
         if (!isSelfInCall) {
             Log.d(TAG, "Self not in call, disconnecting from all other sessions");
 
-            for (String sessionId : oldSessions) {
-                Log.d(TAG, "   oldSession that will be removed is: " + sessionId);
-                endPeerConnection(sessionId, false);
+            for (Participant participant : participantsInCall) {
+                String sessionId = participant.getSessionId();
+                Log.d(TAG, "   session that will be removed is: " + sessionId);
+                endPeerConnection(sessionId, "video");
+                endPeerConnection(sessionId, "screen");
+                removeCallParticipant(sessionId);
             }
 
             return;
         }
 
-        // Calculate sessions that left the call
-        List<String> disconnectedSessions = new ArrayList<>(oldSessions);
-        disconnectedSessions.removeAll(newSessions);
-
-        // Calculate sessions that join the call
-        newSessions.removeAll(oldSessions);
-
         if (currentCallStatus == CallStatus.LEAVING) {
             return;
         }
@@ -1846,42 +1853,72 @@ public class CallActivity extends CallBaseActivity {
             getOrCreatePeerConnectionWrapperForSessionIdAndType(webSocketClient.getSessionId(), VIDEO_STREAM_TYPE_VIDEO, true);
         }
 
-        for (String sessionId : newSessions) {
+        boolean selfJoined = false;
+        boolean selfParticipantHasAudioOrVideo = participantInCallFlagsHaveAudioOrVideo(selfParticipant);
+
+        for (Participant participant : joined) {
+            String sessionId = participant.getSessionId();
+
+            if (sessionId == null) {
+                Log.w(TAG, "Null sessionId for call participant, this should not happen: " + participant);
+                continue;
+            }
+
+            if (sessionId.equals(currentSessionId)) {
+                selfJoined = true;
+                continue;
+            }
+
             Log.d(TAG, "   newSession joined: " + sessionId);
-            getOrCreatePeerConnectionWrapperForSessionIdAndType(sessionId, VIDEO_STREAM_TYPE_VIDEO, false);
 
-            String userId = userIdsBySessionId.get(sessionId);
+            CallParticipant callParticipant = addCallParticipant(sessionId);
+
+            String userId = participant.getUserId();
             if (userId != null) {
-                runOnUiThread(() -> {
-                    boolean notifyDataSetChanged = false;
-                    if (participantDisplayItems.get(sessionId + "-video") != null) {
-                        participantDisplayItems.get(sessionId + "-video").setUserId(userId);
-                        notifyDataSetChanged = true;
-                    }
-                    if (participantDisplayItems.get(sessionId + "-screen") != null) {
-                        participantDisplayItems.get(sessionId + "-screen").setUserId(userId);
-                        notifyDataSetChanged = true;
-                    }
-                    if (notifyDataSetChanged) {
-                        participantsAdapter.notifyDataSetChanged();
-                    }
-                });
+                callParticipants.get(sessionId).setUserId(userId);
+            }
+
+            String nick;
+            if (hasExternalSignalingServer) {
+                nick = webSocketClient.getDisplayNameForSession(sessionId);
+            } else {
+                nick = offerAnswerNickProviders.get(sessionId) != null ? offerAnswerNickProviders.get(sessionId).getNick() : "";
+            }
+            callParticipants.get(sessionId).setNick(nick);
+
+            boolean participantHasAudioOrVideo = participantInCallFlagsHaveAudioOrVideo(participant);
+
+            // FIXME Without MCU, PeerConnectionWrapper only sends an offer if the local session ID is higher than the
+            // remote session ID. However, if the other participant does not have audio nor video that participant
+            // will not send an offer, so no connection is actually established when the remote participant has a
+            // higher session ID but is not publishing media.
+            if ((hasMCU && participantHasAudioOrVideo) ||
+                    (!hasMCU && selfParticipantHasAudioOrVideo && (!participantHasAudioOrVideo || sessionId.compareTo(currentSessionId) < 0))) {
+                getOrCreatePeerConnectionWrapperForSessionIdAndType(sessionId, VIDEO_STREAM_TYPE_VIDEO, false);
             }
         }
 
-        if (newSessions.size() > 0 && currentCallStatus != CallStatus.IN_CONVERSATION) {
+        boolean othersInCall = selfJoined ? joined.size() > 1 : joined.size() > 0;
+        if (othersInCall && currentCallStatus != CallStatus.IN_CONVERSATION) {
             setCallState(CallStatus.IN_CONVERSATION);
         }
 
-        for (String sessionId : disconnectedSessions) {
+        for (Participant participant : left) {
+            String sessionId = participant.getSessionId();
             Log.d(TAG, "   oldSession that will be removed is: " + sessionId);
-            endPeerConnection(sessionId, false);
+            endPeerConnection(sessionId, "video");
+            endPeerConnection(sessionId, "screen");
+            removeCallParticipant(sessionId);
         }
     }
 
-    private void deletePeerConnection(PeerConnectionWrapper peerConnectionWrapper) {
-        peerConnectionWrapper.removePeerConnection();
-        peerConnectionWrapperList.remove(peerConnectionWrapper);
+    private boolean participantInCallFlagsHaveAudioOrVideo(Participant participant) {
+        if (participant == null) {
+            return false;
+        }
+
+        return (participant.getInCall() & Participant.InCallFlags.WITH_AUDIO) > 0 ||
+            (!isVoiceOnlyCall && (participant.getInCall() & Participant.InCallFlags.WITH_VIDEO) > 0);
     }
 
     private PeerConnectionWrapper getPeerConnectionWrapperForSessionIdAndType(String sessionId, String type) {
@@ -1965,46 +2002,22 @@ 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);
-
-                // DataChannel messages are sent only in video peers; (sender) screen peers do not even open them.
-                PeerConnectionWrapper.DataChannelMessageListener dataChannelMessageListener =
-                    new CallActivityDataChannelMessageListener(sessionId);
-                dataChannelMessageListeners.put(sessionId, dataChannelMessageListener);
-                peerConnectionWrapper.addListener(dataChannelMessageListener);
-            }
-
-            if (!publisher && !hasExternalSignalingServer && offerAnswerNickProviders.get(sessionId) == null) {
-                OfferAnswerNickProvider offerAnswerNickProvider = new OfferAnswerNickProvider(sessionId);
-                offerAnswerNickProviders.put(sessionId, offerAnswerNickProvider);
-                signalingMessageReceiver.addListener(offerAnswerNickProvider.getVideoWebRtcMessageListener(), sessionId, "video");
-                signalingMessageReceiver.addListener(offerAnswerNickProvider.getScreenWebRtcMessageListener(), sessionId, "screen");
-            }
-
-            PeerConnectionWrapper.PeerConnectionObserver peerConnectionObserver =
-                new CallActivityPeerConnectionObserver(sessionId, type);
-            peerConnectionObservers.put(sessionId + "-" + type, peerConnectionObserver);
-            peerConnectionWrapper.addObserver(peerConnectionObserver);
-
             if (!publisher) {
-                runOnUiThread(() -> {
-                    // userId is unknown here, but it will be got based on the session id, and the stream will be
-                    // updated once it is added to the connection.
-                    setupVideoStreamForLayout(
-                        null,
-                        sessionId,
-                        false,
-                        type);
-                });
+                CallParticipant callParticipant = callParticipants.get(sessionId);
+                if (callParticipant == null) {
+                    callParticipant = addCallParticipant(sessionId);
+                }
+
+                if ("screen".equals(type)) {
+                    callParticipant.setScreenPeerConnectionWrapper(peerConnectionWrapper);
+                } else {
+                    callParticipant.setPeerConnectionWrapper(peerConnectionWrapper);
+                }
             }
 
             if (publisher) {
+                peerConnectionWrapper.addObserver(selfPeerConnectionObserver);
+
                 startSendingNick();
             }
 
@@ -2012,53 +2025,91 @@ public class CallActivity extends CallBaseActivity {
         }
     }
 
-    private List<PeerConnectionWrapper> getPeerConnectionWrapperListForSessionId(String sessionId) {
-        List<PeerConnectionWrapper> internalList = new ArrayList<>();
-        for (PeerConnectionWrapper peerConnectionWrapper : peerConnectionWrapperList) {
-            if (peerConnectionWrapper.getSessionId().equals(sessionId)) {
-                internalList.add(peerConnectionWrapper);
-            }
+    private CallParticipant addCallParticipant(String sessionId) {
+        CallParticipant callParticipant = new CallParticipant(sessionId);
+        callParticipants.put(sessionId, callParticipant);
+
+        SignalingMessageReceiver.CallParticipantMessageListener callParticipantMessageListener =
+            new CallActivityCallParticipantMessageListener(sessionId);
+        callParticipantMessageListeners.put(sessionId, callParticipantMessageListener);
+        signalingMessageReceiver.addListener(callParticipantMessageListener, sessionId);
+
+        if (!hasExternalSignalingServer) {
+            OfferAnswerNickProvider offerAnswerNickProvider = new OfferAnswerNickProvider(sessionId);
+            offerAnswerNickProviders.put(sessionId, offerAnswerNickProvider);
+            signalingMessageReceiver.addListener(offerAnswerNickProvider.getVideoWebRtcMessageListener(), sessionId, "video");
+            signalingMessageReceiver.addListener(offerAnswerNickProvider.getScreenWebRtcMessageListener(), sessionId, "screen");
         }
 
-        return internalList;
+        final CallParticipantModel callParticipantModel = callParticipant.getCallParticipantModel();
+
+        ScreenParticipantDisplayItemManager screenParticipantDisplayItemManager =
+            new ScreenParticipantDisplayItemManager(callParticipantModel);
+        screenParticipantDisplayItemManagers.put(sessionId, screenParticipantDisplayItemManager);
+        callParticipantModel.addObserver(screenParticipantDisplayItemManager, screenParticipantDisplayItemManagersHandler);
+
+        runOnUiThread(() -> {
+            addParticipantDisplayItem(callParticipantModel, "video");
+        });
+
+        return callParticipant;
     }
 
-    private void endPeerConnection(String sessionId, boolean justScreen) {
-        List<PeerConnectionWrapper> peerConnectionWrappers;
-        if (!(peerConnectionWrappers = getPeerConnectionWrapperListForSessionId(sessionId)).isEmpty()) {
-            for (PeerConnectionWrapper peerConnectionWrapper : peerConnectionWrappers) {
-                if (peerConnectionWrapper.getSessionId().equals(sessionId)) {
-                    if (!justScreen && VIDEO_STREAM_TYPE_VIDEO.equals(peerConnectionWrapper.getVideoStreamType())) {
-                        PeerConnectionWrapper.DataChannelMessageListener dataChannelMessageListener = dataChannelMessageListeners.remove(sessionId);
-                        peerConnectionWrapper.removeListener(dataChannelMessageListener);
-                    }
-                    String videoStreamType = peerConnectionWrapper.getVideoStreamType();
-                    if (VIDEO_STREAM_TYPE_SCREEN.equals(videoStreamType) || !justScreen) {
-                        PeerConnectionWrapper.PeerConnectionObserver peerConnectionObserver = peerConnectionObservers.remove(sessionId + "-" + videoStreamType);
-                        peerConnectionWrapper.removeObserver(peerConnectionObserver);
+    private void endPeerConnection(String sessionId, String type) {
+        PeerConnectionWrapper peerConnectionWrapper = getPeerConnectionWrapperForSessionIdAndType(sessionId, type);
+        if (peerConnectionWrapper == null) {
+            return;
+        }
 
-                        runOnUiThread(() -> removeMediaStream(sessionId, videoStreamType));
-                        deletePeerConnection(peerConnectionWrapper);
-                    }
-                }
+        if (webSocketClient != null && webSocketClient.getSessionId() != null && webSocketClient.getSessionId().equals(sessionId)) {
+            peerConnectionWrapper.removeObserver(selfPeerConnectionObserver);
+        }
+
+        CallParticipant callParticipant = callParticipants.get(sessionId);
+        if (callParticipant != null) {
+            if ("screen".equals(type)) {
+                callParticipant.setScreenPeerConnectionWrapper(null);
+            } else {
+                callParticipant.setPeerConnectionWrapper(null);
             }
         }
 
-        if (!justScreen) {
-            SignalingMessageReceiver.CallParticipantMessageListener listener = callParticipantMessageListeners.remove(sessionId);
-            signalingMessageReceiver.removeListener(listener);
+        peerConnectionWrapper.removePeerConnection();
+        peerConnectionWrapperList.remove(peerConnectionWrapper);
+    }
+
+    private void removeCallParticipant(String sessionId) {
+        CallParticipant callParticipant = callParticipants.remove(sessionId);
+        if (callParticipant == null) {
+            return;
+        }
+
+        ScreenParticipantDisplayItemManager screenParticipantDisplayItemManager =
+            screenParticipantDisplayItemManagers.remove(sessionId);
+        callParticipant.getCallParticipantModel().removeObserver(screenParticipantDisplayItemManager);
 
-            OfferAnswerNickProvider offerAnswerNickProvider = offerAnswerNickProviders.remove(sessionId);
-            if (offerAnswerNickProvider != null) {
-                signalingMessageReceiver.removeListener(offerAnswerNickProvider.getVideoWebRtcMessageListener());
-                signalingMessageReceiver.removeListener(offerAnswerNickProvider.getScreenWebRtcMessageListener());
-            }
+        callParticipant.destroy();
+
+        SignalingMessageReceiver.CallParticipantMessageListener listener = callParticipantMessageListeners.remove(sessionId);
+        signalingMessageReceiver.removeListener(listener);
+
+        OfferAnswerNickProvider offerAnswerNickProvider = offerAnswerNickProviders.remove(sessionId);
+        if (offerAnswerNickProvider != null) {
+            signalingMessageReceiver.removeListener(offerAnswerNickProvider.getVideoWebRtcMessageListener());
+            signalingMessageReceiver.removeListener(offerAnswerNickProvider.getScreenWebRtcMessageListener());
         }
+
+        runOnUiThread(() -> removeParticipantDisplayItem(sessionId, "video"));
     }
 
-    private void removeMediaStream(String sessionId, String videoStreamType) {
-        Log.d(TAG, "removeMediaStream");
-        participantDisplayItems.remove(sessionId + "-" + videoStreamType);
+    private void removeParticipantDisplayItem(String sessionId, String videoStreamType) {
+        Log.d(TAG, "removeParticipantDisplayItem");
+        ParticipantDisplayItem participantDisplayItem = participantDisplayItems.remove(sessionId + "-" + videoStreamType);
+        if (participantDisplayItem == null) {
+            return;
+        }
+
+        participantDisplayItem.destroy();
 
         if (!isDestroyed()) {
             initGridAdapter();
@@ -2072,7 +2123,10 @@ public class CallActivity extends CallBaseActivity {
         updateSelfVideoViewPosition();
     }
 
-    private void updateSelfVideoViewConnected(boolean connected) {
+    private void updateSelfVideoViewIceConnectionState(PeerConnection.IceConnectionState iceConnectionState) {
+        boolean connected = iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
+                                iceConnectionState == PeerConnection.IceConnectionState.COMPLETED;
+
         // FIXME In voice only calls there is no video view, so the progress bar would appear floating in the middle of
         // nowhere. However, a way to signal that the local participant is not connected to the HPB is still need in
         // that case.
@@ -2133,28 +2187,6 @@ public class CallActivity extends CallBaseActivity {
         }
     }
 
-    private void handlePeerConnected(String sessionId, String videoStreamType) {
-        String participantDisplayItemId = sessionId + "-" + videoStreamType;
-
-        if (webSocketClient != null && webSocketClient.getSessionId() != null && webSocketClient.getSessionId().equals(sessionId)) {
-            updateSelfVideoViewConnected(true);
-        } else if (participantDisplayItems.get(participantDisplayItemId) != null) {
-            participantDisplayItems.get(participantDisplayItemId).setConnected(true);
-            participantsAdapter.notifyDataSetChanged();
-        }
-    }
-
-    private void handlePeerDisconnected(String sessionId, String videoStreamType) {
-        String participantDisplayItemId = sessionId + "-" + videoStreamType;
-
-        if (webSocketClient != null && webSocketClient.getSessionId() != null && webSocketClient.getSessionId().equals(sessionId)) {
-            updateSelfVideoViewConnected(false);
-        } else if (participantDisplayItems.get(participantDisplayItemId) != null) {
-            participantDisplayItems.get(participantDisplayItemId).setConnected(false);
-            participantsAdapter.notifyDataSetChanged();
-        }
-    }
-
     private void startSendingNick() {
         DataChannelMessage dataChannelMessage = new DataChannelMessage();
         dataChannelMessage.setType("nickChanged");
@@ -2204,42 +2236,16 @@ public class CallActivity extends CallBaseActivity {
                                                          this);
     }
 
-    private void setupVideoStreamForLayout(@Nullable MediaStream mediaStream,
-                                           String session,
-                                           boolean videoStreamEnabled,
-                                           String videoStreamType) {
-        PeerConnectionWrapper peerConnectionWrapper = getPeerConnectionWrapperForSessionIdAndType(session,
-                                                                                                  videoStreamType);
-
-        boolean connected = false;
-        if (peerConnectionWrapper != null) {
-            PeerConnection.IceConnectionState iceConnectionState = peerConnectionWrapper.getPeerConnection().iceConnectionState();
-            connected = iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
-                iceConnectionState == PeerConnection.IceConnectionState.COMPLETED;
-        }
-
-        String nick;
-        if (hasExternalSignalingServer) {
-            nick = webSocketClient.getDisplayNameForSession(session);
-        } else {
-            nick = offerAnswerNickProviders.get(session) != null ? offerAnswerNickProviders.get(session).getNick() : "";
-        }
-
-        String userId = userIdsBySessionId.get(session);
-
+    private void addParticipantDisplayItem(CallParticipantModel callParticipantModel, String videoStreamType) {
         String defaultGuestNick = getResources().getString(R.string.nc_nick_guest);
 
         ParticipantDisplayItem participantDisplayItem = new ParticipantDisplayItem(baseUrl,
-                                                                                   userId,
-                                                                                   session,
-                                                                                   connected,
-                                                                                   nick,
                                                                                    defaultGuestNick,
-                                                                                   mediaStream,
+                                                                                   rootEglBase,
                                                                                    videoStreamType,
-                                                                                   videoStreamEnabled,
-                                                                                   rootEglBase);
-        participantDisplayItems.put(session + "-" + videoStreamType, participantDisplayItem);
+                                                                                   callParticipantModel);
+        String sessionId = callParticipantModel.getSessionId();
+        participantDisplayItems.put(sessionId + "-" + videoStreamType, participantDisplayItem);
 
         initGridAdapter();
     }
@@ -2548,17 +2554,8 @@ public class CallActivity extends CallBaseActivity {
         private void onOfferOrAnswer(String nick) {
             this.nick = nick;
 
-            boolean notifyDataSetChanged = false;
-            if (participantDisplayItems.get(sessionId + "-video") != null) {
-                participantDisplayItems.get(sessionId + "-video").setNick(nick);
-                notifyDataSetChanged = true;
-            }
-            if (participantDisplayItems.get(sessionId + "-screen") != null) {
-                participantDisplayItems.get(sessionId + "-screen").setNick(nick);
-                notifyDataSetChanged = true;
-            }
-            if (notifyDataSetChanged) {
-                participantsAdapter.notifyDataSetChanged();
+            if (callParticipants.get(sessionId) != null) {
+                callParticipants.get(sessionId).setNick(nick);
             }
         }
 
@@ -2585,146 +2582,55 @@ public class CallActivity extends CallBaseActivity {
 
         @Override
         public void onUnshareScreen() {
-            endPeerConnection(sessionId, true);
+            endPeerConnection(sessionId, "screen");
         }
     }
 
-    private class CallActivityDataChannelMessageListener implements PeerConnectionWrapper.DataChannelMessageListener {
-
-        private final String participantDisplayItemId;
-
-        private CallActivityDataChannelMessageListener(String sessionId) {
-            // DataChannel messages are sent only in video peers, so the listener only acts on the "video" items.
-            this.participantDisplayItemId = sessionId + "-video";
-        }
+    private class CallActivitySelfPeerConnectionObserver implements PeerConnectionWrapper.PeerConnectionObserver {
 
         @Override
-        public void onAudioOn() {
-            runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) != null) {
-                    participantDisplayItems.get(participantDisplayItemId).setAudioEnabled(true);
-                    participantsAdapter.notifyDataSetChanged();
-                }
-            });
-        }
-
-        @Override
-        public void onAudioOff() {
-            runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) != null) {
-                    participantDisplayItems.get(participantDisplayItemId).setAudioEnabled(false);
-                    participantsAdapter.notifyDataSetChanged();
-                }
-            });
+        public void onStreamAdded(MediaStream mediaStream) {
         }
 
         @Override
-        public void onVideoOn() {
-            runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) != null) {
-                    participantDisplayItems.get(participantDisplayItemId).setStreamEnabled(true);
-                    participantsAdapter.notifyDataSetChanged();
-                }
-            });
+        public void onStreamRemoved(MediaStream mediaStream) {
         }
 
         @Override
-        public void onVideoOff() {
+        public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
             runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) != null) {
-                    participantDisplayItems.get(participantDisplayItemId).setStreamEnabled(false);
-                    participantsAdapter.notifyDataSetChanged();
-                }
-            });
-        }
+                updateSelfVideoViewIceConnectionState(iceConnectionState);
 
-        @Override
-        public void onNickChanged(String nick) {
-            runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) != null) {
-                    participantDisplayItems.get(participantDisplayItemId).setNick(nick);
-                    participantsAdapter.notifyDataSetChanged();
+                if (iceConnectionState == PeerConnection.IceConnectionState.FAILED) {
+                    setCallState(CallStatus.PUBLISHER_FAILED);
+                    webSocketClient.clearResumeId();
+                    hangup(false);
                 }
             });
         }
     }
 
-    private class CallActivityPeerConnectionObserver implements PeerConnectionWrapper.PeerConnectionObserver {
-
-        private final String sessionId;
-        private final String videoStreamType;
-        private final String participantDisplayItemId;
-
-        private CallActivityPeerConnectionObserver(String sessionId, String videoStreamType) {
-            this.sessionId = sessionId;
-            this.videoStreamType = videoStreamType;
-            this.participantDisplayItemId = sessionId + "-" + videoStreamType;
-        }
-
-        @Override
-        public void onStreamAdded(MediaStream mediaStream) {
-            handleStream(mediaStream);
-        }
-
-        @Override
-        public void onStreamRemoved(MediaStream mediaStream) {
-            handleStream(null);
-        }
+    private class ScreenParticipantDisplayItemManager implements CallParticipantModel.Observer {
 
-        private void handleStream(MediaStream mediaStream) {
-            runOnUiThread(() -> {
-                if (participantDisplayItems.get(participantDisplayItemId) == null) {
-                    return;
-                }
-
-                boolean hasAtLeastOneVideoStream = false;
-                if (mediaStream != null) {
-                    hasAtLeastOneVideoStream = mediaStream.videoTracks != null && mediaStream.videoTracks.size() > 0;
-                }
+        private final CallParticipantModel callParticipantModel;
 
-                ParticipantDisplayItem participantDisplayItem = participantDisplayItems.get(participantDisplayItemId);
-                participantDisplayItem.setMediaStream(mediaStream);
-                participantDisplayItem.setStreamEnabled(hasAtLeastOneVideoStream);
-                participantsAdapter.notifyDataSetChanged();
-            });
+        private ScreenParticipantDisplayItemManager(CallParticipantModel callParticipantModel) {
+            this.callParticipantModel = callParticipantModel;
         }
 
         @Override
-        public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
-            runOnUiThread(() -> {
-                if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
-                        iceConnectionState == PeerConnection.IceConnectionState.COMPLETED) {
-                    handlePeerConnected(sessionId, videoStreamType);
-
-                    return;
-                }
-
-                if (iceConnectionState == PeerConnection.IceConnectionState.DISCONNECTED ||
-                        iceConnectionState == PeerConnection.IceConnectionState.NEW ||
-                        iceConnectionState == PeerConnection.IceConnectionState.CHECKING) {
-                    handlePeerDisconnected(sessionId, videoStreamType);
-
-                    return;
-                }
-
-                if (iceConnectionState == PeerConnection.IceConnectionState.CLOSED) {
-                    endPeerConnection(sessionId, VIDEO_STREAM_TYPE_SCREEN.equals(videoStreamType));
-
-                    return;
-                }
+        public void onChange() {
+            String sessionId = callParticipantModel.getSessionId();
+            if (callParticipantModel.getScreenIceConnectionState() == null) {
+                removeParticipantDisplayItem(sessionId, "screen");
 
-                if (iceConnectionState == PeerConnection.IceConnectionState.FAILED) {
-                    if (webSocketClient != null && webSocketClient.getSessionId() != null && webSocketClient.getSessionId().equals(sessionId)) {
-                        setCallState(CallStatus.PUBLISHER_FAILED);
-                        webSocketClient.clearResumeId();
-                        hangup(false);
-                    } else {
-                        handlePeerDisconnected(sessionId, videoStreamType);
-                    }
+                return;
+            }
 
-                    return;
-                }
-            });
+            boolean hasScreenParticipantDisplayItem = participantDisplayItems.get(sessionId + "-screen") != null;
+            if (!hasScreenParticipantDisplayItem) {
+                addParticipantDisplayItem(callParticipantModel, "screen");
+            }
         }
     }
 

+ 74 - 65
app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java

@@ -1,65 +1,104 @@
 package com.nextcloud.talk.adapters;
 
+import android.os.Handler;
+import android.os.Looper;
 import android.text.TextUtils;
 
+import com.nextcloud.talk.call.CallParticipantModel;
 import com.nextcloud.talk.utils.ApiUtils;
 
 import org.webrtc.EglBase;
 import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
 
 public class ParticipantDisplayItem {
-    private String baseUrl;
+
+    public interface Observer {
+        void onChange();
+    }
+
+    /**
+     * Shared handler to receive change notifications from the model on the main thread.
+     */
+    private static final Handler handler = new Handler(Looper.getMainLooper());
+
+    private final ParticipantDisplayItemNotifier participantDisplayItemNotifier = new ParticipantDisplayItemNotifier();
+
+    private final String baseUrl;
+    private final String defaultGuestNick;
+    private final EglBase rootEglBase;
+
+    private final String session;
+    private final String streamType;
+
+    private final CallParticipantModel callParticipantModel;
+
+    private final CallParticipantModel.Observer callParticipantModelObserver = this::updateFromModel;
+
     private String userId;
-    private String session;
-    private boolean connected;
+    private PeerConnection.IceConnectionState iceConnectionState;
     private String nick;
-    private final String defaultGuestNick;
     private String urlForAvatar;
     private MediaStream mediaStream;
-    private String streamType;
     private boolean streamEnabled;
-    private EglBase rootEglBase;
     private boolean isAudioEnabled;
 
-    public ParticipantDisplayItem(String baseUrl, String userId, String session, boolean connected, String nick, String defaultGuestNick, MediaStream mediaStream, String streamType, boolean streamEnabled, EglBase rootEglBase) {
+    public ParticipantDisplayItem(String baseUrl, String defaultGuestNick, EglBase rootEglBase, String streamType,
+                                  CallParticipantModel callParticipantModel) {
         this.baseUrl = baseUrl;
-        this.userId = userId;
-        this.session = session;
-        this.connected = connected;
-        this.nick = nick;
         this.defaultGuestNick = defaultGuestNick;
-        this.mediaStream = mediaStream;
-        this.streamType = streamType;
-        this.streamEnabled = streamEnabled;
         this.rootEglBase = rootEglBase;
 
-        this.updateUrlForAvatar();
+        this.session = callParticipantModel.getSessionId();
+        this.streamType = streamType;
+
+        this.callParticipantModel = callParticipantModel;
+        this.callParticipantModel.addObserver(callParticipantModelObserver, handler);
+
+        updateFromModel();
     }
 
-    public String getUserId() {
-        return userId;
+    public void destroy() {
+        this.callParticipantModel.removeObserver(callParticipantModelObserver);
     }
 
-    public void setUserId(String userId) {
-        this.userId = userId;
+    private void updateFromModel() {
+        userId = callParticipantModel.getUserId();
+        nick = callParticipantModel.getNick();
 
         this.updateUrlForAvatar();
-    }
 
-    public String getSession() {
-        return session;
-    }
+        if ("screen".equals(streamType)) {
+            iceConnectionState = callParticipantModel.getScreenIceConnectionState();
+            mediaStream = callParticipantModel.getScreenMediaStream();
+            isAudioEnabled = true;
+            streamEnabled = true;
+        } else {
+            iceConnectionState = callParticipantModel.getIceConnectionState();
+            mediaStream = callParticipantModel.getMediaStream();
+            isAudioEnabled = callParticipantModel.isAudioAvailable() != null ?
+                callParticipantModel.isAudioAvailable() : false;
+            streamEnabled = callParticipantModel.isVideoAvailable() != null ?
+                callParticipantModel.isVideoAvailable() : false;
+        }
 
-    public void setSession(String session) {
-        this.session = session;
+        participantDisplayItemNotifier.notifyChange();
     }
 
-    public boolean isConnected() {
-        return connected;
+    private void updateUrlForAvatar() {
+        if (!TextUtils.isEmpty(userId)) {
+            urlForAvatar = ApiUtils.getUrlForAvatar(baseUrl, userId, true);
+        } else {
+            urlForAvatar = ApiUtils.getUrlForGuestAvatar(baseUrl, getNick(), true);
+        }
     }
 
-    public void setConnected(boolean connected) {
-        this.connected = connected;
+    public boolean isConnected() {
+        return iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
+            iceConnectionState == PeerConnection.IceConnectionState.COMPLETED ||
+            // If there is no connection state that means that no connection is needed, so it is a special case that is
+            // also seen as "connected".
+            iceConnectionState == null;
     }
 
     public String getNick() {
@@ -70,62 +109,32 @@ public class ParticipantDisplayItem {
         return nick;
     }
 
-    public void setNick(String nick) {
-        this.nick = nick;
-
-        this.updateUrlForAvatar();
-    }
-
     public String getUrlForAvatar() {
         return urlForAvatar;
     }
 
-    private void updateUrlForAvatar() {
-        if (!TextUtils.isEmpty(userId)) {
-            urlForAvatar = ApiUtils.getUrlForAvatar(baseUrl, userId, true);
-        } else {
-            urlForAvatar = ApiUtils.getUrlForGuestAvatar(baseUrl, getNick(), true);
-        }
-    }
-
     public MediaStream getMediaStream() {
         return mediaStream;
     }
 
-    public void setMediaStream(MediaStream mediaStream) {
-        this.mediaStream = mediaStream;
-    }
-
-    public String getStreamType() {
-        return streamType;
-    }
-
-    public void setStreamType(String streamType) {
-        this.streamType = streamType;
-    }
-
     public boolean isStreamEnabled() {
         return streamEnabled;
     }
 
-    public void setStreamEnabled(boolean streamEnabled) {
-        this.streamEnabled = streamEnabled;
-    }
-
     public EglBase getRootEglBase() {
         return rootEglBase;
     }
 
-    public void setRootEglBase(EglBase rootEglBase) {
-        this.rootEglBase = rootEglBase;
-    }
-
     public boolean isAudioEnabled() {
         return isAudioEnabled;
     }
 
-    public void setAudioEnabled(boolean audioEnabled) {
-        isAudioEnabled = audioEnabled;
+    public void addObserver(Observer observer) {
+        participantDisplayItemNotifier.addObserver(observer);
+    }
+
+    public void removeObserver(Observer observer) {
+        participantDisplayItemNotifier.removeObserver(observer);
     }
 
     @Override

+ 55 - 0
app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItemNotifier.java

@@ -0,0 +1,55 @@
+/*
+ * 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.adapters;
+
+import com.nextcloud.talk.signaling.SignalingMessageReceiver;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Helper class to register and notify ParticipantDisplayItem.Observers.
+ *
+ * This class is only meant for internal use by ParticipantDisplayItem; observers must register themselves against a
+ * ParticipantDisplayItem rather than against a ParticipantDisplayItemNotifier.
+ */
+class ParticipantDisplayItemNotifier {
+
+    private final Set<ParticipantDisplayItem.Observer> participantDisplayItemObservers = new LinkedHashSet<>();
+
+    public synchronized void addObserver(ParticipantDisplayItem.Observer observer) {
+        if (observer == null) {
+            throw new IllegalArgumentException("ParticipantDisplayItem.Observer can not be null");
+        }
+
+        participantDisplayItemObservers.add(observer);
+    }
+
+    public synchronized void removeObserver(ParticipantDisplayItem.Observer observer) {
+        participantDisplayItemObservers.remove(observer);
+    }
+
+    public synchronized void notifyChange() {
+        for (ParticipantDisplayItem.Observer observer : new ArrayList<>(participantDisplayItemObservers)) {
+            observer.onChange();
+        }
+    }
+}

+ 11 - 0
app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java

@@ -29,6 +29,8 @@ public class ParticipantsAdapter extends BaseAdapter {
 
     private static final String TAG = "ParticipantsAdapter";
 
+    private final ParticipantDisplayItem.Observer participantDisplayItemObserver = this::notifyDataSetChanged;
+
     private final Context mContext;
     private final ArrayList<ParticipantDisplayItem> participantDisplayItems;
     private final RelativeLayout gridViewWrapper;
@@ -50,8 +52,17 @@ public class ParticipantsAdapter extends BaseAdapter {
 
         this.participantDisplayItems = new ArrayList<>();
         this.participantDisplayItems.addAll(participantDisplayItems.values());
+
+        for (ParticipantDisplayItem participantDisplayItem : this.participantDisplayItems) {
+            participantDisplayItem.addObserver(participantDisplayItemObserver);
+        }
     }
 
+    public void destroy() {
+        for (ParticipantDisplayItem participantDisplayItem : participantDisplayItems) {
+            participantDisplayItem.removeObserver(participantDisplayItemObserver);
+        }
+    }
 
     @Override
     public int getCount() {

+ 198 - 0
app/src/main/java/com/nextcloud/talk/call/CallParticipant.java

@@ -0,0 +1,198 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
+
+import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
+
+/**
+ * Model for (remote) call participants.
+ *
+ * This class keeps track of the state changes in a call participant and updates its data model as needed. View classes
+ * are expected to directly use the read-only data model.
+ */
+public class CallParticipant {
+
+    private final PeerConnectionWrapper.PeerConnectionObserver peerConnectionObserver =
+            new PeerConnectionWrapper.PeerConnectionObserver() {
+        @Override
+        public void onStreamAdded(MediaStream mediaStream) {
+            handleStreamChange(mediaStream);
+        }
+
+        @Override
+        public void onStreamRemoved(MediaStream mediaStream) {
+            handleStreamChange(mediaStream);
+        }
+
+        @Override
+        public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
+            handleIceConnectionStateChange(iceConnectionState);
+        }
+    };
+
+    private final PeerConnectionWrapper.PeerConnectionObserver screenPeerConnectionObserver =
+            new PeerConnectionWrapper.PeerConnectionObserver() {
+        @Override
+        public void onStreamAdded(MediaStream mediaStream) {
+            callParticipantModel.setScreenMediaStream(mediaStream);
+        }
+
+        @Override
+        public void onStreamRemoved(MediaStream mediaStream) {
+            callParticipantModel.setScreenMediaStream(null);
+        }
+
+        @Override
+        public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
+            callParticipantModel.setScreenIceConnectionState(iceConnectionState);
+        }
+    };
+
+    // DataChannel messages are sent only in video peers; (sender) screen peers do not even open them.
+    private final PeerConnectionWrapper.DataChannelMessageListener dataChannelMessageListener =
+            new PeerConnectionWrapper.DataChannelMessageListener() {
+        @Override
+        public void onAudioOn() {
+            callParticipantModel.setAudioAvailable(Boolean.TRUE);
+        }
+
+        @Override
+        public void onAudioOff() {
+            callParticipantModel.setAudioAvailable(Boolean.FALSE);
+        }
+
+        @Override
+        public void onVideoOn() {
+            callParticipantModel.setVideoAvailable(Boolean.TRUE);
+        }
+
+        @Override
+        public void onVideoOff() {
+            callParticipantModel.setVideoAvailable(Boolean.FALSE);
+        }
+
+        @Override
+        public void onNickChanged(String nick) {
+            callParticipantModel.setNick(nick);
+        }
+    };
+
+    private final MutableCallParticipantModel callParticipantModel;
+
+    private PeerConnectionWrapper peerConnectionWrapper;
+    private PeerConnectionWrapper screenPeerConnectionWrapper;
+
+    public CallParticipant(String sessionId) {
+        callParticipantModel = new MutableCallParticipantModel(sessionId);
+    }
+
+    public void destroy() {
+        if (peerConnectionWrapper != null) {
+            peerConnectionWrapper.removeObserver(peerConnectionObserver);
+            peerConnectionWrapper.removeListener(dataChannelMessageListener);
+        }
+        if (screenPeerConnectionWrapper != null) {
+            screenPeerConnectionWrapper.removeObserver(screenPeerConnectionObserver);
+        }
+    }
+
+    public CallParticipantModel getCallParticipantModel() {
+        return callParticipantModel;
+    }
+
+    public void setUserId(String userId) {
+        callParticipantModel.setUserId(userId);
+    }
+
+    public void setNick(String nick) {
+        callParticipantModel.setNick(nick);
+    }
+
+    public void setPeerConnectionWrapper(PeerConnectionWrapper peerConnectionWrapper) {
+        if (this.peerConnectionWrapper != null) {
+            this.peerConnectionWrapper.removeObserver(peerConnectionObserver);
+            this.peerConnectionWrapper.removeListener(dataChannelMessageListener);
+        }
+
+        this.peerConnectionWrapper = peerConnectionWrapper;
+
+        if (this.peerConnectionWrapper == null) {
+            callParticipantModel.setIceConnectionState(null);
+            callParticipantModel.setMediaStream(null);
+            callParticipantModel.setAudioAvailable(null);
+            callParticipantModel.setVideoAvailable(null);
+
+            return;
+        }
+
+        handleIceConnectionStateChange(this.peerConnectionWrapper.getPeerConnection().iceConnectionState());
+        handleStreamChange(this.peerConnectionWrapper.getStream());
+
+        this.peerConnectionWrapper.addObserver(peerConnectionObserver);
+        this.peerConnectionWrapper.addListener(dataChannelMessageListener);
+    }
+
+    private void handleIceConnectionStateChange(PeerConnection.IceConnectionState iceConnectionState) {
+        callParticipantModel.setIceConnectionState(iceConnectionState);
+
+        if (iceConnectionState == PeerConnection.IceConnectionState.NEW ||
+                iceConnectionState == PeerConnection.IceConnectionState.CHECKING) {
+            callParticipantModel.setAudioAvailable(null);
+            callParticipantModel.setVideoAvailable(null);
+        }
+    }
+
+    private void handleStreamChange(MediaStream mediaStream) {
+        if (mediaStream == null) {
+            callParticipantModel.setMediaStream(null);
+            callParticipantModel.setVideoAvailable(Boolean.FALSE);
+
+            return;
+        }
+
+        boolean hasAtLeastOneVideoStream = mediaStream.videoTracks != null && !mediaStream.videoTracks.isEmpty();
+
+        callParticipantModel.setMediaStream(mediaStream);
+        callParticipantModel.setVideoAvailable(hasAtLeastOneVideoStream);
+    }
+
+    public void setScreenPeerConnectionWrapper(PeerConnectionWrapper screenPeerConnectionWrapper) {
+        if (this.screenPeerConnectionWrapper != null) {
+            this.screenPeerConnectionWrapper.removeObserver(screenPeerConnectionObserver);
+        }
+
+        this.screenPeerConnectionWrapper = screenPeerConnectionWrapper;
+
+        if (this.screenPeerConnectionWrapper == null) {
+            callParticipantModel.setScreenIceConnectionState(null);
+            callParticipantModel.setScreenMediaStream(null);
+
+            return;
+        }
+
+        callParticipantModel.setScreenIceConnectionState(this.screenPeerConnectionWrapper.getPeerConnection().iceConnectionState());
+        callParticipantModel.setScreenMediaStream(this.screenPeerConnectionWrapper.getStream());
+
+        this.screenPeerConnectionWrapper.addObserver(screenPeerConnectionObserver);
+    }
+}

+ 164 - 0
app/src/main/java/com/nextcloud/talk/call/CallParticipantList.java

@@ -0,0 +1,164 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+import com.nextcloud.talk.signaling.SignalingMessageReceiver;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Helper class to keep track of the participants in a call based on the signaling messages.
+ *
+ * The CallParticipantList adds a listener for participant list messages as soon as it is created and starts tracking
+ * the call participants until destroyed. Notifications about the changes can be received by adding an observer to the
+ * CallParticipantList; note that no sorting is guaranteed on the participants.
+ */
+public class CallParticipantList {
+
+    public interface Observer {
+        void onCallParticipantsChanged(Collection<Participant> joined, Collection<Participant> updated,
+                                       Collection<Participant> left, Collection<Participant> unchanged);
+        void onCallEndedForAll();
+    }
+
+    private final SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener =
+            new SignalingMessageReceiver.ParticipantListMessageListener() {
+
+        private final Map<String, Participant> callParticipants = new HashMap<>();
+
+        @Override
+        public void onUsersInRoom(List<Participant> participants) {
+            processParticipantList(participants);
+        }
+
+        @Override
+        public void onParticipantsUpdate(List<Participant> participants) {
+            processParticipantList(participants);
+        }
+
+        private void processParticipantList(List<Participant> participants) {
+            Collection<Participant> joined = new ArrayList<>();
+            Collection<Participant> updated = new ArrayList<>();
+            Collection<Participant> left = new ArrayList<>();
+            Collection<Participant> unchanged = new ArrayList<>();
+
+            Collection<Participant> knownCallParticipantsNotFound = new ArrayList<>(callParticipants.values());
+
+            for (Participant participant : participants) {
+                String sessionId = participant.getSessionId();
+                Participant callParticipant = callParticipants.get(sessionId);
+
+                boolean knownCallParticipant = callParticipant != null;
+                if (!knownCallParticipant && participant.getInCall() != Participant.InCallFlags.DISCONNECTED) {
+                    callParticipants.put(sessionId, copyParticipant(participant));
+                    joined.add(copyParticipant(participant));
+                } else if (knownCallParticipant && participant.getInCall() == Participant.InCallFlags.DISCONNECTED) {
+                    callParticipants.remove(sessionId);
+                    // No need to copy it, as it will be no longer used.
+                    callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+                    left.add(callParticipant);
+                } else if (knownCallParticipant && callParticipant.getInCall() != participant.getInCall()) {
+                    callParticipant.setInCall(participant.getInCall());
+                    updated.add(copyParticipant(participant));
+                } else if (knownCallParticipant) {
+                    unchanged.add(copyParticipant(participant));
+                }
+
+                if (knownCallParticipant) {
+                    knownCallParticipantsNotFound.remove(callParticipant);
+                }
+            }
+
+            for (Participant callParticipant : knownCallParticipantsNotFound) {
+                callParticipants.remove(callParticipant.getSessionId());
+                // No need to copy it, as it will be no longer used.
+                callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+                left.add(callParticipant);
+            }
+
+            if (!joined.isEmpty() || !updated.isEmpty() || !left.isEmpty()) {
+                callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
+            }
+        }
+
+        @Override
+        public void onAllParticipantsUpdate(long inCall) {
+            if (inCall != Participant.InCallFlags.DISCONNECTED) {
+                // Updating all participants is expected to happen only to disconnect them.
+                return;
+            }
+
+            callParticipantListNotifier.notifyCallEndedForAll();
+
+            Collection<Participant> joined = new ArrayList<>();
+            Collection<Participant> updated = new ArrayList<>();
+            Collection<Participant> left = new ArrayList<>(callParticipants.size());
+            Collection<Participant> unchanged = new ArrayList<>();
+
+            for (Participant callParticipant : callParticipants.values()) {
+                // No need to copy it, as it will be no longer used.
+                callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
+                left.add(callParticipant);
+            }
+            callParticipants.clear();
+
+            if (!left.isEmpty()) {
+                callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
+            }
+        }
+
+        private Participant copyParticipant(Participant participant) {
+            Participant copiedParticipant = new Participant();
+            copiedParticipant.setInCall(participant.getInCall());
+            copiedParticipant.setLastPing(participant.getLastPing());
+            copiedParticipant.setSessionId(participant.getSessionId());
+            copiedParticipant.setType(participant.getType());
+            copiedParticipant.setUserId(participant.getUserId());
+
+            return copiedParticipant;
+        }
+    };
+
+    private final CallParticipantListNotifier callParticipantListNotifier = new CallParticipantListNotifier();
+
+    private final SignalingMessageReceiver signalingMessageReceiver;
+
+    public CallParticipantList(SignalingMessageReceiver signalingMessageReceiver) {
+        this.signalingMessageReceiver = signalingMessageReceiver;
+        this.signalingMessageReceiver.addListener(participantListMessageListener);
+    }
+
+    public void destroy() {
+        signalingMessageReceiver.removeListener(participantListMessageListener);
+    }
+
+    public void addObserver(Observer observer) {
+        callParticipantListNotifier.addObserver(observer);
+    }
+
+    public void removeObserver(Observer observer) {
+        callParticipantListNotifier.removeObserver(observer);
+    }
+}

+ 63 - 0
app/src/main/java/com/nextcloud/talk/call/CallParticipantListNotifier.java

@@ -0,0 +1,63 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Helper class to register and notify CallParticipantList.Observers.
+ *
+ * This class is only meant for internal use by CallParticipantList; listeners must register themselves against
+ * a CallParticipantList rather than against a CallParticipantListNotifier.
+ */
+class CallParticipantListNotifier {
+
+    private final Set<CallParticipantList.Observer> callParticipantListObservers = new LinkedHashSet<>();
+
+    public synchronized void addObserver(CallParticipantList.Observer observer) {
+        if (observer == null) {
+            throw new IllegalArgumentException("CallParticipantList.Observer can not be null");
+        }
+
+        callParticipantListObservers.add(observer);
+    }
+
+    public synchronized void removeObserver(CallParticipantList.Observer observer) {
+        callParticipantListObservers.remove(observer);
+    }
+
+    public synchronized void notifyChanged(Collection<Participant> joined, Collection<Participant> updated,
+                                           Collection<Participant> left, Collection<Participant> unchanged) {
+        for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
+            observer.onCallParticipantsChanged(joined, updated, left, unchanged);
+        }
+    }
+
+    public synchronized void notifyCallEndedForAll() {
+        for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
+            observer.onCallEndedForAll();
+        }
+    }
+}

+ 164 - 0
app/src/main/java/com/nextcloud/talk/call/CallParticipantModel.java

@@ -0,0 +1,164 @@
+/*
+ * 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.call;
+
+import android.os.Handler;
+
+import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
+
+import java.util.Objects;
+
+/**
+ * Read-only data model for (remote) call participants.
+ *
+ * The received audio and video are available only if the participant is sending them and also has them enabled.
+ * Before a connection is established it is not known whether audio and video are available or not, so null is returned
+ * in that case (therefore it should not be autoboxed to a plain boolean without checking that).
+ *
+ * Audio and video in screen shares, on the other hand, are always seen as available.
+ *
+ * Clients of the model can observe it with CallParticipantModel.Observer to be notified when any value changes.
+ * Getters called after receiving a notification are guaranteed to provide at least the value that triggered the
+ * notification, but it may return even a more up to date one (so getting the value again on the following
+ * notification may return the same value as before).
+ */
+public class CallParticipantModel {
+
+    public interface Observer {
+        void onChange();
+    }
+
+    protected class Data<T> {
+
+        private T value;
+
+        public T getValue() {
+            return value;
+        }
+
+        public void setValue(T value) {
+            if (Objects.equals(this.value, value)) {
+                return;
+            }
+
+            this.value = value;
+
+            callParticipantModelNotifier.notifyChange();
+        }
+    }
+
+    private final CallParticipantModelNotifier callParticipantModelNotifier = new CallParticipantModelNotifier();
+
+    protected final String sessionId;
+
+    protected Data<String> userId;
+    protected Data<String> nick;
+
+    protected Data<PeerConnection.IceConnectionState> iceConnectionState;
+    protected Data<MediaStream> mediaStream;
+    protected Data<Boolean> audioAvailable;
+    protected Data<Boolean> videoAvailable;
+
+    protected Data<PeerConnection.IceConnectionState> screenIceConnectionState;
+    protected Data<MediaStream> screenMediaStream;
+
+    public CallParticipantModel(String sessionId) {
+        this.sessionId = sessionId;
+
+        this.userId = new Data<>();
+        this.nick = new Data<>();
+
+        this.iceConnectionState = new Data<>();
+        this.mediaStream = new Data<>();
+        this.audioAvailable = new Data<>();
+        this.videoAvailable = new Data<>();
+
+        this.screenIceConnectionState = new Data<>();
+        this.screenMediaStream = new Data<>();
+    }
+
+    public String getSessionId() {
+        return sessionId;
+    }
+
+    public String getUserId() {
+        return userId.getValue();
+    }
+
+    public String getNick() {
+        return nick.getValue();
+    }
+
+    public PeerConnection.IceConnectionState getIceConnectionState() {
+        return iceConnectionState.getValue();
+    }
+
+    public MediaStream getMediaStream() {
+        return mediaStream.getValue();
+    }
+
+    public Boolean isAudioAvailable() {
+        return audioAvailable.getValue();
+    }
+
+    public Boolean isVideoAvailable() {
+        return videoAvailable.getValue();
+    }
+
+    public PeerConnection.IceConnectionState getScreenIceConnectionState() {
+        return screenIceConnectionState.getValue();
+    }
+
+    public MediaStream getScreenMediaStream() {
+        return screenMediaStream.getValue();
+    }
+
+    /**
+     * Adds an Observer to be notified when any value changes.
+     *
+     * @param observer the Observer
+     * @see CallParticipantModel#addObserver(Observer, Handler)
+     */
+    public void addObserver(Observer observer) {
+        addObserver(observer, null);
+    }
+
+    /**
+     * Adds an observer to be notified when any value changes.
+     *
+     * The observer will be notified on the thread associated to the given handler. If no handler is given the
+     * observer will be immediately notified on the same thread that changed the value; the observer will be
+     * immediately notified too if the thread of the handler is the same thread that changed the value.
+     *
+     * An observer is expected to be added only once. If the same observer is added again it will be notified just
+     * once on the thread of the last handler.
+     *
+     * @param observer the Observer
+     * @param handler a Handler for the thread to be notified on
+     */
+    public void addObserver(Observer observer, Handler handler) {
+        callParticipantModelNotifier.addObserver(observer, handler);
+    }
+
+    public void removeObserver(Observer observer) {
+        callParticipantModelNotifier.removeObserver(observer);
+    }
+}

+ 86 - 0
app/src/main/java/com/nextcloud/talk/call/CallParticipantModelNotifier.java

@@ -0,0 +1,86 @@
+/*
+ * 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.call;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Helper class to register and notify CallParticipantModel.Observers.
+ *
+ * This class is only meant for internal use by CallParticipantModel; observers must register themselves against a
+ * CallParticipantModel rather than against a CallParticipantModelNotifier.
+ */
+class CallParticipantModelNotifier {
+
+    /**
+     * Helper class to associate a CallParticipantModel.Observer with a Handler.
+     */
+    private static class CallParticipantModelObserverOn {
+        public final CallParticipantModel.Observer observer;
+        public final Handler handler;
+
+        private CallParticipantModelObserverOn(CallParticipantModel.Observer observer, Handler handler) {
+            this.observer = observer;
+            this.handler = handler;
+        }
+    }
+
+    private final List<CallParticipantModelObserverOn> callParticipantModelObserversOn = new ArrayList<>();
+
+    public synchronized void addObserver(CallParticipantModel.Observer observer, Handler handler) {
+        if (observer == null) {
+            throw new IllegalArgumentException("CallParticipantModel.Observer can not be null");
+        }
+
+        removeObserver(observer);
+
+        callParticipantModelObserversOn.add(new CallParticipantModelObserverOn(observer, handler));
+    }
+
+    public synchronized void removeObserver(CallParticipantModel.Observer observer) {
+        Iterator<CallParticipantModelObserverOn> it = callParticipantModelObserversOn.iterator();
+        while (it.hasNext()) {
+            CallParticipantModelObserverOn observerOn = it.next();
+
+            if (observerOn.observer == observer) {
+                it.remove();
+
+                return;
+            }
+        }
+    }
+
+    public synchronized void notifyChange() {
+        for (CallParticipantModelObserverOn observerOn : new ArrayList<>(callParticipantModelObserversOn)) {
+            if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) {
+                observerOn.observer.onChange();
+            } else {
+                observerOn.handler.post(() -> {
+                    observerOn.observer.onChange();
+                });
+            }
+        }
+    }
+}

+ 67 - 0
app/src/main/java/com/nextcloud/talk/call/MutableCallParticipantModel.java

@@ -0,0 +1,67 @@
+/*
+ * 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.call;
+
+import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
+
+/**
+ * Mutable data model for (remote) call participants.
+ *
+ * There is no synchronization when setting the values; if needed, it should be handled by the clients of the model.
+ */
+public class MutableCallParticipantModel extends CallParticipantModel {
+
+    public MutableCallParticipantModel(String sessionId) {
+        super(sessionId);
+    }
+
+    public void setUserId(String userId) {
+        this.userId.setValue(userId);
+    }
+
+    public void setNick(String nick) {
+        this.nick.setValue(nick);
+    }
+
+    public void setIceConnectionState(PeerConnection.IceConnectionState iceConnectionState) {
+        this.iceConnectionState.setValue(iceConnectionState);
+    }
+
+    public void setMediaStream(MediaStream mediaStream) {
+        this.mediaStream.setValue(mediaStream);
+    }
+
+    public void setAudioAvailable(Boolean audioAvailable) {
+        this.audioAvailable.setValue(audioAvailable);
+    }
+
+    public void setVideoAvailable(Boolean videoAvailable) {
+        this.videoAvailable.setValue(videoAvailable);
+    }
+
+    public void setScreenIceConnectionState(PeerConnection.IceConnectionState screenIceConnectionState) {
+        this.screenIceConnectionState.setValue(screenIceConnectionState);
+    }
+
+    public void setScreenMediaStream(MediaStream screenMediaStream) {
+        this.screenMediaStream.setValue(screenMediaStream);
+    }
+}

+ 11 - 0
app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java

@@ -121,6 +121,9 @@ public class PeerConnectionWrapper {
     private final boolean isMCUPublisher;
     private final String videoStreamType;
 
+    // It is assumed that there will be at most one remote stream at each time.
+    private MediaStream stream;
+
     @Inject
     Context context;
 
@@ -219,6 +222,10 @@ public class PeerConnectionWrapper {
         return videoStreamType;
     }
 
+    public MediaStream getStream() {
+        return stream;
+    }
+
     public void removePeerConnection() {
         signalingMessageReceiver.removeListener(webRtcMessageListener);
 
@@ -484,11 +491,15 @@ public class PeerConnectionWrapper {
 
         @Override
         public void onAddStream(MediaStream mediaStream) {
+            stream = mediaStream;
+
             peerConnectionNotifier.notifyStreamAdded(mediaStream);
         }
 
         @Override
         public void onRemoveStream(MediaStream mediaStream) {
+            stream = null;
+
             peerConnectionNotifier.notifyStreamRemoved(mediaStream);
         }
 

+ 663 - 0
app/src/test/java/com/nextcloud/talk/call/CallParticipantListExternalSignalingTest.java

@@ -0,0 +1,663 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+import com.nextcloud.talk.signaling.SignalingMessageReceiver;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
+import org.mockito.InOrder;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.DISCONNECTED;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.IN_CALL;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.WITH_AUDIO;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.WITH_VIDEO;
+import static com.nextcloud.talk.models.json.participants.Participant.ParticipantType.GUEST;
+import static com.nextcloud.talk.models.json.participants.Participant.ParticipantType.GUEST_MODERATOR;
+import static com.nextcloud.talk.models.json.participants.Participant.ParticipantType.MODERATOR;
+import static com.nextcloud.talk.models.json.participants.Participant.ParticipantType.OWNER;
+import static com.nextcloud.talk.models.json.participants.Participant.ParticipantType.USER;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+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;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+public class CallParticipantListExternalSignalingTest {
+
+    private static class ParticipantsUpdateParticipantBuilder {
+        private Participant newUser(long inCall, long lastPing, String sessionId, Participant.ParticipantType type,
+                                    String userId) {
+            Participant participant = new Participant();
+            participant.setInCall(inCall);
+            participant.setLastPing(lastPing);
+            participant.setSessionId(sessionId);
+            participant.setType(type);
+            participant.setUserId(userId);
+
+            return participant;
+        }
+
+        private Participant newGuest(long inCall, long lastPing, String sessionId, Participant.ParticipantType type) {
+            Participant participant = new Participant();
+            participant.setInCall(inCall);
+            participant.setLastPing(lastPing);
+            participant.setSessionId(sessionId);
+            participant.setType(type);
+
+            return participant;
+        }
+    }
+
+    private final ParticipantsUpdateParticipantBuilder builder = new ParticipantsUpdateParticipantBuilder();
+
+    private CallParticipantList callParticipantList;
+    private SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener;
+
+    private CallParticipantList.Observer mockedCallParticipantListObserver;
+
+    private Collection<Participant> expectedJoined;
+    private Collection<Participant> expectedUpdated;
+    private Collection<Participant> expectedLeft;
+    private Collection<Participant> expectedUnchanged;
+
+    // The order of the left participants in some tests depends on how they are internally sorted by the map, so the
+    // list of left participants needs to be checked ignoring the sorting (or, rather, sorting by session ID as in
+    // expectedLeft).
+    // Other tests can just relay on the not guaranteed, but known internal sorting of the elements.
+    private final ArgumentMatcher<List<Participant>> matchesExpectedLeftIgnoringOrder = left -> {
+        Collections.sort(left, Comparator.comparing(Participant::getSessionId));
+        return expectedLeft.equals(left);
+    };
+
+    @Before
+    public void setUp() {
+        SignalingMessageReceiver mockedSignalingMessageReceiver = mock(SignalingMessageReceiver.class);
+
+        callParticipantList = new CallParticipantList(mockedSignalingMessageReceiver);
+
+        mockedCallParticipantListObserver = mock(CallParticipantList.Observer.class);
+
+        // Get internal ParticipantListMessageListener from callParticipantList set in the
+        // mockedSignalingMessageReceiver.
+        ArgumentCaptor<SignalingMessageReceiver.ParticipantListMessageListener> participantListMessageListenerArgumentCaptor =
+            ArgumentCaptor.forClass(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        verify(mockedSignalingMessageReceiver).addListener(participantListMessageListenerArgumentCaptor.capture());
+
+        participantListMessageListener = participantListMessageListenerArgumentCaptor.getValue();
+
+        expectedJoined = new ArrayList<>();
+        expectedUpdated = new ArrayList<>();
+        expectedLeft = new ArrayList<>();
+        expectedUnchanged = new ArrayList<>();
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(DISCONNECTED, 4, "theSessionId4", USER, "theUserId4"));
+        participants.add(builder.newUser(DISCONNECTED, 5, "theSessionId5", OWNER, "theUserId5"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomThenJoinCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomThenJoinCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(DISCONNECTED, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedJoined.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomAndCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomAndCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedJoined.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateJoinRoomAndCallRepeated() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+        participantListMessageListener.onParticipantsUpdate(participants);
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeCallFlags() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeCallFlagsSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL | WITH_VIDEO, 4, "theSessionId4", USER, "theUserId4"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedUpdated.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_VIDEO, 4, "theSessionId4", USER, "theUserId4"));
+        expectedUnchanged.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2", GUEST));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeLastPing() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId1", MODERATOR, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeLastPingSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 3, "theSessionId3", USER, "theUserId3"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 108, "theSessionId2", GUEST));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 815, "theSessionId3", USER, "theUserId3"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeParticipantType() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", USER, "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateChangeParticipantTypeeSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 3, "theSessionId3", USER, "theUserId3"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", USER, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 2, "theSessionId2", GUEST_MODERATOR));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 3, "theSessionId3", MODERATOR, "theUserId3"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCallThenLeaveRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCallThenLeaveRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCallAndRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testParticipantsUpdateLeaveCallAndRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+
+        verify(mockedCallParticipantListObserver).onCallParticipantsChanged(eq(expectedJoined), eq(expectedUpdated),
+                                                                            argThat(matchesExpectedLeftIgnoringOrder), eq(expectedUnchanged));
+    }
+
+    @Test
+    public void testParticipantsUpdateSeveralEventsSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+        participants.add(builder.newUser(IN_CALL, 5, "theSessionId5", OWNER, "theUserId5"));
+        // theSessionId6 has not joined yet.
+        participants.add(builder.newGuest(IN_CALL | WITH_VIDEO, 7, "theSessionId7", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 8, "theSessionId8", USER, "theUserId8"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 9, "theSessionId9", MODERATOR, "theUserId9"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        // theSessionId1 is gone.
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 5, "theSessionId5", OWNER, "theUserId5"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 6, "theSessionId6", GUEST));
+        participants.add(builder.newGuest(IN_CALL, 7, "theSessionId7", GUEST));
+        participants.add(builder.newUser(IN_CALL, 8, "theSessionId8", USER, "theUserId8"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId9", USER, "theUserId9"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newGuest(IN_CALL | WITH_AUDIO, 6, "theSessionId6", GUEST));
+        expectedJoined.add(builder.newUser(IN_CALL, 8, "theSessionId8", USER, "theUserId8"));
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 5, "theSessionId5", OWNER, "theUserId5"));
+        expectedUpdated.add(builder.newGuest(IN_CALL, 7, "theSessionId7", GUEST));
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2", GUEST));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", USER, "theUserId4"));
+        // Last ping and participant type are not seen as changed, even if they did.
+        expectedUnchanged.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId9", USER, "theUserId9"));
+
+        verify(mockedCallParticipantListObserver).onCallParticipantsChanged(eq(expectedJoined), eq(expectedUpdated),
+                                                                            argThat(matchesExpectedLeftIgnoringOrder), eq(expectedUnchanged));
+    }
+
+    @Test
+    public void testAllParticipantsUpdateDisconnected() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onAllParticipantsUpdate(DISCONNECTED);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        InOrder inOrder = inOrder(mockedCallParticipantListObserver);
+
+        inOrder.verify(mockedCallParticipantListObserver).onCallEndedForAll();
+        inOrder.verify(mockedCallParticipantListObserver).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testAllParticipantsUpdateDisconnectedWithSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        participants.add(builder.newUser(DISCONNECTED, 2, "theSessionId2", USER, "theUserId2"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 3, "theSessionId3", USER, "theUserId3"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 4, "theSessionId4", GUEST));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onAllParticipantsUpdate(DISCONNECTED);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", MODERATOR, "theUserId1"));
+        expectedLeft.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", USER, "theUserId3"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 4, "theSessionId4", GUEST));
+
+        InOrder inOrder = inOrder(mockedCallParticipantListObserver);
+
+        inOrder.verify(mockedCallParticipantListObserver).onCallEndedForAll();
+        inOrder.verify(mockedCallParticipantListObserver).onCallParticipantsChanged(eq(expectedJoined), eq(expectedUpdated),
+            argThat(matchesExpectedLeftIgnoringOrder), eq(expectedUnchanged));
+    }
+
+    @Test
+    public void testAllParticipantsUpdateDisconnectedNoOneInCall() {
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onAllParticipantsUpdate(DISCONNECTED);
+
+        InOrder inOrder = inOrder(mockedCallParticipantListObserver);
+
+        inOrder.verify(mockedCallParticipantListObserver).onCallEndedForAll();
+        verifyNoMoreInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testAllParticipantsUpdateDisconnectedThenJoinCallAgain() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        participantListMessageListener.onAllParticipantsUpdate(DISCONNECTED);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        participantListMessageListener.onParticipantsUpdate(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL, 1, "theSessionId1", MODERATOR, "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+}

+ 535 - 0
app/src/test/java/com/nextcloud/talk/call/CallParticipantListInternalSignalingTest.java

@@ -0,0 +1,535 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.models.json.participants.Participant;
+import com.nextcloud.talk.signaling.SignalingMessageReceiver;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.DISCONNECTED;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.IN_CALL;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.WITH_AUDIO;
+import static com.nextcloud.talk.models.json.participants.Participant.InCallFlags.WITH_VIDEO;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+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 CallParticipantListInternalSignalingTest {
+
+    private static class UsersInRoomParticipantBuilder {
+        private Participant newUser(long inCall, long lastPing, String sessionId, String userId) {
+            Participant participant = new Participant();
+            participant.setInCall(inCall);
+            participant.setLastPing(lastPing);
+            participant.setSessionId(sessionId);
+            participant.setUserId(userId);
+
+            return participant;
+        }
+
+        private Participant newGuest(long inCall, long lastPing, String sessionId) {
+            Participant participant = new Participant();
+            participant.setInCall(inCall);
+            participant.setLastPing(lastPing);
+            participant.setSessionId(sessionId);
+
+            return participant;
+        }
+    }
+
+    private final UsersInRoomParticipantBuilder builder = new UsersInRoomParticipantBuilder();
+
+    private CallParticipantList callParticipantList;
+    private SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener;
+
+    private CallParticipantList.Observer mockedCallParticipantListObserver;
+
+    private Collection<Participant> expectedJoined;
+    private Collection<Participant> expectedUpdated;
+    private Collection<Participant> expectedLeft;
+    private Collection<Participant> expectedUnchanged;
+
+    // The order of the left participants in some tests depends on how they are internally sorted by the map, so the
+    // list of left participants needs to be checked ignoring the sorting (or, rather, sorting by session ID as in
+    // expectedLeft).
+    // Other tests can just relay on the not guaranteed, but known internal sorting of the elements.
+    private final ArgumentMatcher<List<Participant>> matchesExpectedLeftIgnoringOrder = left -> {
+        Collections.sort(left, Comparator.comparing(Participant::getSessionId));
+        return expectedLeft.equals(left);
+    };
+
+    @Before
+    public void setUp() {
+        SignalingMessageReceiver mockedSignalingMessageReceiver = mock(SignalingMessageReceiver.class);
+
+        callParticipantList = new CallParticipantList(mockedSignalingMessageReceiver);
+
+        mockedCallParticipantListObserver = mock(CallParticipantList.Observer.class);
+
+        // Get internal ParticipantListMessageListener from callParticipantList set in the
+        // mockedSignalingMessageReceiver.
+        ArgumentCaptor<SignalingMessageReceiver.ParticipantListMessageListener> participantListMessageListenerArgumentCaptor =
+            ArgumentCaptor.forClass(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        verify(mockedSignalingMessageReceiver).addListener(participantListMessageListenerArgumentCaptor.capture());
+
+        participantListMessageListener = participantListMessageListenerArgumentCaptor.getValue();
+
+        expectedJoined = new ArrayList<>();
+        expectedUpdated = new ArrayList<>();
+        expectedLeft = new ArrayList<>();
+        expectedUnchanged = new ArrayList<>();
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(DISCONNECTED, 4, "theSessionId4", "theUserId4"));
+        participants.add(builder.newUser(DISCONNECTED, 5, "theSessionId5", "theUserId5"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomThenJoinCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomThenJoinCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(DISCONNECTED, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        expectedJoined.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomAndCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomAndCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        expectedJoined.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomJoinRoomAndCallRepeated() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+        participantListMessageListener.onUsersInRoom(participants);
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomChangeCallFlags() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 1, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomChangeCallFlagsSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL | WITH_VIDEO, 4, "theSessionId4", "theUserId4"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedUpdated.add(builder.newUser(IN_CALL, 1, "theSessionId1", "theUserId1"));
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_VIDEO, 4, "theSessionId4", "theUserId4"));
+        expectedUnchanged.add(builder.newGuest(IN_CALL | WITH_AUDIO | WITH_VIDEO, 2, "theSessionId2"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomChangeLastPing() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId1", "theUserId1"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomChangeLastPingSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 2, "theSessionId2"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 3, "theSessionId3", "theUserId3"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 108, "theSessionId2"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 815, "theSessionId3", "theUserId3"));
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCall() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCallSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCallThenLeaveRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCallThenLeaveRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        verifyNoInteractions(mockedCallParticipantListObserver);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCallAndRoom() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+
+        verify(mockedCallParticipantListObserver, only()).onCallParticipantsChanged(expectedJoined, expectedUpdated,
+                                                                                    expectedLeft, expectedUnchanged);
+    }
+
+    @Test
+    public void testUsersInRoomLeaveCallAndRoomSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+
+        verify(mockedCallParticipantListObserver).onCallParticipantsChanged(eq(expectedJoined), eq(expectedUpdated),
+                                                                            argThat(matchesExpectedLeftIgnoringOrder), eq(expectedUnchanged));
+    }
+
+    @Test
+    public void testUsersInRoomSeveralEventsSeveralParticipants() {
+        List<Participant> participants = new ArrayList<>();
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 1, "theSessionId1", "theUserId1"));
+        participants.add(builder.newGuest(IN_CALL, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+        participants.add(builder.newUser(IN_CALL, 5, "theSessionId5", "theUserId5"));
+        // theSessionId6 has not joined yet.
+        participants.add(builder.newGuest(IN_CALL | WITH_VIDEO, 7, "theSessionId7"));
+        participants.add(builder.newUser(DISCONNECTED, 8, "theSessionId8", "theUserId8"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 9, "theSessionId9", "theUserId9"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        callParticipantList.addObserver(mockedCallParticipantListObserver);
+
+        participants = new ArrayList<>();
+        // theSessionId1 is gone.
+        participants.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        participants.add(builder.newUser(DISCONNECTED, 3, "theSessionId3", "theUserId3"));
+        participants.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 5, "theSessionId5", "theUserId5"));
+        participants.add(builder.newGuest(IN_CALL | WITH_AUDIO, 6, "theSessionId6"));
+        participants.add(builder.newGuest(IN_CALL, 7, "theSessionId7"));
+        participants.add(builder.newUser(IN_CALL, 8, "theSessionId8", "theUserId8"));
+        participants.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId9", "theUserId9"));
+
+        participantListMessageListener.onUsersInRoom(participants);
+
+        expectedJoined.add(builder.newGuest(IN_CALL | WITH_AUDIO, 6, "theSessionId6"));
+        expectedJoined.add(builder.newUser(IN_CALL, 8, "theSessionId8", "theUserId8"));
+        expectedUpdated.add(builder.newUser(IN_CALL | WITH_AUDIO | WITH_VIDEO, 5, "theSessionId5", "theUserId5"));
+        expectedUpdated.add(builder.newGuest(IN_CALL, 7, "theSessionId7"));
+        expectedLeft.add(builder.newUser(DISCONNECTED, 1, "theSessionId1", "theUserId1"));
+        expectedLeft.add(builder.newGuest(DISCONNECTED, 2, "theSessionId2"));
+        expectedUnchanged.add(builder.newUser(IN_CALL, 4, "theSessionId4", "theUserId4"));
+        // Last ping is not seen as changed, even if it did.
+        expectedUnchanged.add(builder.newUser(IN_CALL | WITH_AUDIO, 42, "theSessionId9", "theUserId9"));
+
+        verify(mockedCallParticipantListObserver).onCallParticipantsChanged(eq(expectedJoined), eq(expectedUpdated),
+                                                                            argThat(matchesExpectedLeftIgnoringOrder), eq(expectedUnchanged));
+    }
+}

+ 61 - 0
app/src/test/java/com/nextcloud/talk/call/CallParticipantListTest.java

@@ -0,0 +1,61 @@
+/*
+ * 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.call;
+
+import com.nextcloud.talk.signaling.SignalingMessageReceiver;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+public class CallParticipantListTest {
+
+    private SignalingMessageReceiver mockedSignalingMessageReceiver;
+
+    private CallParticipantList callParticipantList;
+    private SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener;
+
+    @Before
+    public void setUp() {
+        mockedSignalingMessageReceiver = mock(SignalingMessageReceiver.class);
+
+        callParticipantList = new CallParticipantList(mockedSignalingMessageReceiver);
+
+        // Get internal ParticipantListMessageListener from callParticipantList set in the
+        // mockedSignalingMessageReceiver.
+        ArgumentCaptor<SignalingMessageReceiver.ParticipantListMessageListener> participantListMessageListenerArgumentCaptor =
+            ArgumentCaptor.forClass(SignalingMessageReceiver.ParticipantListMessageListener.class);
+
+        verify(mockedSignalingMessageReceiver).addListener(participantListMessageListenerArgumentCaptor.capture());
+
+        participantListMessageListener = participantListMessageListenerArgumentCaptor.getValue();
+    }
+
+    @Test
+    public void testDestroy() {
+        callParticipantList.destroy();
+
+        verify(mockedSignalingMessageReceiver).removeListener(participantListMessageListener);
+        verifyNoMoreInteractions(mockedSignalingMessageReceiver);
+    }
+}