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

@@ -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,24 +21,29 @@ 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);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
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 MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
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;
private long callConnectedTime = -1;
private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
private boolean answerWithVideoAvailable = false;
private Runnable elapsedTimeRunnable = this::handleTick;
private boolean canEnterPipMode = false;
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
private long callConnectedTime = -1;
private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
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,