Add join/leave banner for group calls.

This commit is contained in:
Alex Hart 2020-12-04 15:41:29 -04:00 committed by Greyson Parrelli
parent 67a3a30d4c
commit 112782ccaf
13 changed files with 637 additions and 27 deletions

View file

@ -41,6 +41,7 @@ import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
@ -74,6 +75,8 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private WebRtcCallView callScreen;
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
@ -215,6 +218,8 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe
private void initializeResources() {
callScreen = findViewById(R.id.callScreen);
callScreen.setControlsListener(new ControlsListener());
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
}
private void initializeViewModel() {
@ -225,6 +230,7 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants);
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();

View file

@ -0,0 +1,138 @@
package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import androidx.collection.LongSparseArray;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Represents the delta between two lists of CallParticipant objects. This is used along with
* {@link CallParticipantsListUpdatePopupWindow} to display in-call notifications to the user
* whenever remote participants leave or reconnect to the call.
*/
public final class CallParticipantListUpdate {
private final Set<Holder> added;
private final Set<Holder> removed;
CallParticipantListUpdate(@NonNull Set<Holder> added, @NonNull Set<Holder> removed) {
this.added = added;
this.removed = removed;
}
public @NonNull Set<Holder> getAdded() {
return added;
}
public @NonNull Set<Holder> getRemoved() {
return removed;
}
public boolean hasNoChanges() {
return added.isEmpty() && removed.isEmpty();
}
public boolean hasSingleChange() {
return added.size() + removed.size() == 1;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CallParticipantListUpdate that = (CallParticipantListUpdate) o;
return added.equals(that.added) && removed.equals(that.removed);
}
@Override
public int hashCode() {
return Objects.hash(added, removed);
}
/**
* Generates a new Update Object for given lists. This new update object will ignore any participants
* that have the demux id set to {@link CallParticipantId#DEFAULT_ID}.
*
* @param oldList The old list of CallParticipants
* @param newList The new (or current) list of CallParticipants
*/
public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List<CallParticipant> oldList,
@NonNull List<CallParticipant> newList)
{
Set<CallParticipantId> primaries = getPrimaries(oldList, newList);
Set<CallParticipantListUpdate.Holder> oldParticipants = Stream.of(oldList)
.filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID)
.map(p -> createHolder(p, primaries.contains(p.getCallParticipantId())))
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Holder> newParticipants = Stream.of(newList)
.filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID)
.map(p -> createHolder(p, primaries.contains(p.getCallParticipantId())))
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Holder> added = SetUtil.difference(newParticipants, oldParticipants);
Set<CallParticipantListUpdate.Holder> removed = SetUtil.difference(oldParticipants, newParticipants);
return new CallParticipantListUpdate(added, removed);
}
static Holder createHolder(@NonNull CallParticipant callParticipant, boolean isPrimary) {
return new Holder(callParticipant.getCallParticipantId(), callParticipant.getRecipient(), isPrimary);
}
private static @NonNull Set<CallParticipantId> getPrimaries(@NonNull List<CallParticipant> oldList, @NonNull List<CallParticipant> newList) {
return Stream.concat(Stream.of(oldList), Stream.of(newList))
.map(CallParticipant::getCallParticipantId)
.distinctBy(CallParticipantId::getRecipientId)
.collect(Collectors.toSet());
}
static final class Holder {
private final CallParticipantId callParticipantId;
private final Recipient recipient;
private final boolean isPrimary;
private Holder(@NonNull CallParticipantId callParticipantId, @NonNull Recipient recipient, boolean isPrimary) {
this.callParticipantId = callParticipantId;
this.recipient = recipient;
this.isPrimary = isPrimary;
}
public @NonNull Recipient getRecipient() {
return recipient;
}
/**
* Denotes whether this was the first detected instance of this recipient when generating an update. See
* {@link CallParticipantListUpdate#computeDeltaUpdate(List, List)}
*/
public boolean isPrimary() {
return isPrimary;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Holder holder = (Holder) o;
return callParticipantId.equals(holder.callParticipantId);
}
@Override
public int hashCode() {
return Objects.hash(callParticipantId);
}
}
}

View file

@ -0,0 +1,184 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupWindow;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.TimeUnit;
public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
private static final long DURATION = TimeUnit.SECONDS.toMillis(2);
private final ViewGroup parent;
private final AvatarImageView avatarImageView;
private final TextView descriptionTextView;
private final Set<CallParticipantListUpdate.Holder> pendingAdditions = new HashSet<>();
private final Set<CallParticipantListUpdate.Holder> pendingRemovals = new HashSet<>();
public CallParticipantsListUpdatePopupWindow(@NonNull ViewGroup parent) {
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_participant_list_update, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewUtil.dpToPx(94));
this.parent = parent;
this.avatarImageView = getContentView().findViewById(R.id.avatar);
this.descriptionTextView = getContentView().findViewById(R.id.description);
setOnDismissListener(this::showPending);
setAnimationStyle(R.style.PopupAnimation);
}
public void addCallParticipantListUpdate(@NonNull CallParticipantListUpdate update) {
pendingAdditions.addAll(update.getAdded());
pendingAdditions.removeAll(update.getRemoved());
pendingRemovals.addAll(update.getRemoved());
pendingRemovals.removeAll(update.getAdded());
if (!isShowing()) {
showPending();
}
}
private void showPending() {
if (!pendingAdditions.isEmpty()) {
showAdditions();
} else if (!pendingRemovals.isEmpty()) {
showRemovals();
}
}
private void showAdditions() {
setAvatar(getNextRecipient(pendingAdditions.iterator()));
setDescription(pendingAdditions, true);
pendingAdditions.clear();
show();
}
private void showRemovals() {
setAvatar(getNextRecipient(pendingRemovals.iterator()));
setDescription(pendingRemovals, false);
pendingRemovals.clear();
show();
}
private void show() {
showAtLocation(parent, Gravity.TOP | Gravity.START, 0, 0);
measureChild();
update();
getContentView().postDelayed(this::dismiss, DURATION);
}
private void measureChild() {
getContentView().measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
}
private void setAvatar(@Nullable Recipient recipient) {
avatarImageView.setAvatarUsingProfile(recipient);
avatarImageView.setVisibility(recipient == null ? View.GONE : View.VISIBLE);
}
private void setDescription(@NonNull Set<CallParticipantListUpdate.Holder> holders, boolean isAdded) {
if (holders.isEmpty()) {
descriptionTextView.setText("");
} else {
setDescriptionForRecipients(holders, isAdded);
}
}
private void setDescriptionForRecipients(@NonNull Set<CallParticipantListUpdate.Holder> recipients, boolean isAdded) {
Iterator<CallParticipantListUpdate.Holder> iterator = recipients.iterator();
Context context = getContentView().getContext();
String description;
switch (recipients.size()) {
case 0:
throw new IllegalArgumentException("Recipients must contain 1 or more entries");
case 1:
description = context.getString(getOneMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator));
break;
case 2:
description = context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator));
break;
case 3:
description = context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator), getNextDisplayName(iterator));
break;
default:
description = context.getString(getManyMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator), recipients.size() - 2);
}
descriptionTextView.setText(description);
}
private @NonNull Recipient getNextRecipient(@NonNull Iterator<CallParticipantListUpdate.Holder> holderIterator) {
return holderIterator.next().getRecipient();
}
private @NonNull String getNextDisplayName(@NonNull Iterator<CallParticipantListUpdate.Holder> holderIterator) {
CallParticipantListUpdate.Holder holder = holderIterator.next();
Recipient recipient = holder.getRecipient();
if (recipient.isSelf()) {
return getContentView().getContext().getString(R.string.CallParticipantsListUpdatePopupWindow__you_on_another_device);
} else if (holder.isPrimary()) {
return recipient.getDisplayName(getContentView().getContext());
} else {
return getContentView().getContext().getString(R.string.CallParticipantsListUpdatePopupWindow__s_on_another_device,
recipient.getDisplayName(getContentView().getContext()));
}
}
private static @StringRes int getOneMemberDescriptionResourceId(boolean isAdded) {
if (isAdded) {
return R.string.CallParticipantsListUpdatePopupWindow__s_joined;
} else {
return R.string.CallParticipantsListUpdatePopupWindow__s_left;
}
}
private static @StringRes int getTwoMemberDescriptionResourceId(boolean isAdded) {
if (isAdded) {
return R.string.CallParticipantsListUpdatePopupWindow__s_and_s_joined;
} else {
return R.string.CallParticipantsListUpdatePopupWindow__s_and_s_left;
}
}
private static @StringRes int getThreeMemberDescriptionResourceId(boolean isAdded) {
if (isAdded) {
return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_joined;
} else {
return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_left;
}
}
private static @StringRes int getManyMemberDescriptionResourceId(boolean isAdded) {
if (isAdded) {
return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_joined;
} else {
return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_left;
}
}
}

View file

@ -10,7 +10,10 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -18,6 +21,9 @@ import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List;
public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
@ -28,6 +34,7 @@ public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final MutableLiveData<CallParticipantsState> participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
@ -36,6 +43,7 @@ public class WebRtcCallViewModel extends ViewModel {
private boolean answerWithVideoAvailable = false;
private Runnable elapsedTimeRunnable = this::handleTick;
private boolean canEnterPipMode = false;
private List<CallParticipant> previousParticipantsList = Collections.emptyList();
private final WebRtcCallRepository repository = new WebRtcCallRepository();
@ -67,6 +75,10 @@ public class WebRtcCallViewModel extends ViewModel {
return participantsState;
}
public LiveData<CallParticipantListUpdate> getCallParticipantListUpdate() {
return callParticipantListUpdate;
}
public boolean canEnterPipMode() {
return canEnterPipMode;
}
@ -104,6 +116,15 @@ public class WebRtcCallViewModel extends ViewModel {
//noinspection ConstantConditions
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), webRtcViewModel, enableVideo));
if (webRtcViewModel.getGroupState().isConnected()) {
if (!containsPlaceholders(previousParticipantsList)) {
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantsList, webRtcViewModel.getRemoteParticipants());
callParticipantListUpdate.setValue(update);
}
previousParticipantsList = webRtcViewModel.getRemoteParticipants();
}
updateWebRtcControls(webRtcViewModel.getState(),
webRtcViewModel.getGroupState(),
localParticipant.getCameraState().isEnabled(),
@ -135,6 +156,10 @@ public class WebRtcCallViewModel extends ViewModel {
}
}
private boolean containsPlaceholders(@NonNull List<CallParticipant> callParticipants) {
return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID);
}
private void updateWebRtcControls(@NonNull WebRtcViewModel.State state,
@NonNull WebRtcViewModel.GroupCallState groupState,
boolean isLocalVideoEnabled,

View file

@ -12,8 +12,9 @@ import java.util.Objects;
public final class CallParticipant {
public static final CallParticipant EMPTY = createRemote(Recipient.UNKNOWN, null, new BroadcastVideoSink(null), false, false, 0, true);
public static final CallParticipant EMPTY = createRemote(new CallParticipantId(Recipient.UNKNOWN), Recipient.UNKNOWN, null, new BroadcastVideoSink(null), false, false, 0, true);
private final @NonNull CallParticipantId callParticipantId;
private final @NonNull CameraState cameraState;
private final @NonNull Recipient recipient;
private final @Nullable IdentityKey identityKey;
@ -27,7 +28,8 @@ public final class CallParticipant {
@NonNull BroadcastVideoSink renderer,
boolean microphoneEnabled)
{
return new CallParticipant(Recipient.self(),
return new CallParticipant(new CallParticipantId(Recipient.self()),
Recipient.self(),
null,
renderer,
cameraState,
@ -37,7 +39,8 @@ public final class CallParticipant {
true);
}
public static @NonNull CallParticipant createRemote(@NonNull Recipient recipient,
public static @NonNull CallParticipant createRemote(@NonNull CallParticipantId callParticipantId,
@NonNull Recipient recipient,
@Nullable IdentityKey identityKey,
@NonNull BroadcastVideoSink renderer,
boolean audioEnabled,
@ -45,10 +48,11 @@ public final class CallParticipant {
long lastSpoke,
boolean mediaKeysReceived)
{
return new CallParticipant(recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, audioEnabled, lastSpoke, mediaKeysReceived);
return new CallParticipant(callParticipantId, recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, audioEnabled, lastSpoke, mediaKeysReceived);
}
private CallParticipant(@NonNull Recipient recipient,
private CallParticipant(@NonNull CallParticipantId callParticipantId,
@NonNull Recipient recipient,
@Nullable IdentityKey identityKey,
@NonNull BroadcastVideoSink videoSink,
@NonNull CameraState cameraState,
@ -57,6 +61,7 @@ public final class CallParticipant {
long lastSpoke,
boolean mediaKeysReceived)
{
this.callParticipantId = callParticipantId;
this.recipient = recipient;
this.identityKey = identityKey;
this.videoSink = videoSink;
@ -68,11 +73,15 @@ public final class CallParticipant {
}
public @NonNull CallParticipant withIdentityKey(@NonNull IdentityKey identityKey) {
return new CallParticipant(recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
return new CallParticipant(callParticipantId, recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
}
public @NonNull CallParticipant withVideoEnabled(boolean videoEnabled) {
return new CallParticipant(recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
return new CallParticipant(callParticipantId, recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
}
public @NonNull CallParticipantId getCallParticipantId() {
return callParticipantId;
}
public @NonNull Recipient getRecipient() {
@ -123,7 +132,8 @@ public final class CallParticipant {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CallParticipant that = (CallParticipant) o;
return videoEnabled == that.videoEnabled &&
return callParticipantId.equals(that.callParticipantId) &&
videoEnabled == that.videoEnabled &&
microphoneEnabled == that.microphoneEnabled &&
lastSpoke == that.lastSpoke &&
mediaKeysReceived == that.mediaKeysReceived &&
@ -135,7 +145,7 @@ public final class CallParticipant {
@Override
public int hashCode() {
return Objects.hash(cameraState, recipient, identityKey, videoSink, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
return Objects.hash(callParticipantId, cameraState, recipient, identityKey, videoSink, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived);
}
@Override

View file

@ -14,7 +14,7 @@ import java.util.Objects;
*/
public final class CallParticipantId {
private static final long DEFAULT_ID = -1;
public static final long DEFAULT_ID = -1;
private final long demuxId;
private final RecipientId recipientId;

View file

@ -8,6 +8,7 @@ import org.signal.ringrtc.CallException;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
@ -41,6 +42,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
.putRemotePeer(remotePeer)
.putParticipant(remotePeer.getRecipient(),
CallParticipant.createRemote(
new CallParticipantId(remotePeer.getRecipient()),
remotePeer.getRecipient(),
null,
new BroadcastVideoSink(currentState.getVideoState().getEglBase()),
@ -82,6 +84,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
.callState(WebRtcViewModel.State.CALL_INCOMING)
.putParticipant(remotePeer.getRecipient(),
CallParticipant.createRemote(
new CallParticipantId(remotePeer.getRecipient()),
remotePeer.getRecipient(),
null,
new BroadcastVideoSink(currentState.getVideoState().getEglBase()),

View file

@ -80,7 +80,8 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor {
}
builder.putParticipant(callParticipantId,
CallParticipant.createRemote(recipient,
CallParticipant.createRemote(callParticipantId,
recipient,
null,
videoSink,
Boolean.FALSE.equals(device.getAudioMuted()),

View file

@ -12,6 +12,7 @@ import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -114,7 +115,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
.changeCallInfoState();
for (Recipient recipient : callParticipants) {
builder.putParticipant(recipient, CallParticipant.createRemote(recipient, null, new BroadcastVideoSink(null), true, true, 0, false));
builder.putParticipant(recipient, CallParticipant.createRemote(new CallParticipantId(recipient), recipient, null, new BroadcastVideoSink(null), true, true, 0, false));
}
return builder.build();

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/core_grey_80" />
<corners android:radius="8dp" />
</shape>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="94dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginStart="12dp"
android:layout_marginTop="30dp"
android:layout_marginEnd="12dp"
android:background="@drawable/call_participant_update_window_background">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@drawable/circle_ultramarine"
tools:src="@drawable/ic_profile_80"
tools:visibility="visible" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:height="0dp"
android:gravity="center_vertical"
android:textAppearance="@style/TextAppearance.Signal.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -2967,6 +2967,18 @@
<string name="ReviewCard__delete">Delete</string>
<string name="ReviewCard__recently_changed">Recently changed their profile name from %1$s to %2$s</string>
<!-- CallParticipantsListUpdatePopupWindow -->
<string name="CallParticipantsListUpdatePopupWindow__s_joined">%1$s joined</string>
<string name="CallParticipantsListUpdatePopupWindow__s_and_s_joined">%1$s and %2$s joined</string>
<string name="CallParticipantsListUpdatePopupWindow__s_s_and_s_joined">%1$s, %2$s and %3$s joined</string>
<string name="CallParticipantsListUpdatePopupWindow__s_s_and_d_others_joined">%1$s, %2$s and %3$d others joined</string>
<string name="CallParticipantsListUpdatePopupWindow__s_left">%1$s left</string>
<string name="CallParticipantsListUpdatePopupWindow__s_and_s_left">%1$s and %2$s left</string>
<string name="CallParticipantsListUpdatePopupWindow__s_s_and_s_left">%1$s, %2$s and %3$s left</string>
<string name="CallParticipantsListUpdatePopupWindow__s_s_and_d_others_left">%1$s, %2$s and %3$d others left</string>
<string name="CallParticipantsListUpdatePopupWindow__you_on_another_device">You (on another device)</string>
<string name="CallParticipantsListUpdatePopupWindow__s_on_another_device">%1$s (on another device)</string>
<!-- EOF -->
</resources>

View file

@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.components.webrtc;
import com.annimon.stream.Stream;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientDetails;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
public class CallParticipantListUpdateTest {
@Test
public void givenEmptySets_thenExpectNoChanges() {
// GIVEN
Set<CallParticipantListUpdate.Holder> added = Collections.emptySet();
Set<CallParticipantListUpdate.Holder> removed = Collections.emptySet();
CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed);
// THEN
assertTrue(update.hasNoChanges());
assertFalse(update.hasSingleChange());
}
@Test
public void givenOneEmptySet_thenExpectMultipleChanges() {
// GIVEN
Set<CallParticipantListUpdate.Holder> added = new HashSet<>(Arrays.asList(createHolders(1, 2, 3)));
Set<CallParticipantListUpdate.Holder> removed = Collections.emptySet();
CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed);
// THEN
assertFalse(update.hasNoChanges());
assertFalse(update.hasSingleChange());
}
@Test
public void givenNoEmptySets_thenExpectMultipleChanges() {
// GIVEN
Set<CallParticipantListUpdate.Holder> added = new HashSet<>(Arrays.asList(createHolders(1, 2, 3)));
Set<CallParticipantListUpdate.Holder> removed = new HashSet<>(Arrays.asList(createHolders(4, 5, 6)));
CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed);
// THEN
assertFalse(update.hasNoChanges());
assertFalse(update.hasSingleChange());
}
@Test
public void givenOneSetWithSingleItemAndAnEmptySet_thenExpectSingleChange() {
// GIVEN
Set<CallParticipantListUpdate.Holder> added = new HashSet<>(Arrays.asList(createHolders(1)));
Set<CallParticipantListUpdate.Holder> removed = Collections.emptySet();
CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed);
// THEN
assertFalse(update.hasNoChanges());
assertTrue(update.hasSingleChange());
}
@Test
public void whenFirstListIsAdded_thenIExpectAnUpdateWithAllItemsFromListAdded() {
// GIVEN
List<CallParticipant> newList = createParticipants(1, 2, 3, 4, 5);
// WHEN
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(Collections.emptyList(), newList);
// THEN
assertFalse(update.hasNoChanges());
assertTrue(update.getRemoved().isEmpty());
assertThat(update.getAdded(), Matchers.containsInAnyOrder(createHolders(1, 2, 3, 4, 5)));
}
@Test
public void whenSameListIsAddedTwiceInARowWithinTimeout_thenIExpectAnEmptyUpdate() {
// GIVEN
List<CallParticipant> newList = createParticipants(1, 2, 3, 4, 5);
// WHEN
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(newList, newList);
// THEN
assertTrue(update.hasNoChanges());
}
@Test
public void whenPlaceholdersAreUsed_thenIExpectAnEmptyUpdate() {
// GIVEN
List<CallParticipant> newList = createPlaceholderParticipants(1, 2, 3, 4, 5);
// WHEN
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(Collections.emptyList(), newList);
// THEN
assertTrue(update.hasNoChanges());
}
@Test
public void whenNewListIsAdded_thenIExpectAReducedUpdate() {
// GIVEN
List<CallParticipant> list1 = createParticipants(1, 2, 3, 4, 5);
List<CallParticipant> list2 = createParticipants(2, 3, 4, 5, 6);
// WHEN
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(list1, list2);
// THEN
assertFalse(update.hasNoChanges());
assertThat(update.getAdded(), Matchers.containsInAnyOrder(createHolders(6)));
assertThat(update.getRemoved(), Matchers.containsInAnyOrder(createHolders(1)));
}
@Test
public void whenRecipientExistsMultipleTimes_thenIExpectOneInstancePrimaryAndOthersSecondary() {
// GIVEN
List<CallParticipant> list = createParticipants(new long[]{1, 1, 1}, new long[]{1, 2, 3});
// WHEN
CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(Collections.emptyList(), list);
// THEN
List<Boolean> isPrimaryList = Stream.of(update.getAdded()).map(CallParticipantListUpdate.Holder::isPrimary).toList();
assertThat(isPrimaryList, Matchers.containsInAnyOrder(true, false, false));
}
static CallParticipantListUpdate.Holder[] createHolders(long ... recipientIds) {
CallParticipantListUpdate.Holder[] ids = new CallParticipantListUpdate.Holder[recipientIds.length];
for (int i = 0; i < recipientIds.length; i++) {
CallParticipant participant = createParticipant(recipientIds[i], recipientIds[i]);
ids[i] = CallParticipantListUpdate.createHolder(participant, true);
}
return ids;
}
private static List<CallParticipant> createPlaceholderParticipants(long ... recipientIds) {
long[] deMuxIds = new long[recipientIds.length];
Arrays.fill(deMuxIds, -1);
return createParticipants(recipientIds, deMuxIds);
}
private static List<CallParticipant> createParticipants(long ... recipientIds) {
return createParticipants(recipientIds, recipientIds);
}
private static List<CallParticipant> createParticipants(long[] recipientIds, long[] placeholderIds) {
List<CallParticipant> participants = new ArrayList<>(recipientIds.length);
for (int i = 0; i < recipientIds.length; i++) {
participants.add(createParticipant(recipientIds[i], placeholderIds[i]));
}
return participants;
}
private static CallParticipant createParticipant(long recipientId, long deMuxId) {
Recipient recipient = new Recipient(RecipientId.from(recipientId), mock(RecipientDetails.class), true);
return CallParticipant.createRemote(new CallParticipantId(deMuxId, recipient.getId()), recipient, null, new BroadcastVideoSink(null), false, false, -1, false);
}
}