Clean up old calling code.

This commit is contained in:
Alex Hart
2026-01-13 09:31:59 -04:00
committed by Michelle Tang
parent fd32ec9598
commit 1f7e9df7ff
70 changed files with 402 additions and 6425 deletions

View File

@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.components.webrtc
/**
* This is an interface for [WebRtcAudioPicker31] and [WebRtcAudioPickerLegacy] to reference methods in [WebRtcAudioOutputToggleButton] without actually depending on it.
* This is an interface for [WebRtcAudioPicker31] and [WebRtcAudioPickerLegacy] as a callback for [org.thoughtcrime.securesms.components.webrtc.v2.CallAudioToggleButton]
*/
interface AudioStateUpdater {
fun updateAudioOutputState(audioOutput: WebRtcAudioOutput)

View File

@@ -1,74 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.graphics.Rect
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.PopupWindowCompat
import androidx.fragment.app.FragmentActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.visible
/**
* A popup window for calls that holds extra actions, such as reactions, raise hand, and screen sharing.
*
*/
class CallOverflowPopupWindow(private val activity: FragmentActivity, parentViewGroup: ViewGroup, private val raisedHandDelegate: RaisedHandDelegate) : PopupWindow(
LayoutInflater.from(activity).inflate(R.layout.call_overflow_holder, parentViewGroup, false),
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_width).toInt(),
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
) {
private val raiseHandLabel: TextView = (contentView as LinearLayout).findViewById(R.id.raise_hand_label)
init {
val root = (contentView as LinearLayout)
val reactionScrubber = root.findViewById<CallReactionScrubber>(R.id.reaction_scrubber)
reactionScrubber.initialize(activity.supportFragmentManager) {
AppDependencies.signalCallManager.react(it)
dismiss()
}
val raiseHand = root.findViewById<ConstraintLayout>(R.id.raise_hand_layout_parent)
raiseHand.visible = true
raiseHand.setOnClickListener {
AppDependencies.signalCallManager.raiseHand(!raisedHandDelegate.isSelfHandRaised())
dismiss()
}
}
fun show(anchor: View) {
isFocusable = true
val resources = activity.resources
val margin = resources.getDimension(R.dimen.calling_reaction_scrubber_margin).toInt()
val windowRect = Rect()
contentView.getWindowVisibleDisplayFrame(windowRect)
val windowWidth = windowRect.width()
val popupWidth = resources.getDimension(R.dimen.reaction_scrubber_width).toInt()
val popupHeight = resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
val xOffset = windowWidth - popupWidth - margin
val yOffset = -popupHeight - margin
raiseHandLabel.setText(if (raisedHandDelegate.isSelfHandRaised()) R.string.CallOverflowPopupWindow__lower_hand else R.string.CallOverflowPopupWindow__raise_hand)
PopupWindowCompat.showAsDropDown(this, anchor, xOffset, yOffset, Gravity.NO_GRAVITY)
}
fun interface RaisedHandDelegate {
fun isSelfHandRaised(): Boolean
}
}

View File

@@ -6,9 +6,9 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.signal.core.util.SetUtil;
import java.util.List;
import java.util.Objects;
@@ -16,7 +16,7 @@ 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
* {@link org.thoughtcrime.securesms.components.webrtc.v2.CallParticipantUpdatePopupKt} to display in-call notifications to the user
* whenever remote participants leave or reconnect to the call.
*/
public final class CallParticipantListUpdate {

View File

@@ -1,465 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.ImageViewCompat;
import androidx.transition.Transition;
import androidx.transition.TransitionManager;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
import org.whispersystems.signalservice.api.util.Preconditions;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Encapsulates views needed to show a call participant including their
* avatar in full screen or pip mode, and their video feed.
*/
public class CallParticipantView extends ConstraintLayout {
private static final long DELAY_SHOWING_MISSING_MEDIA_KEYS = TimeUnit.SECONDS.toMillis(5);
private static final int SMALL_AVATAR = ViewUtil.dpToPx(96);
private static final int LARGE_AVATAR = ViewUtil.dpToPx(112);
private RecipientId recipientId;
private boolean infoMode;
private boolean raiseHandAllowed;
private Runnable missingMediaKeysUpdater;
private boolean shouldRenderInPip;
private SelfPipMode selfPipMode = SelfPipMode.NOT_SELF_PIP;
private AppCompatImageView backgroundAvatar;
private AvatarImageView avatar;
private BadgeImageView badge;
private View rendererFrame;
private TextureViewRenderer renderer;
private ImageView pipAvatar;
private BadgeImageView pipBadge;
private ContactPhoto contactPhoto;
private AudioIndicatorView audioIndicator;
private View infoOverlay;
private EmojiTextView infoMessage;
private Button infoMoreInfo;
private AppCompatImageView infoIcon;
private View switchCameraIconFrame;
private View switchCameraIcon;
private ImageView raiseHandIcon;
private TextView nameLabel;
public CallParticipantView(@NonNull Context context) {
super(context);
onFinishInflate();
}
public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
backgroundAvatar = findViewById(R.id.call_participant_background_avatar);
avatar = findViewById(R.id.call_participant_item_avatar);
pipAvatar = findViewById(R.id.call_participant_item_pip_avatar);
rendererFrame = findViewById(R.id.call_participant_renderer_frame);
renderer = findViewById(R.id.call_participant_renderer);
audioIndicator = findViewById(R.id.call_participant_audio_indicator);
infoOverlay = findViewById(R.id.call_participant_info_overlay);
infoIcon = findViewById(R.id.call_participant_info_icon);
infoMessage = findViewById(R.id.call_participant_info_message);
infoMoreInfo = findViewById(R.id.call_participant_info_more_info);
badge = findViewById(R.id.call_participant_item_badge);
pipBadge = findViewById(R.id.call_participant_item_pip_badge);
switchCameraIconFrame = findViewById(R.id.call_participant_switch_camera);
switchCameraIcon = findViewById(R.id.call_participant_switch_camera_icon);
raiseHandIcon = findViewById(R.id.call_participant_raise_hand_icon);
nameLabel = findViewById(R.id.call_participant_name_label);
useLargeAvatar();
}
public void setMirror(boolean mirror) {
renderer.setMirror(mirror);
}
public void setScalingType(@NonNull RendererCommon.ScalingType scalingType) {
renderer.setScalingType(scalingType);
}
void setScalingType(@NonNull RendererCommon.ScalingType scalingTypeMatchOrientation, @NonNull RendererCommon.ScalingType scalingTypeMismatchOrientation) {
renderer.setScalingType(scalingTypeMatchOrientation, scalingTypeMismatchOrientation);
}
public void setCallParticipant(@NonNull CallParticipant participant) {
boolean participantChanged = recipientId == null || !recipientId.equals(participant.getRecipient().getId());
recipientId = participant.getRecipient().getId();
infoMode = participant.getRecipient().isBlocked() || isMissingMediaKeys(participant);
if (infoMode) {
rendererFrame.setVisibility(View.GONE);
renderer.setVisibility(View.GONE);
renderer.attachBroadcastVideoSink(null);
audioIndicator.setVisibility(View.GONE);
avatar.setVisibility(View.GONE);
badge.setVisibility(View.GONE);
pipAvatar.setVisibility(View.GONE);
pipBadge.setVisibility(View.GONE);
infoOverlay.setVisibility(View.VISIBLE);
ImageViewCompat.setImageTintList(infoIcon, ContextCompat.getColorStateList(getContext(), R.color.core_white));
if (participant.getRecipient().isBlocked()) {
infoIcon.setImageResource(R.drawable.ic_block_tinted_24);
infoMessage.setText(getContext().getString(R.string.CallParticipantView__s_is_blocked, participant.getRecipient().getShortDisplayName(getContext())));
infoMoreInfo.setOnClickListener(v -> showBlockedDialog(participant.getRecipient()));
} else {
infoIcon.setImageResource(R.drawable.ic_error_solid_24);
infoMessage.setText(getContext().getString(R.string.CallParticipantView__cant_receive_audio_video_from_s, participant.getRecipient().getShortDisplayName(getContext())));
infoMoreInfo.setOnClickListener(v -> showNoMediaKeysDialog(participant.getRecipient()));
}
} else {
infoOverlay.setVisibility(View.GONE);
//TODO: [calling] SFU instability causes the forwarding video flag to alternate quickly, should restore after calling server update
boolean hasContentToRender = (participant.isVideoEnabled() || participant.isScreenSharing()); // && participant.isForwardingVideo();
rendererFrame.setVisibility(hasContentToRender ? View.VISIBLE : View.INVISIBLE);
renderer.setVisibility(hasContentToRender ? View.VISIBLE : View.INVISIBLE);
if (participant.isVideoEnabled()) {
participant.getVideoSink().getLockableEglBase().performWithValidEglBase(eglBase -> {
renderer.init(eglBase);
});
renderer.attachBroadcastVideoSink(participant.getVideoSink());
} else {
renderer.attachBroadcastVideoSink(null);
}
audioIndicator.setVisibility(View.VISIBLE);
audioIndicator.bind(participant.isMicrophoneEnabled(), participant.getAudioLevel());
final String shortRecipientDisplayName = participant.getShortRecipientDisplayName(getContext());
if (raiseHandAllowed && participant.isHandRaised()) {
raiseHandIcon.setVisibility(View.VISIBLE);
nameLabel.setVisibility(View.VISIBLE);
nameLabel.setText(shortRecipientDisplayName);
} else {
raiseHandIcon.setVisibility(View.GONE);
nameLabel.setVisibility(View.GONE);
}
}
if (participantChanged || !Objects.equals(contactPhoto, participant.getRecipient().getContactPhoto())) {
avatar.setAvatarUsingProfile(participant.getRecipient());
badge.setBadgeFromRecipient(participant.getRecipient());
AvatarUtil.loadBlurredIconIntoImageView(participant.getRecipient(), backgroundAvatar);
setPipAvatar(participant.getRecipient());
pipBadge.setBadgeFromRecipient(participant.getRecipient());
contactPhoto = participant.getRecipient().getContactPhoto();
}
setRenderInPip(shouldRenderInPip);
}
private boolean isMissingMediaKeys(@NonNull CallParticipant participant) {
if (missingMediaKeysUpdater != null) {
ThreadUtil.cancelRunnableOnMain(missingMediaKeysUpdater);
missingMediaKeysUpdater = null;
}
if (!participant.isMediaKeysReceived()) {
long time = System.currentTimeMillis() - participant.getAddedToCallTime();
if (time > DELAY_SHOWING_MISSING_MEDIA_KEYS) {
return true;
} else {
missingMediaKeysUpdater = () -> {
if (recipientId.equals(participant.getRecipient().getId())) {
setCallParticipant(participant);
}
};
ThreadUtil.runOnMainDelayed(missingMediaKeysUpdater, DELAY_SHOWING_MISSING_MEDIA_KEYS - time);
}
}
return false;
}
public void setRenderInPip(boolean shouldRenderInPip) {
this.shouldRenderInPip = shouldRenderInPip;
if (infoMode) {
infoMessage.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
infoMoreInfo.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
infoOverlay.setOnClickListener(shouldRenderInPip ? v -> infoMoreInfo.performClick() : null);
return;
} else {
infoOverlay.setOnClickListener(null);
}
avatar.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
badge.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
pipAvatar.setVisibility(shouldRenderInPip ? View.VISIBLE : View.GONE);
pipBadge.setVisibility(shouldRenderInPip ? View.VISIBLE : View.GONE);
}
public void setRaiseHandAllowed(boolean raiseHandAllowed) {
this.raiseHandAllowed = raiseHandAllowed;
}
public void setCameraToggleOnClickListener(@Nullable View.OnClickListener onClickListener) {
switchCameraIconFrame.setOnClickListener(onClickListener);
}
/**
* Adjust UI elements for the various self PIP positions. If called after a {@link TransitionManager#beginDelayedTransition(ViewGroup, Transition)},
* the changes to the UI elements will animate.
*/
public void setSelfPipMode(@NonNull SelfPipMode selfPipMode, boolean isMoreThanOneCameraAvailable) {
Preconditions.checkArgument(selfPipMode != SelfPipMode.NOT_SELF_PIP);
if (this.selfPipMode == selfPipMode) {
return;
}
this.selfPipMode = selfPipMode;
ConstraintSet constraints = new ConstraintSet();
constraints.clone(this);
switch (selfPipMode) {
case NORMAL_SELF_PIP -> {
constraints.connect(
R.id.call_participant_audio_indicator,
ConstraintSet.START,
ConstraintSet.PARENT_ID,
ConstraintSet.START,
ViewUtil.dpToPx(6)
);
constraints.clear(
R.id.call_participant_audio_indicator,
ConstraintSet.END
);
constraints.setMargin(
R.id.call_participant_audio_indicator,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(6)
);
if (isMoreThanOneCameraAvailable) {
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(6)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(6)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(16);
switchCameraIcon.setLayoutParams(params);
switchCameraIconFrame.setClickable(false);
switchCameraIconFrame.setEnabled(false);
} else {
constraints.setVisibility(R.id.call_participant_switch_camera, View.GONE);
}
}
case EXPANDED_SELF_PIP -> {
constraints.connect(
R.id.call_participant_audio_indicator,
ConstraintSet.START,
ConstraintSet.PARENT_ID,
ConstraintSet.START,
ViewUtil.dpToPx(8)
);
constraints.clear(
R.id.call_participant_audio_indicator,
ConstraintSet.END
);
constraints.setMargin(
R.id.call_participant_audio_indicator,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(8)
);
if (isMoreThanOneCameraAvailable) {
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(8)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(8)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(24);
switchCameraIcon.setLayoutParams(params);
switchCameraIconFrame.setClickable(true);
switchCameraIconFrame.setEnabled(true);
} else {
constraints.setVisibility(R.id.call_participant_switch_camera, View.GONE);
}
}
case MINI_SELF_PIP -> {
constraints.connect(
R.id.call_participant_audio_indicator,
ConstraintSet.START,
ConstraintSet.PARENT_ID,
ConstraintSet.START,
0
);
constraints.connect(
R.id.call_participant_audio_indicator,
ConstraintSet.END,
ConstraintSet.PARENT_ID,
ConstraintSet.END,
0
);
constraints.setMargin(
R.id.call_participant_audio_indicator,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(6)
);
constraints.setVisibility(R.id.call_participant_switch_camera, View.GONE);
}
}
constraints.applyTo(this);
}
void hideAvatar() {
avatar.setAlpha(0f);
badge.setAlpha(0f);
}
void showAvatar() {
avatar.setAlpha(1f);
badge.setAlpha(1f);
}
void useLargeAvatar() {
changeAvatarParams(LARGE_AVATAR);
}
void useSmallAvatar() {
changeAvatarParams(SMALL_AVATAR);
}
void setBottomInset(int bottomInset) {
int desiredMargin = getResources().getDimensionPixelSize(R.dimen.webrtc_audio_indicator_margin) + bottomInset;
if (ViewKt.getMarginBottom(audioIndicator) == desiredMargin) {
return;
}
TransitionManager.beginDelayedTransition(this);
ViewUtil.setBottomMargin(audioIndicator, desiredMargin);
}
public void releaseRenderer() {
renderer.release();
}
private void changeAvatarParams(int dimension) {
ViewGroup.LayoutParams params = avatar.getLayoutParams();
if (params.height != dimension) {
params.height = dimension;
params.width = dimension;
avatar.setLayoutParams(params);
}
}
private void setPipAvatar(@NonNull Recipient recipient) {
ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(Recipient.self())
: recipient.getContactPhoto();
FallbackAvatarDrawable fallbackAvatarDrawable = new FallbackAvatarDrawable(getContext(), recipient.getFallbackAvatar());
Glide.with(this)
.load(contactPhoto)
.fallback(fallbackAvatarDrawable)
.error(fallbackAvatarDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.fitCenter()
.into(pipAvatar);
pipAvatar.setScaleType(contactPhoto == null ? ImageView.ScaleType.CENTER_INSIDE : ImageView.ScaleType.CENTER_CROP);
ChatColors chatColors = recipient.getChatColors();
pipAvatar.setBackground(chatColors.getChatBubbleMask());
}
private void showBlockedDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(getContext())
.setTitle(getContext().getString(R.string.CallParticipantView__s_is_blocked, recipient.getShortDisplayName(getContext())))
.setMessage(R.string.CallParticipantView__you_wont_receive_their_audio_or_video)
.setPositiveButton(android.R.string.ok, null)
.show();
}
private void showNoMediaKeysDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(getContext())
.setTitle(getContext().getString(R.string.CallParticipantView__cant_receive_audio_and_video_from_s, recipient.getShortDisplayName(getContext())))
.setMessage(R.string.CallParticipantView__this_may_be_Because_they_have_not_verified_your_safety_number_change)
.setPositiveButton(android.R.string.ok, null)
.show();
}
public enum SelfPipMode {
NOT_SELF_PIP,
NORMAL_SELF_PIP,
EXPANDED_SELF_PIP,
MINI_SELF_PIP
}
}

View File

@@ -1,173 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.flexbox.AlignItems;
import com.google.android.flexbox.FlexboxLayout;
import com.google.android.material.card.MaterialCardView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Collections;
import java.util.List;
/**
* Can dynamically render a collection of call participants, adjusting their
* sizing and layout depending on the total number of participants.
*/
public class CallParticipantsLayout extends FlexboxLayout {
private static final int MULTIPLE_PARTICIPANT_SPACING = ViewUtil.dpToPx(3);
private static final int CORNER_RADIUS = ViewUtil.dpToPx(10);
private static final int RAISE_HAND_MINIMUM_COUNT = 2;
private List<CallParticipant> callParticipants = Collections.emptyList();
private CallParticipant focusedParticipant = null;
private boolean shouldRenderInPip;
private boolean isPortrait;
private boolean hideAvatar;
private int navBarBottomInset;
private LayoutStrategy layoutStrategy;
public CallParticipantsLayout(@NonNull Context context) {
super(context);
}
public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void update(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean shouldRenderInPip,
boolean isPortrait,
boolean hideAvatar,
int navBarBottomInset,
@NonNull LayoutStrategy layoutStrategy)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.shouldRenderInPip = shouldRenderInPip;
this.isPortrait = isPortrait;
this.hideAvatar = hideAvatar;
this.navBarBottomInset = navBarBottomInset;
this.layoutStrategy = layoutStrategy;
setFlexDirection(layoutStrategy.getFlexDirection());
updateLayout();
}
private void updateLayout() {
int previousChildCount = getChildCount();
if (shouldRenderInPip && Util.hasItems(callParticipants)) {
updateChildrenCount(1);
update(0, 1, focusedParticipant);
} else {
int count = callParticipants.size();
updateChildrenCount(count);
for (int i = 0; i < count; i++) {
update(i, count, callParticipants.get(i));
}
}
if (previousChildCount != getChildCount()) {
updateMarginsForLayout();
}
}
private void updateMarginsForLayout() {
MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams();
if (callParticipants.size() > 1 && !shouldRenderInPip) {
layoutParams.setMargins(MULTIPLE_PARTICIPANT_SPACING, ViewUtil.getStatusBarHeight(this), MULTIPLE_PARTICIPANT_SPACING, 0);
} else {
layoutParams.setMargins(0, 0, 0, 0);
}
setLayoutParams(layoutParams);
}
private void updateChildrenCount(int count) {
int childCount = getChildCount();
if (childCount < count) {
for (int i = childCount; i < count; i++) {
addCallParticipantView();
}
} else if (childCount > count) {
for (int i = count; i < childCount; i++) {
CallParticipantView callParticipantView = getChildAt(count).findViewById(R.id.group_call_participant);
callParticipantView.releaseRenderer();
removeViewAt(count);
}
}
}
private void update(int index, int count, @NonNull CallParticipant participant) {
View view = getChildAt(index);
MaterialCardView cardView = view.findViewById(R.id.group_call_participant_card_wrapper);
CallParticipantView callParticipantView = view.findViewById(R.id.group_call_participant);
callParticipantView.setCallParticipant(participant);
callParticipantView.setRenderInPip(shouldRenderInPip);
layoutStrategy.setChildScaling(participant, callParticipantView, isPortrait, count);
if (count > 1) {
view.setPadding(MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING);
cardView.setRadius(CORNER_RADIUS);
callParticipantView.setBottomInset(0);
} else {
view.setPadding(0, 0, 0, 0);
cardView.setRadius(0);
callParticipantView.setBottomInset(navBarBottomInset);
}
if (hideAvatar) {
callParticipantView.hideAvatar();
} else {
callParticipantView.showAvatar();
}
if (count > 2) {
callParticipantView.useSmallAvatar();
} else {
callParticipantView.useLargeAvatar();
}
callParticipantView.setRaiseHandAllowed(count >= RAISE_HAND_MINIMUM_COUNT);
layoutStrategy.setChildLayoutParams(view, index, getChildCount());
}
private void addCallParticipantView() {
View view = LayoutInflater.from(getContext()).inflate(R.layout.group_call_participant_item, this, false);
FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) view.getLayoutParams();
params.setAlignSelf(AlignItems.STRETCH);
view.setLayoutParams(params);
addView(view);
}
public interface LayoutStrategy {
int getFlexDirection();
void setChildScaling(@NonNull CallParticipant callParticipant,
@NonNull CallParticipantView callParticipantView,
boolean isPortrait,
int childCount);
void setChildLayoutParams(@NonNull View child, int childPosition, int childCount);
}
}

View File

@@ -1,73 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc
import android.view.View
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayout
import org.thoughtcrime.securesms.events.CallParticipant
import org.webrtc.RendererCommon
object CallParticipantsLayoutStrategies {
private object Portrait : CallParticipantsLayout.LayoutStrategy {
override fun getFlexDirection(): Int = FlexDirection.ROW
override fun setChildScaling(callParticipant: CallParticipant, callParticipantView: CallParticipantView, isPortrait: Boolean, childCount: Int) {
if (callParticipant.isScreenSharing) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} else {
val matchOrientationScaling = if (isPortrait || childCount < 3) RendererCommon.ScalingType.SCALE_ASPECT_FILL else RendererCommon.ScalingType.SCALE_ASPECT_BALANCED
val mismatchOrientationScaling = if (childCount == 1) RendererCommon.ScalingType.SCALE_ASPECT_FIT else matchOrientationScaling
callParticipantView.setScalingType(matchOrientationScaling, mismatchOrientationScaling)
}
}
override fun setChildLayoutParams(child: View, childPosition: Int, childCount: Int) {
val params = child.layoutParams as FlexboxLayout.LayoutParams
if (childCount < 3) {
params.flexBasisPercent = 1f
} else {
if ((childCount % 2) != 0 && childPosition == childCount - 1) {
params.flexBasisPercent = 1f
} else {
params.flexBasisPercent = 0.5f
}
}
child.layoutParams = params
}
}
private object Landscape : CallParticipantsLayout.LayoutStrategy {
override fun getFlexDirection() = FlexDirection.COLUMN
override fun setChildScaling(callParticipant: CallParticipant, callParticipantView: CallParticipantView, isPortrait: Boolean, childCount: Int) {
if (callParticipant.isScreenSharing) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} else {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL, RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
}
}
override fun setChildLayoutParams(child: View, childPosition: Int, childCount: Int) {
val params = child.layoutParams as FlexboxLayout.LayoutParams
if (childCount < 4) {
params.flexBasisPercent = 1f
} else {
if ((childCount % 2) != 0 && childPosition == childCount - 1) {
params.flexBasisPercent = 1f
} else {
params.flexBasisPercent = 0.5f
}
}
child.layoutParams = params
}
}
@JvmStatic
fun getStrategy(isPortrait: Boolean, isLandscapeEnabled: Boolean): CallParticipantsLayout.LayoutStrategy {
return if (isPortrait || !isLandscapeEnabled) {
Portrait
} else {
Landscape
}
}
}

View File

@@ -1,209 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
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.PluralsRes;
import androidx.annotation.StringRes;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.TimeUnit;
public class CallParticipantsListUpdatePopupWindow extends PopupWindow implements DefaultLifecycleObserver {
private static final long DURATION = TimeUnit.SECONDS.toMillis(5);
private final ViewGroup parent;
private final AvatarImageView avatarImageView;
private final BadgeImageView badgeImageView;
private final TextView descriptionTextView;
private final Handler handler;
private final Set<CallParticipantListUpdate.Wrapper> pendingAdditions = new HashSet<>();
private final Set<CallParticipantListUpdate.Wrapper> pendingRemovals = new HashSet<>();
private boolean isEnabled = true;
public CallParticipantsListUpdatePopupWindow(@NonNull ViewGroup parent) {
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_participant_list_update, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
this.parent = parent;
this.avatarImageView = getContentView().findViewById(R.id.avatar);
this.badgeImageView = getContentView().findViewById(R.id.badge);
this.descriptionTextView = getContentView().findViewById(R.id.description);
this.handler = new Handler(Looper.getMainLooper());
setOnDismissListener(this::showPending);
getContentView().setOnClickListener(v -> dismiss());
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();
}
}
public void setEnabled(boolean isEnabled) {
this.isEnabled = isEnabled;
if (!isEnabled) {
dismiss();
}
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
handler.removeCallbacksAndMessages(null);
setOnDismissListener(null);
dismiss();
}
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() {
if (!isEnabled || !parent.isAttachedToWindow()) {
return;
}
showAtLocation(parent, Gravity.TOP | Gravity.START, 0, 0);
measureChild();
update();
handler.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);
badgeImageView.setBadgeFromRecipient(recipient);
avatarImageView.setVisibility(recipient == null ? View.GONE : View.VISIBLE);
}
private void setDescription(@NonNull Set<CallParticipantListUpdate.Wrapper> wrappers, boolean isAdded) {
if (wrappers.isEmpty()) {
descriptionTextView.setText("");
} else {
setDescriptionForRecipients(wrappers, isAdded);
}
}
private void setDescriptionForRecipients(@NonNull Set<CallParticipantListUpdate.Wrapper> recipients, boolean isAdded) {
descriptionTextView.setText(getDescriptionForRecipients(getContentView().getContext(), recipients, isAdded));
}
public static @NonNull String getDescriptionForRecipients(@NonNull Context context, @NonNull Set<CallParticipantListUpdate.Wrapper> recipients, boolean isAdded) {
Iterator<CallParticipantListUpdate.Wrapper> iterator = recipients.iterator();
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(context, iterator));
break;
case 2:
description = context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator));
break;
case 3:
description = context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator));
break;
default:
description = context.getResources().getQuantityString(getManyMemberDescriptionResourceId(isAdded), recipients.size() - 2, getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), recipients.size() - 2);
}
return description;
}
private @NonNull Recipient getNextRecipient(@NonNull Iterator<CallParticipantListUpdate.Wrapper> wrapperIterator) {
return wrapperIterator.next().getCallParticipant().getRecipient();
}
private static @NonNull String getNextDisplayName(@NonNull Context context, @NonNull Iterator<CallParticipantListUpdate.Wrapper> wrapperIterator) {
CallParticipantListUpdate.Wrapper wrapper = wrapperIterator.next();
return wrapper.getCallParticipant().getRecipientDisplayName(context);
}
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 @PluralsRes int getManyMemberDescriptionResourceId(boolean isAdded) {
if (isAdded) {
return R.plurals.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_joined;
} else {
return R.plurals.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_left;
}
}
}

View File

@@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.concurrent.TimeUnit
/**
@@ -83,9 +82,7 @@ data class CallParticipantsState(
} else {
listParticipants.addAll(remoteParticipants.listParticipants)
}
if (foldableState.isFlat && !RemoteConfig.newCallUi) {
listParticipants.add(CallParticipant.EMPTY)
}
listParticipants.reverse()
return listParticipants
}

View File

@@ -1,70 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
class CallReactionScrubber @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
companion object {
const val CUSTOM_REACTION_BOTTOM_SHEET_TAG = "CallReaction"
@JvmStatic
fun dismissCustomEmojiBottomSheet(fm: FragmentManager) {
val bottomSheet = fm.findFragmentByTag(CUSTOM_REACTION_BOTTOM_SHEET_TAG) as? ReactWithAnyEmojiBottomSheetDialogFragment
bottomSheet?.dismissNow()
}
}
private val emojiViews: Array<EmojiImageView>
private var customEmojiIndex = 0
init {
inflate(context, R.layout.call_overflow_popup, this)
emojiViews = arrayOf(
findViewById(R.id.reaction_1),
findViewById(R.id.reaction_2),
findViewById(R.id.reaction_3),
findViewById(R.id.reaction_4),
findViewById(R.id.reaction_5),
findViewById(R.id.reaction_6),
findViewById(R.id.reaction_7)
)
customEmojiIndex = emojiViews.size - 1
}
fun initialize(fragmentManager: FragmentManager, listener: (String) -> Unit) {
val emojis = SignalStore.emoji.reactions
for (i in emojiViews.indices) {
val view = emojiViews[i]
val isAtCustomIndex = i == customEmojiIndex
if (isAtCustomIndex) {
view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_any_emoji_32))
view.setOnClickListener {
val bottomSheet = ReactWithAnyEmojiBottomSheetDialogFragment.createForCallingReactions()
bottomSheet.show(fragmentManager, CUSTOM_REACTION_BOTTOM_SHEET_TAG)
}
} else {
val preferredVariation = SignalStore.emoji.getPreferredVariation(emojis[i])
view.setImageEmoji(preferredVariation)
view.setOnClickListener { listener(preferredVariation) }
}
}
}
}

View File

@@ -1,107 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.view.ViewCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
/**
* Popup window which is displayed whenever the call state changes from user input.
*/
class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
LayoutInflater.from(parent.context).inflate(R.layout.call_state_update, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
) {
private var enabled: Boolean = true
private var pendingUpdate: CallControlsChange? = null
private var lastUpdate: CallControlsChange? = null
private val dismissDebouncer = Debouncer(2, TimeUnit.SECONDS)
private val iconView = contentView.findViewById<ImageView>(R.id.icon)
private val descriptionView = contentView.findViewById<TextView>(R.id.description)
init {
setOnDismissListener {
val pending = pendingUpdate
if (pending != null) {
onCallStateUpdate(pending)
}
}
animationStyle = R.style.CallStateToastAnimation
}
fun setEnabled(enabled: Boolean) {
this.enabled = enabled
if (!enabled) {
dismissDebouncer.clear()
dismiss()
}
}
fun onCallStateUpdate(callControlsChange: CallControlsChange) {
if (isShowing && lastUpdate == callControlsChange) {
dismissDebouncer.publish { dismiss() }
} else if (isShowing) {
dismissDebouncer.clear()
pendingUpdate = callControlsChange
dismiss()
} else {
pendingUpdate = null
lastUpdate = callControlsChange
presentCallState(callControlsChange)
show()
}
}
private fun presentCallState(callControlsChange: CallControlsChange) {
if (callControlsChange.iconRes == null) {
iconView.setImageDrawable(null)
} else {
iconView.setImageResource(callControlsChange.iconRes)
}
iconView.visible = callControlsChange.iconRes != null
descriptionView.setText(callControlsChange.stringRes)
}
private fun show() {
if (!enabled || parent.windowToken == null) {
return
}
measureChild()
val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_above_controls_guideline)
val pill: View = ViewCompat.requireViewById(contentView, R.id.call_state_pill)
// 54 is the top margin of the contentView (30) plus the desired padding (24)
showAtLocation(
parent,
Gravity.TOP or Gravity.START,
0,
anchor.top - 54.dp - pill.measuredHeight
)
update()
dismissDebouncer.publish { dismiss() }
}
private fun measureChild() {
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
}
}

View File

@@ -1,65 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
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.DrawableRes;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.concurrent.TimeUnit;
/**
* Top screen toast to be shown to the user for 3 seconds.
*
* Currently hard coded to show specific text, but could be easily expanded to be customizable
* if desired. Based on {@link CallParticipantsListUpdatePopupWindow}.
*/
public class CallToastPopupWindow extends PopupWindow {
private static final long DURATION = TimeUnit.SECONDS.toMillis(3);
private final ViewGroup parent;
public static void show(@NonNull ViewGroup viewGroup) {
CallToastPopupWindow toast = new CallToastPopupWindow(viewGroup);
toast.show();
}
public static void show(@NonNull ViewGroup viewGroup, @DrawableRes int iconId, @NonNull String description) {
CallToastPopupWindow toast = new CallToastPopupWindow(viewGroup);
TextView text = toast.getContentView().findViewById(R.id.description);
text.setText(description);
text.setCompoundDrawablesRelativeWithIntrinsicBounds(iconId, 0, 0, 0);
toast.show();
}
private CallToastPopupWindow(@NonNull ViewGroup parent) {
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_toast_popup_window, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewUtil.dpToPx(94));
this.parent = parent;
setAnimationStyle(R.style.PopupAnimation);
}
public 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));
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.webrtc
import android.opengl.EGL14
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper.Companion.acquireEglBase
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper.Companion.releaseEglBase
import org.webrtc.EglBase
import org.webrtc.EglBase10
import org.webrtc.EglBase14

View File

@@ -1,37 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import androidx.annotation.Dimension
import androidx.annotation.IdRes
import androidx.constraintlayout.widget.ConstraintSet
import org.thoughtcrime.securesms.R
/** Constraints to apply for different call sizes */
enum class LayoutPositions(
@JvmField @IdRes val participantBottomViewId: Int,
@JvmField @Dimension val participantBottomMargin: Int,
@JvmField @IdRes val reactionBottomViewId: Int,
@JvmField @Dimension val reactionBottomMargin: Int
) {
/** 1:1 or small calls anchor full screen or controls */
SMALL_GROUP(
participantBottomViewId = ConstraintSet.PARENT_ID,
participantBottomMargin = 0,
reactionBottomViewId = R.id.call_screen_pending_recipients,
reactionBottomMargin = 8
),
/** Large calls have a participant rail to anchor to */
LARGE_GROUP(
participantBottomViewId = R.id.call_screen_participants_recycler,
participantBottomMargin = 16,
reactionBottomViewId = R.id.call_screen_pending_recipients,
reactionBottomMargin = 20
);
@JvmField
val participantBottomViewEndSide: Int = if (participantBottomViewId == ConstraintSet.PARENT_ID) ConstraintSet.BOTTOM else ConstraintSet.TOP
}

View File

@@ -1,129 +0,0 @@
/*
* Copyright 2015 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
/**
* Simple container that confines the children to a subrectangle specified as percentage values of
* the container size. The children are centered horizontally and vertically inside the confined
* space.
*/
public class PercentFrameLayout extends ViewGroup {
private int xPercent = 0;
private int yPercent = 0;
private int widthPercent = 100;
private int heightPercent = 100;
private boolean square = false;
private boolean hidden = false;
public PercentFrameLayout(Context context) {
super(context);
}
public PercentFrameLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public PercentFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setSquare(boolean square) {
this.square = square;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
public boolean isHidden() {
return this.hidden;
}
public void setPosition(int xPercent, int yPercent, int widthPercent, int heightPercent) {
this.xPercent = xPercent;
this.yPercent = yPercent;
this.widthPercent = widthPercent;
this.heightPercent = heightPercent;
}
@Override
public boolean shouldDelayChildPressedState() {
return false;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int width = getDefaultSize(Integer.MAX_VALUE, widthMeasureSpec);
final int height = getDefaultSize(Integer.MAX_VALUE, heightMeasureSpec);
setMeasuredDimension(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
int childWidth = width * widthPercent / 100;
int childHeight = height * heightPercent / 100;
if (square) {
if (width > height) childWidth = childHeight;
else childHeight = childWidth;
}
if (hidden) {
childWidth = 1;
childHeight = 1;
}
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.AT_MOST);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.AT_MOST);
for (int i = 0; i < getChildCount(); ++i) {
final View child = getChildAt(i);
if (child.getVisibility() != GONE) {
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final int width = right - left;
final int height = bottom - top;
// Sub-rectangle specified by percentage values.
final int subWidth = width * widthPercent / 100;
final int subHeight = height * heightPercent / 100;
final int subLeft = left + width * xPercent / 100;
final int subTop = top + height * yPercent / 100;
for (int i = 0; i < getChildCount(); ++i) {
final View child = getChildAt(i);
if (child.getVisibility() != GONE) {
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
// Center child both vertically and horizontally.
int childLeft = subLeft + (subWidth - childWidth) / 2;
int childTop = subTop + (subHeight - childHeight) / 2;
if (hidden) {
childLeft = 0;
childTop = 0;
}
child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
}
}
}
}

View File

@@ -1,205 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.graphics.Point;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.transition.AutoTransition;
import androidx.transition.Transition;
import androidx.transition.TransitionListenerAdapter;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* Helps manage the expansion and shrinking of the in-app pip.
*/
@MainThread
final class PictureInPictureExpansionHelper {
private static final int PIP_RESIZE_DURATION_MS = 300;
private static final int EXPANDED_PIP_WIDTH_DP = 170;
private static final int EXPANDED_PIP_HEIGHT_DP = 300;
public static final int NORMAL_PIP_WIDTH_DP = 90;
public static final int NORMAL_PIP_HEIGHT_DP = 160;
public static final int MINI_PIP_WIDTH_DP = 40;
public static final int MINI_PIP_HEIGHT_DP = 72;
private final View selfPip;
private final ViewGroup parent;
private State state = State.IS_SHRUNKEN;
private Point defaultDimensions;
private Point expandedDimensions;
private final OnStateChangedListener onStateChangedListener;
public PictureInPictureExpansionHelper(@NonNull View selfPip, @NonNull OnStateChangedListener onStateChangedListener) {
this.selfPip = selfPip;
this.parent = (ViewGroup) selfPip.getParent();
this.defaultDimensions = new Point(selfPip.getLayoutParams().width, selfPip.getLayoutParams().height);
this.expandedDimensions = new Point(ViewUtil.dpToPx(EXPANDED_PIP_WIDTH_DP), ViewUtil.dpToPx(EXPANDED_PIP_HEIGHT_DP));
this.onStateChangedListener = onStateChangedListener;
}
public boolean isExpandedOrExpanding() {
return state == State.IS_EXPANDED || state == State.IS_EXPANDING;
}
public boolean isShrunkenOrShrinking() {
return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING;
}
public boolean isMiniSize() {
return defaultDimensions.x < ViewUtil.dpToPx(NORMAL_PIP_WIDTH_DP);
}
public void startExpandedSizeTransition(@NonNull Point dimensions, @NonNull Callback callback) {
if (defaultDimensions.equals(dimensions)) {
return;
}
defaultDimensions = dimensions;
int x = (dimensions.x > dimensions.y) ? EXPANDED_PIP_HEIGHT_DP : EXPANDED_PIP_WIDTH_DP;
int y = (dimensions.x > dimensions.y) ? EXPANDED_PIP_WIDTH_DP : EXPANDED_PIP_HEIGHT_DP;
expandedDimensions = new Point(ViewUtil.dpToPx(x), ViewUtil.dpToPx(y));
if (isExpandedOrExpanding()) {
return;
}
beginResizeSelfPipTransition(expandedDimensions, new Callback() {
@Override
public void onAnimationWillStart() {
setState(State.IS_EXPANDING);
callback.onAnimationWillStart();
}
@Override
public void onAnimationHasFinished() {
setState(State.IS_EXPANDED);
callback.onAnimationHasFinished();
}
});
}
public void startDefaultSizeTransition(@NonNull Point dimensions, @NonNull Callback callback) {
if (defaultDimensions.equals(dimensions)) {
return;
}
defaultDimensions = dimensions;
int x = (dimensions.x > dimensions.y) ? EXPANDED_PIP_HEIGHT_DP : EXPANDED_PIP_WIDTH_DP;
int y = (dimensions.x > dimensions.y) ? EXPANDED_PIP_WIDTH_DP : EXPANDED_PIP_HEIGHT_DP;
expandedDimensions = new Point(ViewUtil.dpToPx(x), ViewUtil.dpToPx(y));
if (isExpandedOrExpanding()) {
return;
}
beginResizeSelfPipTransition(defaultDimensions, callback);
}
public void beginExpandTransition() {
if (isExpandedOrExpanding()) {
return;
}
beginResizeSelfPipTransition(expandedDimensions, new Callback() {
@Override
public void onAnimationWillStart() {
setState(State.IS_EXPANDING);
}
@Override
public void onAnimationHasFinished() {
setState(State.IS_EXPANDED);
}
});
}
public void beginShrinkTransition() {
if (isShrunkenOrShrinking()) {
return;
}
beginResizeSelfPipTransition(defaultDimensions, new Callback() {
@Override
public void onAnimationWillStart() {
setState(State.IS_SHRINKING);
}
@Override
public void onAnimationHasFinished() {
setState(State.IS_SHRUNKEN);
}
});
}
private void beginResizeSelfPipTransition(@NonNull Point dimension, @NonNull Callback callback) {
TransitionManager.endTransitions(parent);
Transition transition = new AutoTransition().setDuration(PIP_RESIZE_DURATION_MS);
transition.addListener(new TransitionListenerAdapter() {
@Override
public void onTransitionStart(@NonNull Transition transition) {
callback.onAnimationWillStart();
}
@Override
public void onTransitionEnd(@NonNull Transition transition) {
callback.onAnimationHasFinished();
}
});
TransitionManager.beginDelayedTransition(parent, transition);
ViewGroup.LayoutParams params = selfPip.getLayoutParams();
params.width = dimension.x;
params.height = dimension.y;
selfPip.setLayoutParams(params);
}
private void setState(@NonNull State state) {
this.state = state;
if (onStateChangedListener != null) {
onStateChangedListener.onStateChanged(state);
}
}
enum State {
IS_EXPANDING,
IS_EXPANDED,
IS_SHRINKING,
IS_SHRUNKEN
}
interface OnStateChangedListener {
void onStateChanged(@NonNull State state);
}
public interface Callback {
/**
* Called when an animation (shrink or expand) will begin. This happens before any animation
* is executed.
*/
default void onAnimationWillStart() {}
/**
* Called when the animation is complete. Useful for e.g. adjusting the pip's final location to
* make sure it is respecting the screen space available.
*/
default void onAnimationHasFinished() {}
}
}

View File

@@ -1,449 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.annotation.SuppressLint;
import android.graphics.Point;
import android.view.GestureDetector;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.core.view.GestureDetectorCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
import java.util.Arrays;
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
private static final float DECELERATION_RATE = 0.99f;
private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator();
private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator();
private final ViewGroup parent;
private final View child;
private final int framePadding;
private int pipWidth;
private int pipHeight;
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
private float lastTouchX;
private float lastTouchY;
private int extraPaddingTop;
private int extraPaddingBottom;
private double projectionX;
private double projectionY;
private VelocityTracker velocityTracker;
private int maximumFlingVelocity;
private boolean isLockedToBottomEnd;
private Interpolator interpolator;
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
private int previousTopBoundary = -1;
private int expandedVerticalBoundary = -1;
private int collapsedVerticalBoundary = -1;
private BoundaryState boundaryState = BoundaryState.EXPANDED;
private boolean isCollapsedStateAllowed = false;
@SuppressLint("ClickableViewAccessibility")
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent();
PictureInPictureGestureHelper helper = new PictureInPictureGestureHelper(parent, child);
GestureDetectorCompat gestureDetector = new GestureDetectorCompat(child.getContext(), helper);
parent.setOnInterceptTouchEventListener((event) -> {
final int action = event.getAction();
final int pointerIndex = (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
if (pointerIndex > 0) {
return false;
}
if (helper.velocityTracker == null) {
helper.velocityTracker = VelocityTracker.obtain();
}
helper.velocityTracker.addMovement(event);
return false;
});
parent.setOnTouchListener((v, event) -> {
if (helper.velocityTracker != null) {
helper.velocityTracker.recycle();
helper.velocityTracker = null;
}
return false;
});
child.setOnTouchListener((v, event) -> {
boolean handled = gestureDetector.onTouchEvent(event);
if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
if (!handled) {
handled = helper.onGestureFinished(event);
}
if (helper.velocityTracker != null) {
helper.velocityTracker.recycle();
helper.velocityTracker = null;
}
}
return handled;
});
return helper;
}
private PictureInPictureGestureHelper(@NonNull ViewGroup parent, @NonNull View child) {
this.parent = parent;
this.child = child;
this.framePadding = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_frame_padding);
this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width);
this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height);
this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity();
this.interpolator = ADJUST_INTERPOLATOR;
}
public void setTopVerticalBoundary(int topBoundary) {
if (topBoundary == previousTopBoundary) {
return;
}
previousTopBoundary = topBoundary;
extraPaddingTop = topBoundary - parent.getTop();
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
layoutParams.setMargins(layoutParams.leftMargin, extraPaddingTop + framePadding, layoutParams.rightMargin, layoutParams.bottomMargin);
child.setLayoutParams(layoutParams);
}
public void setCollapsedVerticalBoundary(int bottomBoundary) {
final int oldBoundary = collapsedVerticalBoundary;
collapsedVerticalBoundary = bottomBoundary;
if (oldBoundary != bottomBoundary && boundaryState == BoundaryState.COLLAPSED) {
applyBottomVerticalBoundary(bottomBoundary);
}
}
public void setExpandedVerticalBoundary(int bottomBoundary) {
final int oldBoundary = expandedVerticalBoundary;
expandedVerticalBoundary = bottomBoundary;
if (oldBoundary != bottomBoundary && boundaryState == BoundaryState.EXPANDED) {
applyBottomVerticalBoundary(bottomBoundary);
}
}
public void setBoundaryState(@NonNull BoundaryState boundaryState) {
if (!isCollapsedStateAllowed && boundaryState == BoundaryState.COLLAPSED) {
return;
}
final BoundaryState old = this.boundaryState;
this.boundaryState = boundaryState;
if (old != boundaryState) {
applyBottomVerticalBoundary(boundaryState == BoundaryState.EXPANDED ? expandedVerticalBoundary : collapsedVerticalBoundary);
}
}
public void allowCollapsedState() {
if (isCollapsedStateAllowed) {
return;
}
isCollapsedStateAllowed = true;
setBoundaryState(BoundaryState.COLLAPSED);
}
private void applyBottomVerticalBoundary(int bottomBoundary) {
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
ViewUtil.setBottomMargin(child, extraPaddingBottom + framePadding);
}
private boolean onGestureFinished(MotionEvent e) {
final int pointerIndex = e.findPointerIndex(activePointerId);
if (e.getActionIndex() == pointerIndex) {
onFling(e, e, 0, 0);
return true;
}
return false;
}
public void lockToBottomEnd() {
isLockedToBottomEnd = true;
fling();
}
public void enableCorners() {
isLockedToBottomEnd = false;
}
@Override
public boolean onDown(MotionEvent e) {
activePointerId = e.getPointerId(0);
lastTouchX = e.getX(0) + child.getX();
lastTouchY = e.getY(0) + child.getY();
pipWidth = child.getMeasuredWidth();
pipHeight = child.getMeasuredHeight();
interpolator = FLING_INTERPOLATOR;
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (isLockedToBottomEnd) {
return false;
}
int pointerIndex = e2.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
fling();
return false;
}
float x = e2.getX(pointerIndex) + child.getX();
float y = e2.getY(pointerIndex) + child.getY();
float dx = x - lastTouchX;
float dy = y - lastTouchY;
child.setTranslationX(child.getTranslationX() + dx);
child.setTranslationY(child.getTranslationY() + dy);
lastTouchX = x;
lastTouchY = y;
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isLockedToBottomEnd) {
return false;
}
if (velocityTracker != null) {
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
projectionX = child.getX() + project(velocityTracker.getXVelocity());
projectionY = child.getY() + project(velocityTracker.getYVelocity());
} else {
projectionX = child.getX();
projectionY = child.getY();
}
fling();
return true;
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
child.performClick();
return true;
}
private void fling() {
Point projection = new Point((int) projectionX, (int) projectionY);
Corner nearestCornerPosition = findNearestCornerPosition(projection);
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
layoutParams.gravity = nearestCornerPosition.gravity;
if (currentCornerPosition != null && currentCornerPosition != nearestCornerPosition) {
adjustTranslationFrameOfReference(child, currentCornerPosition, nearestCornerPosition);
}
currentCornerPosition = nearestCornerPosition;
child.setLayoutParams(layoutParams);
child.animate()
.translationX(0)
.translationY(0)
.setDuration(250)
.setInterpolator(interpolator)
.start();
}
private Corner findNearestCornerPosition(Point projection) {
if (isLockedToBottomEnd) {
return ViewUtil.isLtr(parent) ? Corner.BOTTOM_RIGHT
: Corner.BOTTOM_LEFT;
}
CornerPoint maxPoint = null;
double maxDistance = Double.MAX_VALUE;
for (CornerPoint cornerPoint : Arrays.asList(calculateTopLeftCoordinates(),
calculateTopRightCoordinates(parent),
calculateBottomLeftCoordinates(parent),
calculateBottomRightCoordinates(parent))) {
double distance = distance(cornerPoint.point, projection);
if (distance < maxDistance) {
maxDistance = distance;
maxPoint = cornerPoint;
}
}
//noinspection DataFlowIssue
return maxPoint.corner;
}
private CornerPoint calculateTopLeftCoordinates() {
return new CornerPoint(new Point(framePadding, framePadding + extraPaddingTop),
Corner.TOP_LEFT);
}
private CornerPoint calculateTopRightCoordinates(@NonNull ViewGroup parent) {
return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, framePadding + extraPaddingTop),
Corner.TOP_RIGHT);
}
private CornerPoint calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
return new CornerPoint(new Point(framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
Corner.BOTTOM_LEFT);
}
private CornerPoint calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
Corner.BOTTOM_RIGHT);
}
private static float project(float initialVelocity) {
return (initialVelocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE);
}
private static double distance(Point a, Point b) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
/**
* User drag is implemented by translating the view from the current gravity anchor (corner). When the user drags
* to a new corner, we need to adjust the translations for the new corner so the animation of translation X/Y to 0
* works correctly.
*
* For example, if in bottom right and need to move to top right, we need to calculate a new translation Y since instead
* of being translated up from bottom it's translated down from the top.
*/
private void adjustTranslationFrameOfReference(@NonNull View child, @NonNull Corner previous, @NonNull Corner next) {
TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent();
FrameLayout.LayoutParams childLayoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
int parentWidth = parent.getWidth();
int parentHeight = parent.getHeight();
if (previous.topHalf != next.topHalf) {
int childHeight = childLayoutParams.height + childLayoutParams.topMargin + childLayoutParams.bottomMargin;
float adjustedTranslationY;
if (previous.topHalf) {
adjustedTranslationY = -(parentHeight - child.getTranslationY() - childHeight);
} else {
adjustedTranslationY = parentHeight + child.getTranslationY() - childHeight;
}
child.setTranslationY(adjustedTranslationY);
}
if (previous.leftSide != next.leftSide) {
int childWidth = childLayoutParams.width + childLayoutParams.leftMargin + childLayoutParams.rightMargin;
float adjustedTranslationX;
if (previous.leftSide) {
adjustedTranslationX = -(parentWidth - child.getTranslationX() - childWidth);
} else {
adjustedTranslationX = parentWidth + child.getTranslationX() - childWidth;
}
child.setTranslationX(adjustedTranslationX);
}
}
private static class CornerPoint {
final Point point;
final Corner corner;
public CornerPoint(@NonNull Point point, @NonNull Corner corner) {
this.point = point;
this.corner = corner;
}
}
@SuppressLint("RtlHardcoded")
private enum Corner {
TOP_LEFT(Gravity.TOP | Gravity.LEFT, true, true),
TOP_RIGHT(Gravity.TOP | Gravity.RIGHT, false, true),
BOTTOM_LEFT(Gravity.BOTTOM | Gravity.LEFT, true, false),
BOTTOM_RIGHT(Gravity.BOTTOM | Gravity.RIGHT, false, false);
final int gravity;
final boolean leftSide;
final boolean topHalf;
Corner(int gravity, boolean leftSide, boolean topHalf) {
this.gravity = gravity;
this.leftSide = leftSide;
this.topHalf = topHalf;
}
}
/**
* Borrowed from ScrollView
*/
private static class ViscousFluidInterpolator implements Interpolator {
/**
* Controls the viscous fluid effect (how much of it).
*/
private static final float VISCOUS_FLUID_SCALE = 8.0f;
private static final float VISCOUS_FLUID_NORMALIZE;
private static final float VISCOUS_FLUID_OFFSET;
static {
// must be set to 1.0 (used in viscousFluid())
VISCOUS_FLUID_NORMALIZE = 1.0f / viscousFluid(1.0f);
// account for very small floating-point error
VISCOUS_FLUID_OFFSET = 1.0f - VISCOUS_FLUID_NORMALIZE * viscousFluid(1.0f);
}
private static float viscousFluid(float x) {
x *= VISCOUS_FLUID_SCALE;
if (x < 1.0f) {
x -= (1.0f - (float) Math.exp(-x));
} else {
float start = 0.36787944117f; // 1/e == exp(-1)
x = 1.0f - (float) Math.exp(1.0f - x);
x = start + x * (1.0f - start);
}
return x;
}
@Override
public float getInterpolation(float input) {
final float interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input);
if (interpolated > 0) {
return interpolated + VISCOUS_FLUID_OFFSET;
}
return interpolated;
}
}
public enum BoundaryState {
EXPANDED,
COLLAPSED
}
}

View File

@@ -1,62 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.Barrier
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.views.SlideUpWithDependencyBehavior
import kotlin.math.max
/**
* Coordinator Layout Behavior which allows us to "pin" UI Elements to the top of the controls sheet.
*/
class SlideUpWithCallControlsBehavior(
context: Context,
attributeSet: AttributeSet?
) : SlideUpWithDependencyBehavior(context, attributeSet, offsetY = 0f) {
private var minTranslationY: Float = 0f
var onTopOfControlsChangedListener: OnTopOfControlsChangedListener? = null
override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
super.onDependentViewChanged(parent, child, dependency)
val bottomSheetBehavior = (dependency.layoutParams as CoordinatorLayout.LayoutParams).behavior as BottomSheetBehavior<*>
val slideOffset = bottomSheetBehavior.calculateSlideOffset()
if (slideOffset == 0f) {
minTranslationY = child.translationY
} else {
child.translationY = max(child.translationY, minTranslationY)
}
emitViewChanged(child)
return true
}
override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
emitViewChanged(child)
return false
}
override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
return dependency.id == R.id.call_controls_info_parent
}
private fun emitViewChanged(child: View) {
val barrier = child.findViewById<Barrier>(R.id.call_screen_above_controls_barrier)
onTopOfControlsChangedListener?.onTopOfControlsChanged(barrier.bottom + child.translationY.toInt())
}
interface OnTopOfControlsChangedListener {
fun onTopOfControlsChanged(topOfControls: Int)
}
}

View File

@@ -6,7 +6,7 @@ import androidx.compose.runtime.setValue
import kotlin.math.min
/**
* This holds UI state for [WebRtcAudioOutputToggleButton]
* This holds UI state for [org.thoughtcrime.securesms.components.webrtc.v2.CallAudioToggleButton]
*/
class ToggleButtonOutputState {
private val availableOutputs: LinkedHashSet<WebRtcAudioOutput> = linkedSetOf(WebRtcAudioOutput.SPEAKER)

View File

@@ -1,172 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.content.ContextWrapper
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View.OnClickListener
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatImageView
import androidx.fragment.app.FragmentActivity
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
/**
* A UI button that triggers a picker dialog/bottom sheet allowing the user to select the audio output for the ongoing call.
*/
class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr), AudioStateUpdater {
private val TAG = Log.tag(WebRtcAudioOutputToggleButton::class.java)
private var outputState: ToggleButtonOutputState = ToggleButtonOutputState()
private var audioOutputChangedListener: OnAudioOutputChangedListener = OnAudioOutputChangedListener { Log.e(TAG, "Attempted to call audioOutputChangedListenerLegacy without initializing!") }
private var picker: DialogInterface? = null
private val clickListenerLegacy: OnClickListener = OnClickListener {
if (picker != null) {
Log.d(TAG, "Tried to launch new audio device picker but one is already present.")
return@OnClickListener
}
val outputs = outputState.getOutputs()
if (outputs.size >= SHOW_PICKER_THRESHOLD || !outputState.isEarpieceAvailable) {
picker = WebRtcAudioPickerLegacy(audioOutputChangedListener, outputState, this).showPicker(context, outputs) { picker = null }
} else {
val audioOutput = outputState.peekNext()
audioOutputChangedListener.audioOutputChanged(WebRtcAudioDevice(audioOutput, null))
updateAudioOutputState(audioOutput)
}
}
@RequiresApi(31)
private val clickListener31 = OnClickListener {
if (picker != null) {
Log.d(TAG, "Tried to launch new audio device picker but one is already present.")
return@OnClickListener
}
val fragmentActivity = context.fragmentActivity()
if (fragmentActivity != null) {
picker = WebRtcAudioPicker31(audioOutputChangedListener, outputState, this).showPicker(fragmentActivity, SHOW_PICKER_THRESHOLD) { picker = null }
} else {
Log.e(TAG, "WebRtcAudioOutputToggleButton instantiated from a context that does not inherit from FragmentActivity.")
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_fragment_activity_error, Toast.LENGTH_LONG).show()
}
}
init {
super.setOnClickListener(
if (Build.VERSION.SDK_INT >= 31) {
clickListener31
} else {
clickListenerLegacy
}
)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
hidePicker()
}
/**
* DO NOT REMOVE senseless comparison suppression.
* Somehow, through XML inflation (reflection?), [outputState] can actually be null,
* even though the compiler disagrees.
* */
override fun onCreateDrawableState(extraSpace: Int): IntArray {
@Suppress("SENSELESS_COMPARISON")
if (outputState == null) {
return super.onCreateDrawableState(extraSpace)
}
val currentOutput = outputState.getCurrentOutput()
val shouldShowDropdownForSpeaker = outputState.getOutputs().size >= SHOW_PICKER_THRESHOLD || !outputState.getOutputs().contains(WebRtcAudioOutput.HANDSET)
val extra = when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
WebRtcAudioOutput.SPEAKER -> if (shouldShowDropdownForSpeaker) intArrayOf(R.attr.state_speaker_selected) else intArrayOf(R.attr.state_speaker_on)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
}
Log.d(TAG, "Switching button drawable to $currentOutput")
val drawableState = super.onCreateDrawableState(extraSpace + extra.size)
mergeDrawableStates(drawableState, extra)
return drawableState
}
override fun setOnClickListener(l: OnClickListener?) {
throw UnsupportedOperationException("This View does not support custom click listeners.")
}
fun setControlAvailability(isEarpieceAvailable: Boolean, isBluetoothHeadsetAvailable: Boolean, isHeadsetAvailable: Boolean) {
outputState.isEarpieceAvailable = isEarpieceAvailable
outputState.isBluetoothHeadsetAvailable = isBluetoothHeadsetAvailable
outputState.isWiredHeadsetAvailable = isHeadsetAvailable
refreshDrawableState()
}
override fun updateAudioOutputState(audioOutput: WebRtcAudioOutput) {
val oldOutput = outputState.getCurrentOutput()
if (oldOutput != audioOutput) {
outputState.setCurrentOutput(audioOutput)
refreshDrawableState()
}
}
fun setOnAudioOutputChangedListener(listener: OnAudioOutputChangedListener) {
audioOutputChangedListener = listener
}
override fun onSaveInstanceState(): Parcelable {
val parentState = super.onSaveInstanceState()
val bundle = Bundle()
bundle.putParcelable(STATE_PARENT, parentState)
bundle.putInt(STATE_OUTPUT_INDEX, outputState.getBackingIndexForBackup())
bundle.putBoolean(STATE_HEADSET_ENABLED, outputState.isBluetoothHeadsetAvailable)
bundle.putBoolean(STATE_HANDSET_ENABLED, outputState.isEarpieceAvailable)
return bundle
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle) {
outputState.isBluetoothHeadsetAvailable = state.getBoolean(STATE_HEADSET_ENABLED)
outputState.isEarpieceAvailable = state.getBoolean(STATE_HANDSET_ENABLED)
outputState.setBackingIndexForRestore(state.getInt(STATE_OUTPUT_INDEX))
refreshDrawableState()
super.onRestoreInstanceState(state.getParcelable(STATE_PARENT))
} else {
super.onRestoreInstanceState(state)
}
}
override fun hidePicker() {
try {
picker?.dismiss()
} catch (e: IllegalStateException) {
Log.w(TAG, "Picker is not attached to a window.")
}
picker = null
}
companion object {
const val SHOW_PICKER_THRESHOLD = 3
private const val STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index"
private const val STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled"
private const val STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled"
private const val STATE_PARENT = "audio.output.toggle.state.parent"
private tailrec fun Context.fragmentActivity(): FragmentActivity? = when (this) {
is FragmentActivity -> this
else -> (this as? ContextWrapper)?.baseContext?.fragmentActivity()
}
}
}

View File

@@ -1,111 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.events.CallParticipant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
class WebRtcCallParticipantsPage {
private final List<CallParticipant> callParticipants;
private final CallParticipant focusedParticipant;
private final boolean isSpeaker;
private final boolean isRenderInPip;
private final boolean isPortrait;
private final boolean isLandscapeEnabled;
private final boolean hideAvatar;
private final int navBarBottomInset;
static WebRtcCallParticipantsPage forMultipleParticipants(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean isRenderInPip,
boolean isPortrait,
boolean isLandscapeEnabled,
boolean hideAvatar,
int navBarBottomInset)
{
return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip, isPortrait, isLandscapeEnabled, hideAvatar, navBarBottomInset);
}
static WebRtcCallParticipantsPage forSingleParticipant(@NonNull CallParticipant singleParticipant,
boolean isRenderInPip,
boolean isPortrait,
boolean isLandscapeEnabled)
{
return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip, isPortrait, isLandscapeEnabled, false, 0);
}
private WebRtcCallParticipantsPage(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean isSpeaker,
boolean isRenderInPip,
boolean isPortrait,
boolean isLandscapeEnabled,
boolean hideAvatar,
int navBarBottomInset)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.isSpeaker = isSpeaker;
this.isRenderInPip = isRenderInPip;
this.isPortrait = isPortrait;
this.isLandscapeEnabled = isLandscapeEnabled;
this.hideAvatar = hideAvatar;
this.navBarBottomInset = navBarBottomInset;
}
public @NonNull List<CallParticipant> getCallParticipants() {
return callParticipants;
}
public @NonNull CallParticipant getFocusedParticipant() {
return focusedParticipant;
}
public boolean isRenderInPip() {
return isRenderInPip;
}
public boolean isSpeaker() {
return isSpeaker;
}
public boolean isPortrait() {
return isPortrait;
}
public boolean shouldHideAvatar() {
return hideAvatar;
}
public int getNavBarBottomInset() {
return navBarBottomInset;
}
public @NonNull CallParticipantsLayout.LayoutStrategy getLayoutStrategy() {
return CallParticipantsLayoutStrategies.getStrategy(isPortrait, isLandscapeEnabled);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final WebRtcCallParticipantsPage that = (WebRtcCallParticipantsPage) o;
return isSpeaker == that.isSpeaker &&
isRenderInPip == that.isRenderInPip &&
isPortrait == that.isPortrait &&
isLandscapeEnabled == that.isLandscapeEnabled &&
hideAvatar == that.hideAvatar &&
callParticipants.equals(that.callParticipants) &&
focusedParticipant.equals(that.focusedParticipant) &&
navBarBottomInset == that.navBarBottomInset;
}
@Override
public int hashCode() {
return Objects.hash(callParticipants, focusedParticipant, isSpeaker, isRenderInPip, isPortrait, isLandscapeEnabled, hideAvatar, navBarBottomInset);
}
}

View File

@@ -1,136 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.webrtc.RendererCommon;
class WebRtcCallParticipantsPagerAdapter extends ListAdapter<WebRtcCallParticipantsPage, WebRtcCallParticipantsPagerAdapter.ViewHolder> {
private static final int VIEW_TYPE_MULTI = 0;
private static final int VIEW_TYPE_SINGLE = 1;
private final Runnable onPageClicked;
WebRtcCallParticipantsPagerAdapter(@NonNull Runnable onPageClicked) {
super(new DiffCallback());
this.onPageClicked = onPageClicked;
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
final ViewHolder viewHolder;
switch (viewType) {
case VIEW_TYPE_SINGLE:
viewHolder = new SingleParticipantViewHolder((CallParticipantView) LayoutInflater.from(parent.getContext())
.inflate(R.layout.call_participant_item,
parent,
false));
break;
case VIEW_TYPE_MULTI:
viewHolder = new MultipleParticipantViewHolder((CallParticipantsLayout) LayoutInflater.from(parent.getContext())
.inflate(R.layout.webrtc_call_participants_layout,
parent,
false));
break;
default:
throw new IllegalArgumentException("Unsupported viewType: " + viewType);
}
viewHolder.itemView.setOnClickListener(unused -> onPageClicked.run());
return viewHolder;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(getItem(position));
}
@Override
public int getItemViewType(int position) {
return getItem(position).isSpeaker() ? VIEW_TYPE_SINGLE : VIEW_TYPE_MULTI;
}
static abstract class ViewHolder extends RecyclerView.ViewHolder {
public ViewHolder(@NonNull View itemView) {
super(itemView);
}
abstract void bind(WebRtcCallParticipantsPage page);
}
private static class MultipleParticipantViewHolder extends ViewHolder {
private final CallParticipantsLayout callParticipantsLayout;
private MultipleParticipantViewHolder(@NonNull CallParticipantsLayout callParticipantsLayout) {
super(callParticipantsLayout);
this.callParticipantsLayout = callParticipantsLayout;
}
@Override
void bind(WebRtcCallParticipantsPage page) {
callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip(), page.isPortrait(), page.shouldHideAvatar(), page.getNavBarBottomInset(), page.getLayoutStrategy());
}
}
private static class SingleParticipantViewHolder extends ViewHolder {
private final CallParticipantView callParticipantView;
private SingleParticipantViewHolder(CallParticipantView callParticipantView) {
super(callParticipantView);
this.callParticipantView = callParticipantView;
ViewGroup.LayoutParams params = callParticipantView.getLayoutParams();
params.height = ViewGroup.LayoutParams.MATCH_PARENT;
params.width = ViewGroup.LayoutParams.MATCH_PARENT;
callParticipantView.setLayoutParams(params);
}
@Override
void bind(WebRtcCallParticipantsPage page) {
CallParticipant participant = page.getCallParticipants().get(0);
callParticipantView.setCallParticipant(participant);
callParticipantView.setRenderInPip(page.isRenderInPip());
callParticipantView.setRaiseHandAllowed(false);
if (participant.isScreenSharing()) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
} else {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
}
}
}
private static final class DiffCallback extends DiffUtil.ItemCallback<WebRtcCallParticipantsPage> {
@Override
public boolean areItemsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) {
return oldItem.isSpeaker() == newItem.isSpeaker();
}
@Override
public boolean areContentsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) {
return oldItem.equals(newItem);
}
}
}

View File

@@ -1,89 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
public class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant, WebRtcCallParticipantsRecyclerAdapter.ViewHolder> {
private static final int PARTICIPANT = 0;
private static final int EMPTY = 1;
public WebRtcCallParticipantsRecyclerAdapter() {
super(new DiffCallback());
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == PARTICIPANT) {
return new ParticipantViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.webrtc_call_participant_recycler_item, parent, false));
} else {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.webrtc_call_participant_recycler_empty_item, parent, false));
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(getItem(position));
}
@Override
public int getItemViewType(int position) {
return getItem(position) == CallParticipant.EMPTY ? EMPTY : PARTICIPANT;
}
public static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(@NonNull View itemView) {
super(itemView);
}
void bind(@NonNull CallParticipant callParticipant) {}
}
private static class ParticipantViewHolder extends ViewHolder {
private final CallParticipantView callParticipantView;
ParticipantViewHolder(@NonNull View itemView) {
super(itemView);
callParticipantView = itemView.findViewById(R.id.call_participant);
View audioIndicator = callParticipantView.findViewById(R.id.call_participant_audio_indicator);
int audioIndicatorMargin = (int) DimensionUnit.DP.toPixels(8f);
ViewUtil.setLeftMargin(audioIndicator, audioIndicatorMargin);
ViewUtil.setBottomMargin(audioIndicator, audioIndicatorMargin);
}
@Override
void bind(@NonNull CallParticipant callParticipant) {
callParticipantView.setCallParticipant(callParticipant);
callParticipantView.setRenderInPip(true);
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
}
}
private static class DiffCallback extends DiffUtil.ItemCallback<CallParticipant> {
@Override
public boolean areItemsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) {
return oldItem.getRecipient().equals(newItem.getRecipient());
}
@Override
public boolean areContentsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) {
return oldItem.equals(newItem);
}
}
}

View File

@@ -1,7 +1,5 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;

View File

@@ -1,965 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc;
import android.Manifest;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.compose.ui.platform.ComposeView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.util.Consumer;
import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.MarginPageTransformer;
import androidx.viewpager2.widget.ViewPager2;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.google.android.material.button.MaterialButton;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.SetUtil;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout;
import org.thoughtcrime.securesms.components.webrtc.v2.CallScreenControlsListener;
import org.thoughtcrime.securesms.components.webrtc.v2.PendingParticipantsListener;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.components.webrtc.v2.PendingParticipantsState;
import org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout;
import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.webrtc.RendererCommon;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class WebRtcCallView extends InsetAwareConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
private static final long TRANSITION_DURATION_MILLIS = 250;
private WebRtcAudioOutputToggleButton audioToggle;
private AccessibleToggleButton videoToggle;
private AccessibleToggleButton micToggle;
private ViewGroup smallLocalRenderFrame;
private CallParticipantView smallLocalRender;
private View largeLocalRenderFrame;
private TextureViewRenderer largeLocalRender;
private View largeLocalRenderNoVideo;
private ImageView largeLocalRenderNoVideoAvatar;
private TextView recipientName;
private TextView status;
private TextView incomingRingStatus;
private CallScreenControlsListener controlsListener;
private RecipientId recipientId;
private ImageView answer;
private TextView answerWithoutVideoLabel;
private ImageView cameraDirectionToggle;
private AccessibleToggleButton ringToggle;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView overflow;
private ImageView hangup;
private View answerWithoutVideo;
private View topGradient;
private View footerGradient;
private View startCallControls;
private ViewPager2 callParticipantsPager;
private RecyclerView callParticipantsRecycler;
private ConstraintLayout largeHeader;
private MaterialButton startCall;
private Stub<FrameLayout> groupCallSpeakerHint;
private Stub<View> groupCallFullStub;
private View errorButton;
private Guideline showParticipantsGuideline;
private Guideline aboveControlsGuideline;
private Guideline topFoldGuideline;
private Guideline callScreenTopFoldGuideline;
private AvatarImageView largeHeaderAvatar;
private int navBarBottomInset;
private View fullScreenShade;
private Toolbar collapsedToolbar;
private Toolbar headerToolbar;
private Stub<PendingParticipantsView> pendingParticipantsViewStub;
private Stub<View> callLinkWarningCard;
private RecyclerView groupReactionsFeed;
private MultiReactionBurstLayout reactionViews;
private ComposeView raiseHandSnackbar;
private View missingPermissionContainer;
private MaterialButton allowAccessButton;
private Guideline callParticipantsOverflowGuideline;
private View callControlsSheet;
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
private WebRtcReactionsRecyclerAdapter reactionsAdapter;
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
private PendingParticipantsListener pendingParticipantsViewListener;
private final Set<View> incomingCallViews = new HashSet<>();
private final Set<View> topViews = new HashSet<>();
private final Set<View> visibleViewSet = new HashSet<>();
private final Set<View> allTimeVisibleViews = new HashSet<>();
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
private WebRtcControls controls = WebRtcControls.NONE;
private CallParticipantsViewState lastState;
private ContactPhoto previousLocalAvatar;
private LayoutPositions previousLayoutPositions = null;
public WebRtcCallView(@NonNull Context context) {
this(context, null);
}
public WebRtcCallView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
inflate(context, R.layout.webrtc_call_view, this);
}
@SuppressWarnings("CodeBlock2Expr")
@Override
protected void onFinishInflate() {
super.onFinishInflate();
audioToggle = findViewById(R.id.call_screen_speaker_toggle);
videoToggle = findViewById(R.id.call_screen_video_toggle);
micToggle = findViewById(R.id.call_screen_audio_mic_toggle);
smallLocalRenderFrame = findViewById(R.id.call_screen_pip);
smallLocalRender = findViewById(R.id.call_screen_small_local_renderer);
largeLocalRenderFrame = findViewById(R.id.call_screen_large_local_renderer_frame);
largeLocalRender = findViewById(R.id.call_screen_large_local_renderer);
largeLocalRenderNoVideo = findViewById(R.id.call_screen_large_local_video_off);
largeLocalRenderNoVideoAvatar = findViewById(R.id.call_screen_large_local_video_off_avatar);
recipientName = findViewById(R.id.call_screen_recipient_name);
status = findViewById(R.id.call_screen_status);
incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status);
answer = findViewById(R.id.call_screen_answer_call);
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
overflow = findViewById(R.id.call_screen_overflow_button);
hangup = findViewById(R.id.call_screen_end_call);
answerWithoutVideo = findViewById(R.id.call_screen_answer_without_video);
topGradient = findViewById(R.id.call_screen_header_gradient);
footerGradient = findViewById(R.id.call_screen_footer_gradient);
startCallControls = findViewById(R.id.call_screen_start_call_controls);
callParticipantsPager = findViewById(R.id.call_screen_participants_pager);
callParticipantsRecycler = findViewById(R.id.call_screen_participants_recycler);
largeHeader = findViewById(R.id.call_screen_header);
startCall = findViewById(R.id.call_screen_start_call_start_call);
errorButton = findViewById(R.id.call_screen_error_cancel);
groupCallSpeakerHint = new Stub<>(findViewById(R.id.call_screen_group_call_speaker_hint));
groupCallFullStub = new Stub<>(findViewById(R.id.group_call_call_full_view));
showParticipantsGuideline = findViewById(R.id.call_screen_show_participants_guideline);
aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline);
topFoldGuideline = findViewById(R.id.fold_top_guideline);
callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline);
largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar);
fullScreenShade = findViewById(R.id.call_screen_full_shade);
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients));
callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning));
groupReactionsFeed = findViewById(R.id.call_screen_reactions_feed);
reactionViews = findViewById(R.id.call_screen_reactions_container);
raiseHandSnackbar = findViewById(R.id.call_screen_raise_hand_view);
missingPermissionContainer = findViewById(R.id.missing_permissions_container);
allowAccessButton = findViewById(R.id.allow_access_button);
callParticipantsOverflowGuideline = findViewById(R.id.call_screen_participants_overflow_guideline);
callControlsSheet = findViewById(R.id.call_controls_info_parent);
View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4)));
pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls);
recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter();
reactionsAdapter = new WebRtcReactionsRecyclerAdapter();
callParticipantsPager.setAdapter(pagerAdapter);
callParticipantsRecycler.setAdapter(recyclerAdapter);
groupReactionsFeed.setAdapter(reactionsAdapter);
DefaultItemAnimator animator = new DefaultItemAnimator();
animator.setSupportsChangeAnimations(false);
callParticipantsRecycler.setItemAnimator(animator);
groupReactionsFeed.addItemDecoration(new WebRtcReactionsAlphaItemDecoration());
groupReactionsFeed.setItemAnimator(new WebRtcReactionsItemAnimator());
callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
runIfNonNull(controlsListener, listener -> listener.onPageChanged(position == 0 ? CallParticipantsState.SelectedPage.GRID : CallParticipantsState.SelectedPage.FOCUSED));
}
});
topViews.add(largeHeader);
topViews.add(topGradient);
incomingCallViews.add(answer);
incomingCallViews.add(answerLabel);
incomingCallViews.add(decline);
incomingCallViews.add(declineLabel);
incomingCallViews.add(footerGradient);
incomingCallViews.add(incomingRingStatus);
audioToggle.setOnAudioOutputChangedListener(webRtcAudioDevice -> {
runIfNonNull(controlsListener, listener ->
{
if (Build.VERSION.SDK_INT >= 31) {
if (webRtcAudioDevice.getDeviceId() != null) {
listener.onAudioOutputChanged31(webRtcAudioDevice);
} else {
Log.e(TAG, "Attempted to change audio output to null device ID.");
}
} else {
listener.onAudioOutputChanged(webRtcAudioDevice.getWebRtcAudioOutput());
}
});
});
videoToggle.setOnCheckedChangeListener((v, isOn) -> {
if (!hasCameraPermission()) {
videoToggle.setChecked(false);
}
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn));
});
micToggle.setOnCheckedChangeListener((v, isOn) -> {
if (!hasAudioPermission()) {
micToggle.setChecked(false);
}
runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn));
});
ringToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated()));
});
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onCameraDirectionChanged));
smallLocalRender.findViewById(R.id.call_participant_switch_camera).setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onCameraDirectionChanged));
overflow.setOnClickListener(v -> {
runIfNonNull(controlsListener, CallScreenControlsListener::onOverflowClicked);
});
hangup.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onEndCallPressed));
decline.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onDenyCallPressed));
answer.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onAcceptCallPressed));
answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onAcceptCallWithVoiceOnlyPressed));
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame);
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(smallLocalRenderFrame, state -> {
if (state == PictureInPictureExpansionHelper.State.IS_SHRUNKEN) {
pictureInPictureGestureHelper.setBoundaryState(PictureInPictureGestureHelper.BoundaryState.COLLAPSED);
} else {
pictureInPictureGestureHelper.setBoundaryState(PictureInPictureGestureHelper.BoundaryState.EXPANDED);
}
});
smallLocalRenderFrame.setOnClickListener(v -> {
if (controlsListener != null) {
controlsListener.onLocalPictureInPictureClicked();
}
});
View smallLocalAudioIndicator = smallLocalRender.findViewById(R.id.call_participant_audio_indicator);
int audioIndicatorMargin = (int) DimensionUnit.DP.toPixels(8f);
ViewUtil.setLeftMargin(smallLocalAudioIndicator, audioIndicatorMargin);
ViewUtil.setBottomMargin(smallLocalAudioIndicator, audioIndicatorMargin);
startCall.setOnClickListener(v -> {
Runnable onGranted = () -> {
if (controlsListener != null) {
startCall.setEnabled(false);
controlsListener.onStartCall(videoToggle.isChecked());
}
};
runIfNonNull(controlsListener, listener -> listener.onAudioPermissionsRequested(onGranted));
});
ColorMatrix greyScaleMatrix = new ColorMatrix();
greyScaleMatrix.setSaturation(0);
largeLocalRenderNoVideoAvatar.setAlpha(0.6f);
largeLocalRenderNoVideoAvatar.setColorFilter(new ColorMatrixColorFilter(greyScaleMatrix));
errorButton.setOnClickListener(v -> {
if (controlsListener != null) {
controlsListener.onCancelStartCall();
}
});
collapsedToolbar.setNavigationOnClickListener(unused -> {
if (controlsListener != null) {
controlsListener.onNavigateUpClicked();
}
});
collapsedToolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.action_info && controlsListener != null) {
controlsListener.onCallInfoClicked();
return true;
}
return false;
});
headerToolbar.setNavigationOnClickListener(unused -> {
if (controlsListener != null) {
controlsListener.onNavigateUpClicked();
}
});
headerToolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.action_info && controlsListener != null) {
controlsListener.onCallInfoClicked();
return true;
}
return false;
});
missingPermissionContainer.setVisibility(hasCameraPermission() ? View.GONE : View.VISIBLE);
allowAccessButton.setOnClickListener(v -> {
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(videoToggle.isEnabled()));
});
ConstraintLayout aboveControls = findViewById(R.id.call_controls_floating_parent);
if (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
aboveControls.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
pictureInPictureGestureHelper.setCollapsedVerticalBoundary(bottom + ViewUtil.getStatusBarHeight(v));
});
}
SlideUpWithCallControlsBehavior behavior = (SlideUpWithCallControlsBehavior) ((CoordinatorLayout.LayoutParams) aboveControls.getLayoutParams()).getBehavior();
Objects.requireNonNull(behavior).setOnTopOfControlsChangedListener(topOfControls -> {
pictureInPictureGestureHelper.setExpandedVerticalBoundary(topOfControls);
});
if (callParticipantsOverflowGuideline != null) {
callParticipantsRecycler.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
callParticipantsOverflowGuideline.setGuidelineEnd(bottom - top);
});
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
final int pipWidth = smallLocalRenderFrame.getMeasuredWidth();
final int controlsWidth = callControlsSheet.getMeasuredWidth();
final float protection = DimensionUnit.DP.toPixels(16 * 4);
final float requiredWidth = pipWidth + controlsWidth + protection;
if (w > h && w >= requiredWidth) {
pictureInPictureGestureHelper.allowCollapsedState();
}
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
navBarBottomInset = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
if (lastState != null) {
updateCallParticipants(lastState);
}
return super.onApplyWindowInsets(insets);
}
@Override
public void onWindowSystemUiVisibilityChanged(int visible) {
final Guideline statusBarGuideline = getStatusBarGuideline();
if ((visible & SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
pictureInPictureGestureHelper.setTopVerticalBoundary(collapsedToolbar.getBottom());
} else if (statusBarGuideline != null) {
pictureInPictureGestureHelper.setTopVerticalBoundary(statusBarGuideline.getBottom());
} else {
Log.d(TAG, "Could not update PiP gesture helper.");
}
}
public void setControlsListener(@Nullable CallScreenControlsListener controlsListener) {
this.controlsListener = controlsListener;
}
public void maybeDismissAudioPicker() {
audioToggle.hidePicker();
}
public void setMicEnabled(boolean isMicEnabled) {
micToggle.setChecked(hasAudioPermission() && isMicEnabled, false);
}
public void setPendingParticipantsViewListener(@Nullable PendingParticipantsListener listener) {
pendingParticipantsViewListener = listener;
}
public void updatePendingParticipantsList(@NonNull PendingParticipantsState state) {
if (state.isInPipMode()) {
pendingParticipantsViewStub.setVisibility(View.GONE);
return;
}
if (state.getPendingParticipantCollection().getUnresolvedPendingParticipants().isEmpty()) {
if (pendingParticipantsViewStub.resolved()) {
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
pendingParticipantsViewStub.get().applyState(state.getPendingParticipantCollection());
}
} else {
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
pendingParticipantsViewStub.get().applyState(state.getPendingParticipantCollection());
}
}
private boolean hasCameraPermission() {
return Permissions.hasAll(getContext(), Manifest.permission.CAMERA);
}
private boolean hasAudioPermission() {
return Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO);
}
public void updateCallParticipants(@NonNull CallParticipantsViewState callParticipantsViewState) {
lastState = callParticipantsViewState;
CallParticipantsState state = callParticipantsViewState.getCallParticipantsState();
boolean isPortrait = callParticipantsViewState.isPortrait();
boolean isLandscapeEnabled = callParticipantsViewState.isLandscapeEnabled();
List<WebRtcCallParticipantsPage> pages = new ArrayList<>(2);
if (!state.getCallState().isErrorState()) {
if (!state.getGridParticipants().isEmpty()) {
pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled, state.getHideAvatar(), navBarBottomInset));
}
if (state.getFocusedParticipant() != CallParticipant.EMPTY && state.getAllRemoteParticipants().size() > 1) {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled));
}
}
if (state.getGroupCallState().isNotIdle()) {
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
if (state.isCallLink()) {
TextView warningTextView = callLinkWarningCard.get().findViewById(R.id.call_screen_call_link_warning_textview);
warningTextView.setText(SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled() ? R.string.WebRtcCallView__anyone_who_joins_pnp_enabled : R.string.WebRtcCallView__anyone_who_joins_pnp_disabled);
callLinkWarningCard.setVisibility(View.VISIBLE);
} else {
callLinkWarningCard.setVisibility(View.GONE);
}
setStatus(state.getPreJoinGroupDescription(getContext()));
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
callLinkWarningCard.setVisibility(View.GONE);
setStatus(state.getOutgoingRingingGroupDescription(getContext()));
} else if (state.getGroupCallState().isRinging()) {
callLinkWarningCard.setVisibility(View.GONE);
setStatus(state.getIncomingRingingGroupDescription(getContext()));
} else {
callLinkWarningCard.setVisibility(View.GONE);
}
}
if (state.getGroupCallState().isNotIdle()) {
boolean enabled = state.getParticipantCount().isPresent();
collapsedToolbar.getMenu().getItem(0).setVisible(enabled);
headerToolbar.getMenu().getItem(0).setVisible(enabled);
} else {
collapsedToolbar.getMenu().getItem(0).setVisible(false);
headerToolbar.getMenu().getItem(0).setVisible(false);
}
pagerAdapter.submitList(pages);
recyclerAdapter.submitList(state.getListParticipants());
reactionsAdapter.submitList(state.getReactions());
reactionViews.displayReactions(state.getReactions());
boolean displaySmallSelfPipInLandscape = !isPortrait && isLandscapeEnabled;
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape);
if (state.isLargeGroup()) {
adjustLayoutForLargeCount();
} else {
adjustLayoutForSmallCount();
}
}
public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state,
@NonNull CallParticipant localCallParticipant,
boolean displaySmallSelfPipInLandscape)
{
largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
smallLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
largeLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
localCallParticipant.getVideoSink().getLockableEglBase().performWithValidEglBase(eglBase -> {
largeLocalRender.init(eglBase);
});
videoToggle.setChecked(hasCameraPermission() && localCallParticipant.isVideoEnabled(), false);
smallLocalRender.setRenderInPip(true);
smallLocalRender.setCallParticipant(localCallParticipant);
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
if (state == WebRtcLocalRenderState.EXPANDED) {
if (largeLocalRenderFrame.getVisibility() == View.VISIBLE) {
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToExpandedRectangle(displaySmallSelfPipInLandscape, localCallParticipant.isMoreThanOneCameraAvailable());
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
} else {
pictureInPictureExpansionHelper.beginExpandTransition();
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP, localCallParticipant.isMoreThanOneCameraAvailable());
}
return;
} else if ((state.isAnySmall() || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) {
pictureInPictureExpansionHelper.beginShrinkTransition();
smallLocalRender.setSelfPipMode(pictureInPictureExpansionHelper.isMiniSize() ? CallParticipantView.SelfPipMode.MINI_SELF_PIP : CallParticipantView.SelfPipMode.NORMAL_SELF_PIP, localCallParticipant.isMoreThanOneCameraAvailable());
if (state != WebRtcLocalRenderState.GONE) {
return;
}
}
switch (state) {
case GONE:
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
smallLocalRenderFrame.setVisibility(View.GONE);
break;
case SMALL_RECTANGLE:
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToLargeRectangle(displaySmallSelfPipInLandscape, localCallParticipant.isMoreThanOneCameraAvailable());
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
break;
case SMALLER_RECTANGLE:
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToSmallRectangle(displaySmallSelfPipInLandscape, localCallParticipant.isMoreThanOneCameraAvailable());
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
break;
case LARGE:
largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink());
largeLocalRenderFrame.setVisibility(View.VISIBLE);
largeLocalRenderNoVideo.setVisibility(View.GONE);
largeLocalRenderNoVideoAvatar.setVisibility(View.GONE);
smallLocalRenderFrame.setVisibility(View.GONE);
break;
case LARGE_NO_VIDEO:
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.VISIBLE);
largeLocalRenderNoVideo.setVisibility(View.VISIBLE);
largeLocalRenderNoVideoAvatar.setVisibility(View.VISIBLE);
ContactPhoto localAvatar = new ProfileContactPhoto(localCallParticipant.getRecipient());
if (!localAvatar.equals(previousLocalAvatar)) {
previousLocalAvatar = localAvatar;
Glide.with(getContext().getApplicationContext())
.load(localAvatar)
.transform(new CenterCrop(), new BlurTransformation(getContext(), 0.25f, BlurTransformation.MAX_RADIUS))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(largeLocalRenderNoVideoAvatar);
}
smallLocalRenderFrame.setVisibility(View.GONE);
break;
}
}
public void setRecipient(@NonNull Recipient recipient) {
collapsedToolbar.setTitle(recipient.getDisplayName(getContext()));
recipientName.setText(recipient.getDisplayName(getContext()));
if (recipient.getId() == recipientId) {
return;
}
recipientId = recipient.getId();
largeHeaderAvatar.setRecipient(recipient, false);
}
public void setStatus(@Nullable String status) {
ThreadUtil.assertMainThread();
this.status.setText(status);
try {
// Toolbar's subtitle view sometimes already has a parent somehow,
// so we clear it out first so that it removes the view from its parent.
// In addition, we catch the ISE to prevent a crash.
collapsedToolbar.setSubtitle(null);
collapsedToolbar.setSubtitle(status);
} catch (IllegalStateException e) {
Log.w(TAG, "IllegalStateException trying to set status on collapsed Toolbar.");
}
}
public void setWebRtcControls(@NonNull WebRtcControls webRtcControls) {
Set<View> lastVisibleSet = new HashSet<>(visibleViewSet);
visibleViewSet.clear();
if (webRtcControls.adjustForFold()) {
showParticipantsGuideline.setGuidelineBegin(-1);
showParticipantsGuideline.setGuidelineEnd(webRtcControls.getFold());
topFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
} else {
showParticipantsGuideline.setGuidelineBegin(((LayoutParams) getStatusBarGuideline().getLayoutParams()).guideBegin);
showParticipantsGuideline.setGuidelineEnd(-1);
topFoldGuideline.setGuidelineEnd(0);
callScreenTopFoldGuideline.setGuidelineEnd(0);
}
if (webRtcControls.displayStartCallControls()) {
visibleViewSet.add(footerGradient);
visibleViewSet.add(startCallControls);
startCall.setText(webRtcControls.getStartCallButtonText());
startCall.setEnabled(webRtcControls.isStartCallEnabled());
}
if (webRtcControls.displayErrorControls()) {
visibleViewSet.add(footerGradient);
visibleViewSet.add(errorButton);
}
if (webRtcControls.displayGroupCallFull()) {
groupCallFullStub.get().setVisibility(View.VISIBLE);
((TextView) groupCallFullStub.get().findViewById(R.id.group_call_call_full_message)).setText(webRtcControls.getGroupCallFullMessage(getContext()));
} else if (groupCallFullStub.resolved()) {
groupCallFullStub.get().setVisibility(View.GONE);
}
if (webRtcControls.displayTopViews()) {
visibleViewSet.addAll(topViews);
}
if (webRtcControls.displayIncomingCallButtons()) {
visibleViewSet.addAll(incomingCallViews);
incomingRingStatus.setText(webRtcControls.displayAnswerWithoutVideo() ? R.string.WebRtcCallView__signal_video_call: R.string.WebRtcCallView__signal_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
}
if (webRtcControls.displayAnswerWithoutVideo()) {
visibleViewSet.add(answerWithoutVideo);
visibleViewSet.add(answerWithoutVideoLabel);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
}
if (!webRtcControls.displayIncomingCallButtons()){
incomingRingStatus.setVisibility(GONE);
}
if (webRtcControls.displayAudioToggle()) {
audioToggle.setControlAvailability(webRtcControls.isEarpieceAvailableForAudioToggle(),
webRtcControls.isBluetoothHeadsetAvailableForAudioToggle(),
webRtcControls.isWiredHeadsetAvailableForAudioToggle());
audioToggle.updateAudioOutputState(webRtcControls.getAudioOutput());
}
if (webRtcControls.displaySmallCallButtons()) {
updateButtonStateForSmallButtons();
} else {
updateButtonStateForLargeButtons();
}
if (webRtcControls.displayRemoteVideoRecycler()) {
callParticipantsRecycler.setVisibility(View.VISIBLE);
} else {
callParticipantsRecycler.setVisibility(View.GONE);
}
if (webRtcControls.showFullScreenShade()) {
fullScreenShade.setVisibility(VISIBLE);
visibleViewSet.remove(topGradient);
visibleViewSet.remove(footerGradient);
} else {
fullScreenShade.setVisibility(GONE);
}
if (webRtcControls.displayReactions()) {
visibleViewSet.add(reactionViews);
visibleViewSet.add(groupReactionsFeed);
}
if (webRtcControls.displayRaiseHand()) {
visibleViewSet.add(raiseHandSnackbar);
}
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
controls = webRtcControls;
if (!controls.isFadeOutEnabled()) {
boolean controlsVisible = true;
}
allTimeVisibleViews.addAll(visibleViewSet);
if (!visibleViewSet.equals(lastVisibleSet) ||
!controls.isFadeOutEnabled() ||
(webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.VISIBLE) ||
(!webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.GONE) ||
forceUpdate)
{
throttledDebouncer.publish(() -> fadeInNewUiState(webRtcControls.showSmallHeader()));
}
onWindowSystemUiVisibilityChanged(getWindowSystemUiVisibility());
}
public @NonNull View getVideoTooltipTarget() {
return videoToggle;
}
public @NonNull View getSwitchCameraTooltipTarget() {
return smallLocalRenderFrame;
}
public void showSpeakerViewHint() {
groupCallSpeakerHint.get().setVisibility(View.VISIBLE);
}
public void hideSpeakerViewHint() {
if (groupCallSpeakerHint.resolved()) {
groupCallSpeakerHint.get().setVisibility(View.GONE);
}
}
private void animatePipToExpandedRectangle(boolean isLandscape, boolean moreThanOneCameraAvailable) {
final Point dimens;
if (isLandscape) {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_HEIGHT_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_WIDTH_DP));
} else {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_WIDTH_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_HEIGHT_DP));
}
pictureInPictureExpansionHelper.startExpandedSizeTransition(dimens, new PictureInPictureExpansionHelper.Callback() {
@Override
public void onAnimationHasFinished() {
pictureInPictureGestureHelper.enableCorners();
}
});
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP, moreThanOneCameraAvailable);
}
private void animatePipToLargeRectangle(boolean isLandscape, boolean moreThanOneCameraAvailable) {
final Point dimens;
if (isLandscape) {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_HEIGHT_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_WIDTH_DP));
} else {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_WIDTH_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_HEIGHT_DP));
}
pictureInPictureExpansionHelper.startDefaultSizeTransition(dimens, new PictureInPictureExpansionHelper.Callback() {
@Override
public void onAnimationHasFinished() {
pictureInPictureGestureHelper.enableCorners();
}
});
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.NORMAL_SELF_PIP, moreThanOneCameraAvailable);
}
private void animatePipToSmallRectangle(boolean isLandscape, boolean moreThanOneCameraAvailable) {
final Point dimens;
if (isLandscape) {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_HEIGHT_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_WIDTH_DP));
} else {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_WIDTH_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_HEIGHT_DP));
}
pictureInPictureExpansionHelper.startDefaultSizeTransition(dimens,
new PictureInPictureExpansionHelper.Callback() {
@Override
public void onAnimationHasFinished() {
pictureInPictureGestureHelper.lockToBottomEnd();
}
});
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.MINI_SELF_PIP, moreThanOneCameraAvailable);
}
private void toggleControls() {
controlsListener.toggleControls();
}
private void adjustLayoutForSmallCount() {
adjustLayoutPositions(LayoutPositions.SMALL_GROUP);
}
private void adjustLayoutForLargeCount() {
adjustLayoutPositions(LayoutPositions.LARGE_GROUP);
}
private void adjustLayoutPositions(@NonNull LayoutPositions layoutPositions) {
if (previousLayoutPositions == layoutPositions) {
return;
}
previousLayoutPositions = layoutPositions;
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.setForceId(false);
constraintSet.clone(this);
constraintSet.connect(R.id.call_screen_reactions_feed,
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.connect(pendingParticipantsViewStub.getId(),
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.applyTo(this);
}
private void fadeInNewUiState(boolean showSmallHeader) {
for (View view : SetUtil.difference(allTimeVisibleViews, visibleViewSet)) {
view.setVisibility(GONE);
}
for (View view : visibleViewSet) {
view.setVisibility(VISIBLE);
}
if (showSmallHeader) {
collapsedToolbar.setEnabled(true);
collapsedToolbar.setAlpha(1);
headerToolbar.setEnabled(false);
headerToolbar.setAlpha(0);
largeHeader.setEnabled(false);
largeHeader.setAlpha(0);
} else {
collapsedToolbar.setEnabled(false);
collapsedToolbar.setAlpha(0);
headerToolbar.setEnabled(true);
headerToolbar.setAlpha(1);
largeHeader.setEnabled(true);
largeHeader.setAlpha(1);
}
}
private static <T> void runIfNonNull(@Nullable T listener, @NonNull Consumer<T> listenerConsumer) {
if (listener != null) {
listenerConsumer.accept(listener);
}
}
private void updateButtonStateForLargeButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup);
overflow.setImageResource(R.drawable.webrtc_call_screen_overflow_menu);
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle);
overflow.setBackgroundResource(R.drawable.webrtc_call_screen_overflow_menu);
}
private void updateButtonStateForSmallButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle_small);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup_small);
overflow.setImageResource(R.drawable.webrtc_call_screen_overflow_menu_small);
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle_small);
overflow.setBackgroundResource(R.drawable.webrtc_call_screen_overflow_menu_small);
}
public void switchToSpeakerView() {
if (pagerAdapter.getItemCount() > 0) {
callParticipantsPager.setCurrentItem(pagerAdapter.getItemCount() - 1, false);
}
}
public void setRingGroup(boolean shouldRingGroup) {
ringToggle.setChecked(shouldRingGroup, false);
}
public void enableRingGroup(boolean enabled) {
ringToggle.setActivated(enabled);
}
public void onControlTopChanged() {
}
}

View File

@@ -6,9 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;

View File

@@ -1,43 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc
import android.view.Gravity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.PopupWindow
import androidx.core.view.postDelayed
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.VibrateUtil
import java.util.concurrent.TimeUnit
/**
* Popup shown when the device is connected to a WiFi and cellular network, and WiFi is unusable for
* RingRTC.
*/
class WifiToCellularPopupWindow(private val parent: ViewGroup) : PopupWindow(
LayoutInflater.from(parent.context).inflate(R.layout.wifi_to_cellular_popup, parent, false),
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
) {
init {
animationStyle = R.style.PopupAnimation
}
fun show() {
if (parent.windowToken == null) {
return
}
showAtLocation(parent, Gravity.TOP or Gravity.START, 0, 0)
VibrateUtil.vibrate(parent.context, VIBRATE_DURATION_MS)
contentView.postDelayed(DISPLAY_DURATION_MS) {
dismiss()
}
}
companion object {
private val DISPLAY_DURATION_MS = TimeUnit.SECONDS.toMillis(4)
private const val VIBRATE_DURATION_MS = 50
}
}

View File

@@ -1,509 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.os.Handler
import android.os.Parcelable
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.Px
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.constraintlayout.widget.Guideline
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.parcelize.Parcelize
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsVisibilityListener
import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks
import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
/**
* Brain for rendering the call controls and info within a bottom sheet when we display the activity in portrait mode.
*/
class ControlsAndInfoController private constructor(
private val activity: BaseActivity,
private val webRtcCallView: WebRtcCallView,
private val overflowPopupWindow: CallOverflowPopupWindow,
private val viewModel: WebRtcCallViewModel,
private val controlsAndInfoViewModel: ControlsAndInfoViewModel,
private val disposables: CompositeDisposable
) : Disposable by disposables {
constructor(
activity: BaseActivity,
webRtcCallView: WebRtcCallView,
overflowPopupWindow: CallOverflowPopupWindow,
viewModel: WebRtcCallViewModel,
controlsAndInfoViewModel: ControlsAndInfoViewModel
) : this(
activity,
webRtcCallView,
overflowPopupWindow,
viewModel,
controlsAndInfoViewModel,
CompositeDisposable()
)
companion object {
private val TAG = Log.tag(ControlsAndInfoController::class.java)
private const val CONTROL_TRANSITION_DURATION = 250L
private const val CONTROL_FADE_OUT_START = 0f
private const val CONTROL_FADE_OUT_DONE = 0.23f
private const val INFO_FADE_IN_START = CONTROL_FADE_OUT_DONE
private const val INFO_FADE_IN_DONE = 0.8f
private val INFO_TRANSLATION_DISTANCE = 24f.dp
private val HIDE_CONTROL_DELAY = 5.seconds.inWholeMilliseconds
}
private val coordinator: CoordinatorLayout = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
private val callInfoComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
private val frame: FrameLayout = webRtcCallView.findViewById(R.id.call_controls_info_parent)
private val behavior = BottomSheetBehavior.from(frame)
private val raiseHandComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
private val aboveControlsGuideline: Guideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline)
private val toggleCameraDirectionView: View = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle)
private val startCallControls: View = webRtcCallView.findViewById(R.id.call_screen_start_call_controls)
private val callControls: ConstraintLayout = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
private val isLandscape = activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
private val waitingToBeLetInProgressDrawable = IndeterminateDrawable.createCircularDrawable(
activity,
CircularProgressIndicatorSpec(activity, null).apply {
indicatorSize = 20.dp
indicatorInset = 0.dp
trackThickness = 2.dp
trackCornerRadius = 1.dp
indicatorColors = intArrayOf(ContextCompat.getColor(activity, R.color.signal_colorOnBackground))
trackColor = Color.TRANSPARENT
}
)
private val waitingToBeLetIn: TextView = webRtcCallView.findViewById<TextView>(R.id.call_controls_waiting_to_be_let_in).apply {
setCompoundDrawablesRelativeWithIntrinsicBounds(waitingToBeLetInProgressDrawable, null, null, null)
}
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
private val callControlsVisibilityListeners = mutableSetOf<CallControlsVisibilityListener>()
private val handler: Handler?
get() = webRtcCallView.handler
private var previousCallControlHeightData = HeightData()
private var controlState: WebRtcControls = WebRtcControls.NONE
private val callInfoCallbacks = CallInfoCallbacks(activity, controlsAndInfoViewModel)
init {
raiseHandComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RaiseHandSnackbar.View(viewModel, showCallInfoListener = ::showCallInfo)
}
}
coordinator.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
webRtcCallView.post { onControlTopChanged() }
}
raiseHandComposeView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
onControlTopChanged()
}
val maxBehaviorHeightPercentage = if (isLandscape) 1f else 0.66f
val minFrameHeightDenominator = if (isLandscape) 1 else 2
callControls.viewTreeObserver.addOnGlobalLayoutListener {
if (callControls.height > 0 && previousCallControlHeightData.hasChanged(callControls.height, coordinator.height)) {
previousCallControlHeightData = HeightData(callControls.height, coordinator.height)
val controlPeakHeight = callControls.height + callControls.y.toInt() + 16.dp
if (startCallControls.isVisible) {
behavior.peekHeight = max(behavior.peekHeight, controlPeakHeight)
} else {
behavior.peekHeight = controlPeakHeight
}
frame.minimumHeight = coordinator.height / minFrameHeightDenominator
behavior.maxHeight = (coordinator.height.toFloat() * maxBehaviorHeightPercentage).toInt()
webRtcCallView.post { onControlTopChanged() }
}
}
webRtcCallView.addWindowInsetsListener(object : InsetAwareConstraintLayout.WindowInsetsListener {
override fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int) {
if (navigationBar > 0) {
callControls.padding(bottom = navigationBar)
}
}
})
overflowPopupWindow.setOnDismissListener {
hide(delay = HIDE_CONTROL_DELAY)
}
activity
.supportFragmentManager
.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, activity) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(activity, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
@SuppressLint("SwitchIntDef")
override fun onStateChanged(bottomSheet: View, newState: Int) {
overflowPopupWindow.dismiss()
when (newState) {
BottomSheetBehavior.STATE_COLLAPSED -> {
controlsAndInfoViewModel.resetScrollState()
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
updateCallSheetVisibilities(0f)
}
BottomSheetBehavior.STATE_EXPANDED -> {
cancelScheduledHide()
updateCallSheetVisibilities(1f)
}
BottomSheetBehavior.STATE_DRAGGING -> {
cancelScheduledHide()
}
BottomSheetBehavior.STATE_HIDDEN -> {
controlsAndInfoViewModel.resetScrollState()
updateCallSheetVisibilities(-1f)
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (slideOffset <= 1 || slideOffset >= -1) {
updateCallSheetVisibilities(slideOffset)
}
}
})
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
SignalTheme(
isDarkMode = true
) {
Surface {
CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop))
}
}
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE
}
fun addVisibilityListener(listener: CallControlsVisibilityListener): Boolean {
return callControlsVisibilityListeners.add(listener)
}
fun onStateRestored() {
when (behavior.state) {
BottomSheetBehavior.STATE_EXPANDED -> {
showCallInfo()
updateCallSheetVisibilities(1f)
}
BottomSheetBehavior.STATE_HIDDEN -> {
hide()
updateCallSheetVisibilities(-1f)
}
else -> {
showControls()
updateCallSheetVisibilities(0f)
}
}
}
fun showCallInfo() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
private fun showControls() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
callControlsVisibilityListeners.forEach { it.onShown() }
}
private fun hide(delay: Long = 0L) {
if (delay == 0L) {
if (controlState.isFadeOutEnabled || controlState == WebRtcControls.PIP || controlState.displayErrorControls() || controlState.displayIncomingCallButtons()) {
behavior.isHideable = true
behavior.state = BottomSheetBehavior.STATE_HIDDEN
callControlsVisibilityListeners.forEach { it.onHidden() }
}
} else {
cancelScheduledHide()
handler?.postDelayed(scheduleHideControlsRunnable, delay)
}
}
fun toggleControls() {
if (behavior.state == BottomSheetBehavior.STATE_EXPANDED || behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
showControls()
} else {
hide()
}
}
fun toggleOverflowPopup() {
if (overflowPopupWindow.isShowing) {
overflowPopupWindow.dismiss()
} else {
cancelScheduledHide()
overflowPopupWindow.show(aboveControlsGuideline)
}
}
fun updateControls(newControlState: WebRtcControls) {
val previousState = controlState
controlState = newControlState
showOrHideControlsOnUpdate(previousState)
if (controlState == WebRtcControls.PIP) {
waitingToBeLetIn.visible = false
toggleCameraDirectionView.visible = false
}
if (controlState != WebRtcControls.PIP && controlState.controlVisibilitiesChanged(previousState)) {
updateControlVisibilities()
}
}
fun restartHideControlsTimer() {
hide(delay = HIDE_CONTROL_DELAY)
}
private fun updateCallSheetVisibilities(slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE - (INFO_TRANSLATION_DISTANCE * callInfoComposeView.alpha)
}
private fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
}
private fun showOrHideControlsOnUpdate(previousState: WebRtcControls) {
if (controlState == WebRtcControls.PIP || controlState.displayErrorControls() || controlState.displayIncomingCallButtons()) {
hide()
return
}
if (controlState.hideControlsSheetInitially()) {
return
}
if (previousState.hideControlsSheetInitially() && (previousState != WebRtcControls.PIP)) {
showControls()
return
}
if (controlState.isFadeOutEnabled) {
if (!previousState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else {
cancelScheduledHide()
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
showControls()
}
}
}
private fun updateControlVisibilities() {
TransitionManager.endTransitions(callControls)
TransitionManager.beginDelayedTransition(
callControls,
AutoTransition().apply {
ordering = TransitionSet.ORDERING_TOGETHER
duration = CONTROL_TRANSITION_DURATION
}
)
val constraints = ConstraintSet().apply {
clone(callControls)
val margin = if (controlState.displaySmallCallButtons()) 4.dp else 8.dp
setControlConstraints(R.id.call_screen_speaker_toggle, controlState.displayAudioToggle(), margin)
setControlConstraints(R.id.call_screen_video_toggle, controlState.displayVideoToggle(), margin)
setControlConstraints(R.id.call_screen_audio_mic_toggle, controlState.displayMuteAudio(), margin)
setControlConstraints(R.id.call_screen_audio_ring_toggle, controlState.displayRingToggle(), margin)
setControlConstraints(R.id.call_screen_overflow_button, controlState.displayOverflow(), margin)
setControlConstraints(R.id.call_screen_end_call, controlState.displayEndCall(), margin)
}
constraints.applyTo(callControls)
toggleCameraDirectionView.visible = controlState.displayCameraToggle()
waitingToBeLetIn.visible = controlState.displayWaitingToBeLetIn()
if (controlState.displayWaitingToBeLetIn()) {
waitingToBeLetInProgressDrawable.setVisible(true, false)
} else {
waitingToBeLetInProgressDrawable.stop()
}
}
private fun onScheduledHide() {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED && !isDisposed) {
hide()
}
}
private fun cancelScheduledHide() {
handler?.removeCallbacks(scheduleHideControlsRunnable)
}
private fun setName(name: String) {
controlsAndInfoViewModel.setName(name)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
},
onError = handleError("setName")
)
.addTo(disposables)
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(activity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
private fun ConstraintSet.setControlConstraints(@IdRes viewId: Int, visible: Boolean, @Px horizontalMargins: Int) {
setVisibility(viewId, if (visible) View.VISIBLE else View.GONE)
setMargin(viewId, ConstraintSet.START, horizontalMargins)
setMargin(viewId, ConstraintSet.END, horizontalMargins)
}
private fun WebRtcControls.controlVisibilitiesChanged(previousState: WebRtcControls): Boolean {
return displayAudioToggle() != previousState.displayAudioToggle() ||
displayCameraToggle() != previousState.displayCameraToggle() ||
displayVideoToggle() != previousState.displayVideoToggle() ||
displayMuteAudio() != previousState.displayMuteAudio() ||
displayRingToggle() != previousState.displayRingToggle() ||
displayOverflow() != previousState.displayOverflow() ||
displayEndCall() != previousState.displayEndCall() ||
displayWaitingToBeLetIn() != previousState.displayWaitingToBeLetIn() ||
(previousState == WebRtcControls.PIP && this != WebRtcControls.PIP)
}
private fun alphaControls(slideOffset: Float): Float {
return if (slideOffset <= CONTROL_FADE_OUT_START) {
1f
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
0f
} else {
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
}
}
private fun alphaCallInfo(slideOffset: Float): Float {
return if (slideOffset >= INFO_FADE_IN_DONE) {
1f
} else if (slideOffset <= INFO_FADE_IN_START) {
0f
} else {
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
}
}
@Parcelize
private data class HeightData(
val controlHeight: Int = 0,
val coordinatorHeight: Int = 0
) : Parcelable {
fun hasChanged(controlHeight: Int, coordinatorHeight: Int): Boolean {
return controlHeight != this.controlHeight || coordinatorHeight != this.coordinatorHeight
}
}
}

View File

@@ -5,27 +5,16 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
@@ -48,7 +34,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow
import org.thoughtcrime.securesms.components.webrtc.v2.CallParticipantUpdatePopupController.DisplayState
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId
@@ -65,36 +50,16 @@ fun CallParticipantUpdatePopup(
controller: CallParticipantUpdatePopupController,
modifier: Modifier = Modifier
) {
val transitionState = remember { MutableTransitionState(controller.displayState != DisplayState.NONE) }
transitionState.targetState = controller.displayState != DisplayState.NONE
LaunchedEffect(transitionState.isIdle) {
if (transitionState.isIdle && !transitionState.targetState) {
controller.updateDisplay()
}
}
AnimatedVisibility(
visibleState = transitionState,
enter = slideInVertically { fullHeight -> -fullHeight } + fadeIn(),
exit = slideOutVertically { fullHeight -> -fullHeight } + fadeOut(),
CallScreenPopup(
visible = controller.displayState != DisplayState.NONE,
onDismiss = { controller.hide() },
displayDuration = controller.displayDuration,
onTransitionComplete = { controller.updateDisplay() },
modifier = modifier
.heightIn(min = 96.dp)
.fillMaxWidth()
) {
LaunchedEffect(controller.displayState, controller.participants) {
if (controller.displayState != DisplayState.NONE) {
delay(controller.displayDuration)
controller.hide()
}
}
PopupContent(
displayState = controller.displayState,
participants = controller.participants,
onClick = {
controller.hide()
}
participants = controller.participants
)
}
}
@@ -105,8 +70,7 @@ fun CallParticipantUpdatePopup(
@Composable
private fun PopupContent(
displayState: DisplayState,
participants: Set<CallParticipantListUpdate.Wrapper>,
onClick: () -> Unit
participants: Set<CallParticipantListUpdate.Wrapper>
) {
val context = LocalContext.current
@@ -135,7 +99,7 @@ private fun PopupContent(
previousDisplayState = displayStateForDescription
if (participants.isNotEmpty()) {
previousDescription = CallParticipantsListUpdatePopupWindow.getDescriptionForRecipients(
previousDescription = getDescriptionForRecipients(
context,
participants,
displayStateForDescription == DisplayState.ADD
@@ -147,18 +111,7 @@ private fun PopupContent(
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.wrapContentSize()
.padding(start = 12.dp, top = 30.dp, end = 12.dp)
.background(
color = colorResource(R.color.signal_light_colorSecondaryContainer),
shape = RoundedCornerShape(percent = 50)
)
.clickable(
onClick = onClick,
role = Role.Button
)
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(48.dp)
@@ -173,7 +126,8 @@ private fun PopupContent(
BadgeImageSmall(
badge = avatarRecipient.featuredBadge,
modifier = Modifier.padding(top = 28.dp, start = 28.dp)
modifier = Modifier
.padding(top = 28.dp, start = 28.dp)
.size(16.dp)
)
}
@@ -181,11 +135,48 @@ private fun PopupContent(
Text(
text = description,
color = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(vertical = 14.dp).padding(start = 10.dp, end = 24.dp)
modifier = Modifier
.padding(vertical = 14.dp)
.padding(start = 10.dp, end = 24.dp)
)
}
}
private fun getDescriptionForRecipients(
context: Context,
recipients: Set<CallParticipantListUpdate.Wrapper>,
isAdded: Boolean
): String {
val iterator = recipients.iterator()
return when (recipients.size) {
0 -> throw IllegalArgumentException("Recipients must contain 1 or more entries")
1 -> context.getString(getOneMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator))
2 -> context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator))
3 -> context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator))
else -> context.resources.getQuantityString(getManyMemberDescriptionResourceId(isAdded), recipients.size - 2, getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), recipients.size - 2)
}
}
private fun getNextDisplayName(context: Context, iterator: Iterator<CallParticipantListUpdate.Wrapper>): String {
return iterator.next().callParticipant.getRecipientDisplayName(context)
}
private fun getOneMemberDescriptionResourceId(isAdded: Boolean): Int {
return if (isAdded) R.string.CallParticipantsListUpdatePopupWindow__s_joined else R.string.CallParticipantsListUpdatePopupWindow__s_left
}
private fun getTwoMemberDescriptionResourceId(isAdded: Boolean): Int {
return if (isAdded) R.string.CallParticipantsListUpdatePopupWindow__s_and_s_joined else R.string.CallParticipantsListUpdatePopupWindow__s_and_s_left
}
private fun getThreeMemberDescriptionResourceId(isAdded: Boolean): Int {
return if (isAdded) R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_joined else R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_left
}
private fun getManyMemberDescriptionResourceId(isAdded: Boolean): Int {
return if (isAdded) R.plurals.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_joined else R.plurals.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_left
}
/**
* Controller owned by the [CallScreenMediator] which allows its callbacks to control this popup.
*/
@@ -265,8 +256,7 @@ private fun PopupContentPreview() {
Previews.Preview {
PopupContent(
displayState = DisplayState.ADD,
participants = participants.take(1).map { CallParticipantListUpdate.createWrapper(it) }.toSet(),
onClick = {}
participants = participants.take(1).map { CallParticipantListUpdate.createWrapper(it) }.toSet()
)
}
}

View File

@@ -70,8 +70,6 @@ import org.webrtc.RendererCommon
* Displays a remote participant (or local participant in pre-join screen).
* Handles both full-size grid view and system PIP mode.
*
* This is a Compose reimplementation of [org.thoughtcrime.securesms.components.webrtc.CallParticipantView].
*
* @param participant The call participant to display
* @param renderInPip Whether rendering in system PIP mode (smaller, simplified UI)
* @param raiseHandAllowed Whether to show raise hand indicator

View File

@@ -114,7 +114,10 @@ fun CallScreen(
onLocalPictureInPictureClicked: () -> Unit,
onLocalPictureInPictureFocusClicked: () -> Unit,
onControlsToggled: (Boolean) -> Unit,
onCallScreenDialogDismissed: () -> Unit = {}
onCallScreenDialogDismissed: () -> Unit = {},
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {}
) {
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
IncomingCallScreen(
@@ -421,6 +424,30 @@ fun CallScreen(
)
}
WifiToCellularPopup(
visible = callScreenState.displayWifiToCellularPopup,
onDismiss = onWifiToCellularPopupDismissed,
modifier = Modifier
.statusBarsPadding()
.fillMaxWidth()
)
SwipeToSpeakerHintPopup(
visible = callScreenState.displaySwipeToSpeakerHint,
onDismiss = onSwipeToSpeakerHintDismissed,
modifier = Modifier
.statusBarsPadding()
.fillMaxWidth()
)
RemoteMuteToastPopup(
message = callScreenState.remoteMuteToastMessage,
onDismiss = onRemoteMuteToastDismissed,
modifier = Modifier
.statusBarsPadding()
.fillMaxWidth()
)
CallScreenDialog(callScreenDialogType, onCallScreenDialogDismissed)
}

View File

@@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import org.whispersystems.signalservice.api.messages.calls.HangupMessage
@@ -52,6 +51,7 @@ interface CallScreenMediator {
fun enableParticipantUpdatePopup(enabled: Boolean)
fun enableCallStateUpdatePopup(enabled: Boolean)
fun showWifiToCellularPopupWindow()
fun showRemoteMuteToast(message: String)
fun hideMissingPermissionsNotice()
fun setStatusFromGroupCallState(context: Context, groupCallState: WebRtcViewModel.GroupCallState) {
@@ -91,11 +91,7 @@ interface CallScreenMediator {
companion object {
fun create(activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel): CallScreenMediator {
return if (RemoteConfig.newCallUi) {
ComposeCallScreenMediator(activity, viewModel)
} else {
ViewCallScreenMediator(activity, viewModel)
}
return ComposeCallScreenMediator(activity, viewModel)
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.thoughtcrime.securesms.R
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Common popup container for call screen notifications that slide in from the top.
* Used for participant updates, wifi-to-cellular notifications, etc.
*
* @param visible Whether the popup should be visible
* @param onDismiss Called when the popup is dismissed (either by timeout or user interaction)
* @param displayDuration How long to display the popup before auto-dismissing
* @param onTransitionComplete Called when the exit transition completes, useful for queuing next popups
* @param modifier Modifier for the outer container
* @param content The content to display inside the pill-shaped popup
*/
@Composable
fun CallScreenPopup(
visible: Boolean,
onDismiss: () -> Unit,
displayDuration: Duration = 4.seconds,
onTransitionComplete: (() -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {
val transitionState = remember { MutableTransitionState(visible) }
transitionState.targetState = visible
LaunchedEffect(transitionState.isIdle) {
if (transitionState.isIdle && !transitionState.targetState) {
onTransitionComplete?.invoke()
}
}
AnimatedVisibility(
visibleState = transitionState,
enter = slideInVertically { fullHeight -> -fullHeight } + fadeIn(),
exit = slideOutVertically { fullHeight -> -fullHeight } + fadeOut(),
modifier = modifier
.heightIn(min = 96.dp)
.fillMaxWidth()
) {
LaunchedEffect(visible) {
if (visible) {
delay(displayDuration)
onDismiss()
}
}
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.wrapContentSize()
.padding(start = 12.dp, top = 30.dp, end = 12.dp)
.background(
color = colorResource(R.color.signal_light_colorSecondaryContainer),
shape = RoundedCornerShape(percent = 50)
)
.clickable(
onClick = onDismiss,
role = Role.Button
),
content = content
)
}
}

View File

@@ -27,6 +27,7 @@ data class CallScreenState(
val displayVideoTooltip: Boolean = false,
val displaySwipeToSpeakerHint: Boolean = false,
val displayWifiToCellularPopup: Boolean = false,
val remoteMuteToastMessage: String? = null,
val displayAdditionalActionsDialog: Boolean = false,
val displayMissingPermissionsNotice: Boolean = false,
val pendingParticipantsState: PendingParticipantsState? = null,

View File

@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber.Companion.CUSTOM_REACTION_BOTTOM_SHEET_TAG
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
@@ -61,6 +60,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
companion object {
private val TAG = Log.tag(ComposeCallScreenMediator::class)
private const val CUSTOM_REACTION_BOTTOM_SHEET_TAG = "CallReaction"
}
private val callScreenViewModel = ViewModelProvider(activity)[CallScreenViewModel::class]
@@ -212,6 +212,9 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onLocalPictureInPictureFocusClicked = viewModel::onLocalPictureInPictureFocusClicked,
onControlsToggled = onControlsToggled,
onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } },
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController
)
}
@@ -355,6 +358,10 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = true) }
}
override fun showRemoteMuteToast(message: String) {
callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = message) }
}
override fun hideMissingPermissionsNotice() {
callScreenViewModel.callScreenState.update { it.copy(displayMissingPermissionsNotice = false) }
}

View File

@@ -33,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.graphics.vector.ImageVector
@@ -41,7 +40,6 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import kotlin.time.Duration.Companion.seconds
/**
* Popup shown when a user is remotely muted during a call.
*/
@Composable
fun RemoteMuteToastPopup(
message: String?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
CallScreenPopup(
visible = message != null,
onDismiss = onDismiss,
displayDuration = 3.seconds,
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_mic_off_solid_18),
contentDescription = null,
tint = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.size(18.dp)
)
Text(
text = message ?: "",
color = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
@NightPreview
@Composable
private fun RemoteMuteToastPopupPreview() {
Previews.Preview {
RemoteMuteToastPopup(
message = "Alex muted you",
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import kotlin.time.Duration.Companion.seconds
/**
* Popup shown to hint the user that they can swipe to view screen share.
*/
@Composable
fun SwipeToSpeakerHintPopup(
visible: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
CallScreenPopup(
visible = visible,
onDismiss = onDismiss,
displayDuration = 3.seconds,
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_arrow_down_24),
contentDescription = null,
tint = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(R.string.CallToastPopupWindow__swipe_to_view_screen_share),
color = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
@NightPreview
@Composable
private fun SwipeToSpeakerHintPopupPreview() {
Previews.Preview {
SwipeToSpeakerHintPopup(
visible = true,
onDismiss = {}
)
}
}

View File

@@ -1,203 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
/**
* Wraps WebRtcCallView and supporting code into a mediator subclass
*/
class ViewCallScreenMediator(
private val activity: WebRtcCallActivity,
private val viewModel: WebRtcCallViewModel
) : CallScreenMediator {
private val callScreen: WebRtcCallView
private val participantUpdateWindow: CallParticipantsListUpdatePopupWindow
private val callStateUpdatePopupWindow: CallStateUpdatePopupWindow
private val callOverflowPopupWindow: CallOverflowPopupWindow
private val wifiToCellularPopupWindow: WifiToCellularPopupWindow
private val controlsAndInfo: ControlsAndInfoController
private val controlsAndInfoViewModel: ControlsAndInfoViewModel
private val lifecycleDisposable = LifecycleDisposable()
init {
activity.setContentView(R.layout.webrtc_call_activity)
callScreen = activity.findViewById(R.id.callScreen)
participantUpdateWindow = CallParticipantsListUpdatePopupWindow(callScreen)
callStateUpdatePopupWindow = CallStateUpdatePopupWindow(callScreen)
wifiToCellularPopupWindow = WifiToCellularPopupWindow(callScreen)
callOverflowPopupWindow = CallOverflowPopupWindow(activity, callScreen) {
val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot
state.localParticipant.isHandRaised
}
activity.lifecycle.addObserver(participantUpdateWindow)
controlsAndInfoViewModel = ViewModelProvider(activity)[ControlsAndInfoViewModel::class]
controlsAndInfo = ControlsAndInfoController(activity, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel)
lifecycleDisposable.bindTo(activity.lifecycle)
lifecycleDisposable.add(controlsAndInfo)
}
override fun setWebRtcCallState(callState: WebRtcViewModel.State) = Unit
override fun setControlsAndInfoVisibilityListener(listener: CallControlsVisibilityListener) {
controlsAndInfo.addVisibilityListener(listener)
}
override fun onStateRestored() {
controlsAndInfo.onStateRestored()
}
override fun toggleOverflowPopup() {
controlsAndInfo.toggleOverflowPopup()
}
override fun restartHideControlsTimer() {
controlsAndInfo.restartHideControlsTimer()
}
override fun showCallInfo() {
controlsAndInfo.showCallInfo()
}
override fun toggleControls() {
controlsAndInfo.toggleControls()
}
override fun setControlsListener(controlsListener: CallScreenControlsListener) {
callScreen.setControlsListener(controlsListener)
}
override fun setMicEnabled(enabled: Boolean) {
callScreen.setMicEnabled(enabled)
}
override fun setRecipient(recipient: Recipient) {
controlsAndInfoViewModel.setRecipient(recipient)
callScreen.setRecipient(recipient)
}
override fun setStatus(status: String) {
callScreen.setStatus(status)
}
override fun setWebRtcControls(webRtcControls: WebRtcControls) {
callScreen.setWebRtcControls(webRtcControls)
controlsAndInfo.updateControls(webRtcControls)
}
override fun updateCallParticipants(callParticipantsViewState: CallParticipantsViewState) {
callScreen.updateCallParticipants(callParticipantsViewState)
}
override fun maybeDismissAudioPicker() {
callScreen.maybeDismissAudioPicker()
}
override fun setPendingParticipantsViewListener(pendingParticipantsViewListener: PendingParticipantsListener) {
callScreen.setPendingParticipantsViewListener(pendingParticipantsViewListener)
}
override fun updatePendingParticipantsList(pendingParticipantsList: PendingParticipantsState) {
callScreen.updatePendingParticipantsList(pendingParticipantsList)
}
override fun setRingGroup(ringGroup: Boolean) {
callScreen.setRingGroup(ringGroup)
}
override fun switchToSpeakerView() {
callScreen.switchToSpeakerView()
}
override fun enableRingGroup(canRing: Boolean) {
callScreen.enableRingGroup(canRing)
}
override fun showSpeakerViewHint() {
callScreen.showSpeakerViewHint()
}
override fun hideSpeakerViewHint() {
callScreen.hideSpeakerViewHint()
}
override fun showVideoTooltip(): Dismissible {
val tooltip = TooltipPopup.forTarget(callScreen.videoTooltipTarget)
.setBackgroundTint(ContextCompat.getColor(activity, R.color.core_ultramarine))
.setTextColor(ContextCompat.getColor(activity, R.color.core_white))
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
.setOnDismissListener { viewModel.onDismissedVideoTooltip() }
.show(TooltipPopup.POSITION_ABOVE)
return Dismissible {
tooltip.dismiss()
}
}
override fun showCameraTooltip(): Dismissible {
val tooltip = TooltipPopup.forTarget(callScreen.switchCameraTooltipTarget)
.setBackgroundTint(ContextCompat.getColor(activity, R.color.core_ultramarine))
.setTextColor(ContextCompat.getColor(activity, R.color.core_white))
.setText(R.string.WebRtcCallActivity__flip_camera_tooltip)
.setOnDismissListener {
viewModel.onDismissedSwitchCameraTooltip()
}
.show(TooltipPopup.POSITION_ABOVE)
return Dismissible { tooltip.dismiss() }
}
override fun onCallStateUpdate(callControlsChange: CallControlsChange) {
callStateUpdatePopupWindow.onCallStateUpdate(callControlsChange)
}
override fun dismissCallOverflowPopup() {
callOverflowPopupWindow.dismiss()
}
override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) {
participantUpdateWindow.addCallParticipantListUpdate(callParticipantListUpdate)
}
override fun enableParticipantUpdatePopup(enabled: Boolean) {
participantUpdateWindow.setEnabled(enabled)
}
override fun enableCallStateUpdatePopup(enabled: Boolean) {
callStateUpdatePopupWindow.setEnabled(enabled)
}
override fun showWifiToCellularPopupWindow() {
wifiToCellularPopupWindow.show()
}
override fun hideMissingPermissionsNotice() {
callScreen.findViewById<View>(R.id.missing_permissions_container).visible = false
}
}

View File

@@ -56,8 +56,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.sensors.Orientation
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil
import org.thoughtcrime.securesms.components.webrtc.InCallStatus
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet
@@ -87,7 +85,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.VibrateUtil
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.ChosenAudioDeviceIdentifier
@@ -102,6 +99,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private const val STANDARD_DELAY_FINISH = 1000L
private const val VIBRATE_DURATION = 50
private const val CUSTOM_REACTION_BOTTOM_SHEET_TAG = "CallReaction"
}
private lateinit var callScreen: CallScreenMediator
@@ -182,10 +180,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
initializePendingParticipantFragmentListener()
if (!RemoteConfig.newCallUi) {
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface))
}
if (!hasCameraPermission() && !hasAudioPermission()) {
askCameraAudioPermissions {
callScreen.setMicEnabled(viewModel.microphoneEnabled.value)
@@ -460,10 +454,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private fun initializeViewModel() {
val orientation: Orientation = resolveOrientationFromContext()
if (orientation == Orientation.PORTRAIT_BOTTOM_EDGE && !RemoteConfig.newCallUi) {
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface2))
WindowUtil.clearTranslucentNavigationBar(window)
}
AppDependencies.signalCallManager.orientationChanged(true, orientation.degrees)
@@ -624,7 +614,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private fun registerSystemPipChangeListeners() {
addOnPictureInPictureModeChangedListener {
CallReactionScrubber.dismissCustomEmojiBottomSheet(supportFragmentManager)
(supportFragmentManager.findFragmentByTag(CUSTOM_REACTION_BOTTOM_SHEET_TAG) as? ReactWithAnyEmojiBottomSheetDialogFragment)?.dismissNow()
}
}
@@ -879,8 +869,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
is CallEvent.StartCall -> startCall(event.isVideoCall)
is CallEvent.ShowGroupCallSafetyNumberChange -> SafetyNumberBottomSheet.forGroupCall(event.identityRecords).show(supportFragmentManager)
is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView()
is CallEvent.ShowSwipeToSpeakerHint -> CallToastPopupWindow.show(rootView())
is CallEvent.ShowRemoteMuteToast -> CallToastPopupWindow.show(rootView(), R.drawable.ic_mic_off_solid_18, event.getDescription(this))
is CallEvent.ShowSwipeToSpeakerHint -> callScreen.showSpeakerViewHint()
is CallEvent.ShowRemoteMuteToast -> callScreen.showRemoteMuteToast(event.getDescription(this))
is CallEvent.ShowVideoTooltip -> {
if (isInPipMode()) return
@@ -1104,12 +1094,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private val fullScreenHelper: FullscreenHelper = FullscreenHelper(this@WebRtcCallActivity)
init {
fullScreenHelper.showAndHideWithSystemUI(
window,
findViewById(R.id.call_screen_header_gradient),
findViewById(R.id.webrtc_call_view_toolbar_text),
findViewById(R.id.webrtc_call_view_toolbar_no_text)
)
fullScreenHelper.showAndHideWithSystemUI(window)
}
override fun onShown() {

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.VibrateUtil
import kotlin.time.Duration.Companion.seconds
private const val VIBRATE_DURATION_MS = 50
/**
* Popup shown when the device is connected to a WiFi and cellular network, and WiFi is unusable for
* RingRTC, causing a switch to cellular.
*/
@Composable
fun WifiToCellularPopup(
visible: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
LaunchedEffect(visible) {
if (visible) {
VibrateUtil.vibrate(context, VIBRATE_DURATION_MS)
}
}
CallScreenPopup(
visible = visible,
onDismiss = onDismiss,
displayDuration = 4.seconds,
modifier = modifier
) {
Text(
text = stringResource(R.string.WifiToCellularPopupWindow__weak_wifi_switched_to_cellular),
color = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 14.dp)
)
}
}
@NightPreview
@Composable
private fun WifiToCellularPopupPreview() {
Previews.Preview {
WifiToCellularPopup(
visible = true,
onDismiss = {}
)
}
}

View File

@@ -1114,14 +1114,6 @@ object RemoteConfig {
hotSwappable = true
)
@JvmStatic
@get:JvmName("newCallUi")
val newCallUi: Boolean by remoteBoolean(
key = "android.newCallUi.2",
defaultValue = false,
hotSwappable = false
)
@JvmStatic
@get:JvmName("useHevcEncoder")
val useHevcEncoder: Boolean by remoteBoolean(