Add join/leave banner for group calls.
This commit is contained in:
parent
67a3a30d4c
commit
112782ccaf
13 changed files with 637 additions and 27 deletions
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
47
app/src/main/res/layout/call_participant_list_update.xml
Normal file
47
app/src/main/res/layout/call_participant_list_update.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Reference in a new issue