diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index f2aa2518e4..3f54711d52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java new file mode 100644 index 0000000000..5a49fdc920 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java @@ -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 added; + private final Set removed; + + CallParticipantListUpdate(@NonNull Set added, @NonNull Set removed) { + this.added = added; + this.removed = removed; + } + + public @NonNull Set getAdded() { + return added; + } + + public @NonNull Set 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 oldList, + @NonNull List newList) + { + Set primaries = getPrimaries(oldList, newList); + Set oldParticipants = Stream.of(oldList) + .filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID) + .map(p -> createHolder(p, primaries.contains(p.getCallParticipantId()))) + .collect(Collectors.toSet()); + Set newParticipants = Stream.of(newList) + .filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID) + .map(p -> createHolder(p, primaries.contains(p.getCallParticipantId()))) + .collect(Collectors.toSet()); + Set added = SetUtil.difference(newParticipants, oldParticipants); + Set 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 getPrimaries(@NonNull List oldList, @NonNull List 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java new file mode 100644 index 0000000000..03b351fe77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java @@ -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 pendingAdditions = new HashSet<>(); + private final Set 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 holders, boolean isAdded) { + if (holders.isEmpty()) { + descriptionTextView.setText(""); + } else { + setDescriptionForRecipients(holders, isAdded); + } + } + + private void setDescriptionForRecipients(@NonNull Set recipients, boolean isAdded) { + Iterator 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 holderIterator) { + return holderIterator.next().getRecipient(); + } + + private @NonNull String getNextDisplayName(@NonNull Iterator 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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index eb71997119..79174772dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -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 microphoneEnabled = new MutableLiveData<>(true); - private final MutableLiveData isInPipMode = new MutableLiveData<>(false); - private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); - private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls); - private final SingleLiveEvent events = new SingleLiveEvent(); - private final MutableLiveData elapsed = new MutableLiveData<>(-1L); - private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); - private final MutableLiveData participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE); + private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); + private final MutableLiveData isInPipMode = new MutableLiveData<>(false); + private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); + private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls); + private final SingleLiveEvent events = new SingleLiveEvent(); + private final MutableLiveData elapsed = new MutableLiveData<>(-1L); + private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); + private final MutableLiveData participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE); + private final SingleLiveEvent 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 previousParticipantsList = Collections.emptyList(); private final WebRtcCallRepository repository = new WebRtcCallRepository(); @@ -67,6 +75,10 @@ public class WebRtcCallViewModel extends ViewModel { return participantsState; } + public LiveData 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 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java index fcd8d16dec..56f960d1d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java index b7011cb0f9..bd13a51ce3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java index 0ca5f79ca5..e2dc093287 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -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()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java index 9da955637b..fb1b8a943d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java @@ -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()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java index c4e4f86d91..4be0e74d90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java @@ -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(); diff --git a/app/src/main/res/drawable/call_participant_update_window_background.xml b/app/src/main/res/drawable/call_participant_update_window_background.xml new file mode 100644 index 0000000000..99759155d9 --- /dev/null +++ b/app/src/main/res/drawable/call_participant_update_window_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_participant_list_update.xml b/app/src/main/res/layout/call_participant_list_update.xml new file mode 100644 index 0000000000..c37a0d1d48 --- /dev/null +++ b/app/src/main/res/layout/call_participant_list_update.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d59519ee7a..b35e0d70d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2967,6 +2967,18 @@ Delete Recently changed their profile name from %1$s to %2$s + + %1$s joined + %1$s and %2$s joined + %1$s, %2$s and %3$s joined + %1$s, %2$s and %3$d others joined + %1$s left + %1$s and %2$s left + %1$s, %2$s and %3$s left + %1$s, %2$s and %3$d others left + You (on another device) + %1$s (on another device) + diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdateTest.java b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdateTest.java new file mode 100644 index 0000000000..bdc64cb294 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdateTest.java @@ -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 added = Collections.emptySet(); + Set removed = Collections.emptySet(); + CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed); + + // THEN + assertTrue(update.hasNoChanges()); + assertFalse(update.hasSingleChange()); + } + + @Test + public void givenOneEmptySet_thenExpectMultipleChanges() { + // GIVEN + Set added = new HashSet<>(Arrays.asList(createHolders(1, 2, 3))); + Set removed = Collections.emptySet(); + CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed); + + // THEN + assertFalse(update.hasNoChanges()); + assertFalse(update.hasSingleChange()); + } + + @Test + public void givenNoEmptySets_thenExpectMultipleChanges() { + // GIVEN + Set added = new HashSet<>(Arrays.asList(createHolders(1, 2, 3))); + Set 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 added = new HashSet<>(Arrays.asList(createHolders(1))); + Set removed = Collections.emptySet(); + CallParticipantListUpdate update = new CallParticipantListUpdate(added, removed); + + // THEN + assertFalse(update.hasNoChanges()); + assertTrue(update.hasSingleChange()); + } + + @Test + public void whenFirstListIsAdded_thenIExpectAnUpdateWithAllItemsFromListAdded() { + // GIVEN + List 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 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 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 list1 = createParticipants(1, 2, 3, 4, 5); + List 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 list = createParticipants(new long[]{1, 1, 1}, new long[]{1, 2, 3}); + + // WHEN + CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(Collections.emptyList(), list); + + // THEN + List 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 createPlaceholderParticipants(long ... recipientIds) { + long[] deMuxIds = new long[recipientIds.length]; + Arrays.fill(deMuxIds, -1); + return createParticipants(recipientIds, deMuxIds); + } + + private static List createParticipants(long ... recipientIds) { + return createParticipants(recipientIds, recipientIds); + } + + private static List createParticipants(long[] recipientIds, long[] placeholderIds) { + List 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); + } + +} \ No newline at end of file