diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java index 5003a5f8a..3ae33fa94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection; import java.util.ArrayList; import java.util.Collections; @@ -29,7 +30,7 @@ public final class CallParticipantsState { public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED, WebRtcViewModel.GroupCallState.IDLE, - Collections.emptyList(), + new ParticipantCollection(SMALL_GROUP_MAX), CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(null), false), null, WebRtcLocalRenderState.GONE, @@ -40,7 +41,7 @@ public final class CallParticipantsState { private final WebRtcViewModel.State callState; private final WebRtcViewModel.GroupCallState groupCallState; - private final List remoteParticipants; + private final ParticipantCollection remoteParticipants; private final CallParticipant localParticipant; private final CallParticipant focusedParticipant; private final WebRtcLocalRenderState localRenderState; @@ -51,7 +52,7 @@ public final class CallParticipantsState { public CallParticipantsState(@NonNull WebRtcViewModel.State callState, @NonNull WebRtcViewModel.GroupCallState groupCallState, - @NonNull List remoteParticipants, + @NonNull ParticipantCollection remoteParticipants, @NonNull CallParticipant localParticipant, @Nullable CallParticipant focusedParticipant, @NonNull WebRtcLocalRenderState localRenderState, @@ -81,11 +82,7 @@ public final class CallParticipantsState { } public @NonNull List getGridParticipants() { - if (getAllRemoteParticipants().size() > SMALL_GROUP_MAX) { - return getAllRemoteParticipants().subList(0, SMALL_GROUP_MAX); - } else { - return getAllRemoteParticipants(); - } + return remoteParticipants.getGridParticipants(); } public @NonNull List getListParticipants() { @@ -94,14 +91,11 @@ public final class CallParticipantsState { if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) { listParticipants.addAll(getAllRemoteParticipants()); listParticipants.remove(focusedParticipant); - } else if (getAllRemoteParticipants().size() > SMALL_GROUP_MAX) { - listParticipants.addAll(getAllRemoteParticipants().subList(SMALL_GROUP_MAX, getAllRemoteParticipants().size())); } else { - return Collections.emptyList(); + listParticipants.addAll(remoteParticipants.getListParticipants()); } listParticipants.add(CallParticipant.EMPTY); - Collections.reverse(listParticipants); return listParticipants; @@ -132,7 +126,7 @@ public final class CallParticipantsState { } public @NonNull List getAllRemoteParticipants() { - return remoteParticipants; + return remoteParticipants.getAllParticipants(); } public @NonNull CallParticipant getLocalParticipant() { @@ -196,7 +190,7 @@ public final class CallParticipantsState { return new CallParticipantsState(webRtcViewModel.getState(), webRtcViewModel.getGroupState(), - webRtcViewModel.getRemoteParticipants(), + oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()), webRtcViewModel.getLocalParticipant(), focused, localRenderState, diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java new file mode 100644 index 000000000..fa32e7d99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service.webrtc.collections; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; + +import com.annimon.stream.ComparatorCompat; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Represents the participants to be displayed in the grid at any given time. + */ +public class ParticipantCollection { + + private static final Comparator LEAST_RECENTLY_ADDED = (a, b) -> Long.compare(a.getAddedToCallTime(), b.getAddedToCallTime()); + private static final Comparator MOST_RECENTLY_SPOKEN = (a, b) -> Long.compare(b.getLastSpoke(), a.getLastSpoke()); + private static final Comparator MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED = ComparatorCompat.chain(MOST_RECENTLY_SPOKEN).thenComparing(LEAST_RECENTLY_ADDED); + + private final int maxGridCellCount; + private final List participants; + + public ParticipantCollection(int maxGridCellCount) { + this(maxGridCellCount, Collections.emptyList()); + } + + private ParticipantCollection(int maxGridCellCount, @NonNull List callParticipants) { + this.maxGridCellCount = maxGridCellCount; + this.participants = Collections.unmodifiableList(callParticipants); + } + + @CheckResult + public @NonNull ParticipantCollection getNext(@NonNull List participants) { + if (participants.isEmpty()) { + return new ParticipantCollection(maxGridCellCount); + } else if (this.participants.isEmpty()) { + List newParticipants = new ArrayList<>(participants); + Collections.sort(newParticipants, participants.size() <= maxGridCellCount ? LEAST_RECENTLY_ADDED : MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED); + + return new ParticipantCollection(maxGridCellCount, newParticipants); + } else { + List newParticipants = new ArrayList<>(participants); + Collections.sort(newParticipants, MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED); + + List oldGridParticipantIds = Stream.of(getGridParticipants()) + .map(CallParticipant::getCallParticipantId) + .toList(); + + for (int i = 0; i < oldGridParticipantIds.size(); i++) { + CallParticipantId oldId = oldGridParticipantIds.get(i); + + int newIndex = Stream.of(newParticipants) + .takeUntilIndexed((j, p) -> j >= maxGridCellCount) + .map(CallParticipant::getCallParticipantId) + .toList() + .indexOf(oldId); + + if (newIndex != -1 && newIndex != i) { + Collections.swap(newParticipants, newIndex, i); + } + } + + return new ParticipantCollection(maxGridCellCount, newParticipants); + } + } + + public List getGridParticipants() { + return participants.size() > maxGridCellCount + ? Collections.unmodifiableList(participants.subList(0, maxGridCellCount)) + : Collections.unmodifiableList(participants); + } + + public List getListParticipants() { + return participants.size() > maxGridCellCount + ? Collections.unmodifiableList(participants.subList(maxGridCellCount, participants.size())) + : Collections.emptyList(); + } + + public boolean isEmpty() { + return participants.isEmpty(); + } + + public List getAllParticipants() { + return participants; + } + + public int size() { + return participants.size(); + } + + public @NonNull CallParticipant get(int i) { + return participants.get(i); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollectionTest.java b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollectionTest.java new file mode 100644 index 000000000..6ee9ae72d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollectionTest.java @@ -0,0 +1,218 @@ +package org.thoughtcrime.securesms.service.webrtc.collections; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +public class ParticipantCollectionTest { + + private final ParticipantCollection testSubject = new ParticipantCollection(3); + + @Test + public void givenAnEmptyCollection_whenIAdd3Participants_thenIExpectThemToBeOrderedByAddedToCallTime() { + // GIVEN + List input = Arrays.asList(participant(1, 1, 4), participant(2, 1, 2), participant(3, 1, 3)); + + // WHEN + ParticipantCollection result = testSubject.getNext(input); + + // THEN + assertThat(result.getGridParticipants(), Matchers.contains(id(2), id(3), id(1))); + } + + @Test + public void givenAnEmptyCollection_whenIAdd3Participants_thenIExpectNoListParticipants() { + // GIVEN + List input = Arrays.asList(participant(1, 1, 4), participant(2, 1, 2), participant(3, 1, 3)); + + // WHEN + ParticipantCollection result = testSubject.getNext(input); + + // THEN + assertEquals(result.getListParticipants().size(), 0); + } + + @Test + public void givenAnEmptyColletion_whenIAdd4Participants_thenIExpectThemToBeOrderedByLastSpokenThenAddedToCallTime() { + // GIVEN + List input = Arrays.asList(participant(1, 1, 2), + participant(2, 5, 2), + participant(3, 1, 1), + participant(4, 1, 0)); + + // WHEN + ParticipantCollection result = testSubject.getNext(input); + + // THEN + assertThat(result.getGridParticipants(), Matchers.contains(id(2), id(4), id(3))); + } + + @Test + public void givenACollection_whenIUpdateWithEmptyList_thenIExpectEmptyList() { + // GIVEN + List initial = Arrays.asList(participant(1, 1, 2), participant(2, 1, 3), participant(3, 1, 4)); + ParticipantCollection initialCollection = testSubject.getNext(initial); + List next = Collections.emptyList(); + + // WHEN + ParticipantCollection result = initialCollection.getNext(next); + + // THEN + assertEquals(0, result.getGridParticipants().size()); + } + + @Test + public void givenACollection_whenIUpdateWithLatestSpeakerAndSpeakerIsAlreadyInGridSection_thenIExpectTheSameGridSectionOrder() { + // GIVEN + List initial = Arrays.asList(participant(1, 1, 2), participant(2, 1, 3), participant(3, 1, 4)); + ParticipantCollection initialCollection = testSubject.getNext(initial); + List next = Arrays.asList(participant(1, 1, 2), participant(2, 2, 3), participant(3, 1, 4)); + + // WHEN + ParticipantCollection result = initialCollection.getNext(next); + + // THEN + assertThat(result.getGridParticipants(), Matchers.contains(id(1), id(2), id(3))); + } + + @Test + public void bigTest() { + + // Welcome to the Thunder dome. 10 people enter... + + ParticipantCollection testSubject = new ParticipantCollection(6); + List init = Arrays.asList(participant(1, 1, 1), // Alice + participant(2, 1, 1), // Bob + participant(3, 1, 1), // Charlie + participant(4, 1, 1), // Diane + participant(5, 1, 1), // Ethel + participant(6, 1, 1), // Francis + participant(7, 1, 1), // Georgina + participant(8, 1, 1), // Henry + participant(9, 1, 1), // Ignace + participant(10, 1, 1)); // Jericho + + ParticipantCollection initialCollection = testSubject.getNext(init); + + assertThat(initialCollection.getGridParticipants(), Matchers.contains(id(1), id(2), id(3), id(4), id(5), id(6))); + assertThat(initialCollection.getListParticipants(), Matchers.contains(id(7), id(8), id(9), id(10))); + + // Bob speaks about his trip to antigua... + + List bobSpoke = Arrays.asList(participant(1, 1, 1), + participant(2, 2, 1), + participant(3, 1, 1), + participant(4, 1, 1), + participant(5, 1, 1), + participant(6, 1, 1), + participant(7, 1, 1), + participant(8, 1, 1), + participant(9, 1, 1), + participant(10, 1, 1)); + + ParticipantCollection afterBobSpoke = initialCollection.getNext(bobSpoke); + + assertThat(afterBobSpoke.getGridParticipants(), Matchers.contains(id(1), id(2), id(3), id(4), id(5), id(6))); + assertThat(afterBobSpoke.getListParticipants(), Matchers.contains(id(7), id(8), id(9), id(10))); + + // Henry interjects and says now is not the time, this is the thunderdome. + + List henrySpoke = Arrays.asList(participant(1, 1, 1), + participant(2, 2, 1), + participant(3, 1, 1), + participant(4, 1, 1), + participant(5, 1, 1), + participant(6, 1, 1), + participant(7, 1, 1), + participant(8, 3, 1), + participant(9, 1, 1), + participant(10, 1, 1)); + + ParticipantCollection afterHenrySpoke = afterBobSpoke.getNext(henrySpoke); + + assertThat(afterHenrySpoke.getGridParticipants(), Matchers.contains(id(1), id(2), id(3), id(4), id(5), id(8))); + assertThat(afterHenrySpoke.getListParticipants(), Matchers.contains(id(6), id(7), id(9), id(10))); + + // Ignace asks how everone's holidays were + + List ignaceSpoke = Arrays.asList(participant(1, 1, 1), + participant(2, 2, 1), + participant(3, 1, 1), + participant(4, 1, 1), + participant(5, 1, 1), + participant(6, 1, 1), + participant(7, 1, 1), + participant(8, 3, 1), + participant(9, 4, 1), + participant(10, 1, 1)); + + ParticipantCollection afterIgnaceSpoke = afterHenrySpoke.getNext(ignaceSpoke); + + assertThat(afterIgnaceSpoke.getGridParticipants(), Matchers.contains(id(1), id(2), id(3), id(4), id(9), id(8))); + assertThat(afterIgnaceSpoke.getListParticipants(), Matchers.contains(id(5), id(6), id(7), id(10))); + + // Alice is the first to fall + + List aliceLeft = Arrays.asList(participant(2, 2, 1), + participant(3, 1, 1), + participant(4, 1, 1), + participant(5, 1, 1), + participant(6, 1, 1), + participant(7, 1, 1), + participant(8, 3, 1), + participant(9, 4, 1), + participant(10, 1, 1)); + + ParticipantCollection afterAliceLeft = afterIgnaceSpoke.getNext(aliceLeft); + + assertThat(afterAliceLeft.getGridParticipants(), Matchers.contains(id(5), id(2), id(3), id(4), id(9), id(8))); + assertThat(afterAliceLeft.getListParticipants(), Matchers.contains(id(6), id(7), id(10))); + + // Just kidding, Alice is back. Georgina and Charlie gasp! + + List mixUp = Arrays.asList(participant(1, 1, 5), + participant(2, 2, 1), + participant(3, 6, 1), + participant(4, 1, 1), + participant(5, 1, 1), + participant(6, 1, 1), + participant(7, 5, 1), + participant(8, 3, 1), + participant(9, 4, 1), + participant(10, 1, 1)); + + ParticipantCollection afterMixUp = afterAliceLeft.getNext(mixUp); + + assertThat(afterMixUp.getGridParticipants(), Matchers.contains(id(7), id(2), id(3), id(4), id(9), id(8))); + assertThat(afterMixUp.getListParticipants(), Matchers.contains(id(5), id(6), id(10), id(1))); + } + + private Matcher id(long serializedId) { + return Matchers.hasProperty("callParticipantId", Matchers.equalTo(new CallParticipantId(serializedId, RecipientId.from(serializedId)))); + } + + private static CallParticipant participant(long serializedId,long lastSpoke, long added) { + return CallParticipant.createRemote( + new CallParticipantId(serializedId, RecipientId.from(serializedId)), + Recipient.UNKNOWN, + null, + new BroadcastVideoSink(null), + false, + false, + lastSpoke, + false, + added); + } +} \ No newline at end of file