mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
Clean up old calling code.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user