Receive calling reactions support and control ux refactor.

Co-authored-by: Nicholas <nicholas@signal.org>
This commit is contained in:
Cody Henthorne 2023-12-06 13:35:19 -05:00
parent 7ce2991b0f
commit a678555d8d
36 changed files with 1852 additions and 747 deletions

View file

@ -1,14 +1,13 @@
package com.google.android.material.bottomsheet package com.google.android.material.bottomsheet
import android.view.View import android.view.View
import android.widget.FrameLayout
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
/** /**
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior]. * Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
*/ */
object BottomSheetBehaviorHack { object BottomSheetBehaviorHack {
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) { fun <T : View> setNestedScrollingChild(behavior: BottomSheetBehavior<T>, view: View) {
behavior.nestedScrollingChildRef = WeakReference(view) behavior.nestedScrollingChildRef = WeakReference(view)
} }
} }

View file

@ -29,6 +29,7 @@ import android.media.AudioManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Rational; import android.util.Rational;
import android.view.View;
import android.view.Window; import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
@ -53,8 +54,8 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil; import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet; import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls; import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow; import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog; import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet; import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
@ -152,6 +154,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private PictureInPictureParams.Builder pipBuilderParams; private PictureInPictureParams.Builder pipBuilderParams;
private LifecycleDisposable lifecycleDisposable; private LifecycleDisposable lifecycleDisposable;
private long lastCallLinkDisconnectDialogShowTime; private long lastCallLinkDisconnectDialogShowTime;
private ControlsAndInfoController controlsAndInfo;
private Disposable ephemeralStateDisposable = Disposable.empty(); private Disposable ephemeralStateDisposable = Disposable.empty();
@ -161,7 +164,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase); super.attachBaseContext(newBase);
} }
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")"); Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
@ -189,6 +192,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
initializeViewModel(isLandscapeEnabled); initializeViewModel(isLandscapeEnabled);
initializePictureInPictureParams(); initializePictureInPictureParams();
controlsAndInfo = new ControlsAndInfoController(callScreen, viewModel);
controlsAndInfo.addVisibilityListener(new FadeCallback());
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.webrtc_call_view_toolbar_text), findViewById(R.id.webrtc_call_view_toolbar_no_text));
lifecycleDisposable.add(controlsAndInfo);
logIntent(getIntent()); logIntent(getIntent());
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) { if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
@ -431,7 +441,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.setIsLandscapeEnabled(isLandscapeEnabled); viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
viewModel.setIsInPipMode(isInPipMode()); viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled); viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls); viewModel.getWebRtcControls().observe(this, controls -> {
callScreen.setWebRtcControls(controls);
controlsAndInfo.updateControls(controls);
});
viewModel.getEvents().observe(this, this::handleViewModelEvent); viewModel.getEvents().observe(this, this::handleViewModelEvent);
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus)); lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
@ -522,7 +535,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video) .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
.setOnDismissListener(() -> viewModel.onDismissedVideoTooltip()) .setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
.show(TooltipPopup.POSITION_ABOVE); .show(TooltipPopup.POSITION_ABOVE);
return;
} }
} else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) { } else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) {
if (videoTooltip != null) { if (videoTooltip != null) {
@ -912,7 +924,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleCallPreJoin(@NonNull WebRtcViewModel event) { private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) { if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
callScreen.setRingGroup(event.shouldRingGroup()); callScreen.setRingGroup(event.shouldRingGroup());
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) { if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
@ -946,10 +957,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
} }
@Override @Override
public void onControlsFadeOut() { public void toggleControls() {
if (videoTooltip != null) { controlsAndInfo.toggleControls();
videoTooltip.dismiss();
}
} }
@Override @Override
@ -1060,7 +1069,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (liveRecipient.get().isCallLink()) { if (liveRecipient.get().isCallLink()) {
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId()); CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
} else { } else {
CallParticipantsListDialog.show(getSupportFragmentManager()); controlsAndInfo.showCallInfo();
} }
} }
@ -1124,4 +1133,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
} }
} }
} }
private class FadeCallback implements ControlsAndInfoController.BottomSheetVisibilityListener {
@Override
public void onShown() {
fullscreenHelper.showSystemUI();
}
@Override
public void onHidden() {
fullscreenHelper.hideSystemUI();
if (videoTooltip != null) {
videoTooltip.dismiss();
}
}
}
} }

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.animation; package org.thoughtcrime.securesms.animation;
import android.graphics.Point;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.Animation; import android.view.animation.Animation;
@ -16,6 +17,10 @@ public class ResizeAnimation extends Animation {
private int startWidth; private int startWidth;
private int startHeight; private int startHeight;
public ResizeAnimation(@NonNull View target, @NonNull Point dimension) {
this(target, dimension.x, dimension.y);
}
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) { public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
this.target = target; this.target = target;
this.targetWidthPx = targetWidthPx; this.targetWidthPx = targetWidthPx;

View file

@ -51,13 +51,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
} }
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) } protected val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) } private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) } private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) } private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) } private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
private val listeners: MutableList<KeyboardStateListener> = mutableListOf() private val windowInsetsListeners: MutableSet<WindowInsetsListener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
private val keyboardAnimator = KeyboardInsetAnimator() private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics() private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false private var overridingKeyboard: Boolean = false
@ -82,20 +83,35 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
} }
fun addKeyboardStateListener(listener: KeyboardStateListener) { fun addKeyboardStateListener(listener: KeyboardStateListener) {
listeners += listener keyboardStateListeners += listener
} }
fun removeKeyboardStateListener(listener: KeyboardStateListener) { fun removeKeyboardStateListener(listener: KeyboardStateListener) {
listeners.remove(listener) keyboardStateListeners.remove(listener)
}
fun addWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners += listener
}
fun removeWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners.remove(listener)
} }
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) { private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this) val isLtr = ViewUtil.isLtr(this)
statusBarGuideline?.setGuidelineBegin(windowInsets.top) val statusBar = windowInsets.top
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom) val navigationBar = windowInsets.bottom
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right) val parentStart = if (isLtr) windowInsets.left else windowInsets.right
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left) val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
statusBarGuideline?.setGuidelineBegin(statusBar)
navigationBarGuideline?.setGuidelineEnd(navigationBar)
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
if (keyboardInsets.bottom > 0) { if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom) setKeyboardHeight(keyboardInsets.bottom)
@ -113,7 +129,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
} }
if (previousKeyboardHeight != keyboardInsets.bottom) { if (previousKeyboardHeight != keyboardInsets.bottom) {
listeners.forEach { keyboardStateListeners.forEach {
if (previousKeyboardHeight <= 0) { if (previousKeyboardHeight <= 0) {
it.onKeyboardShown() it.onKeyboardShown()
} else { } else {
@ -191,6 +207,10 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
fun onKeyboardHidden() fun onKeyboardHidden()
} }
interface WindowInsetsListener {
fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int)
}
/** /**
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing. * Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
*/ */

View file

@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.recyclerview
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.recyclerview.widget.RecyclerView
/**
* Ignores all touch events, purely for rendering views in a recyclable manner.
*/
class NoTouchingRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
return false
}
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
return false
}
}

View file

@ -51,6 +51,7 @@ import androidx.lifecycle.toLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.BottomSheets import org.signal.core.ui.BottomSheets
@ -121,10 +122,6 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
@Composable @Composable
override fun SheetContent() { override fun SheetContent() {
val callLinkDetailsState by callLinkDetailsViewModel.state val callLinkDetailsState by callLinkDetailsViewModel.state
val callParticipantsState by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState()
val participants: List<CallParticipant> by webRtcCallViewModel.callParticipantsState val participants: List<CallParticipant> by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST) .toFlowable(BackpressureStrategy.LATEST)
@ -332,7 +329,7 @@ private fun CallLinkMemberRow(
.fillMaxWidth() .fillMaxWidth()
.padding(Rows.defaultPadding()) .padding(Rows.defaultPadding())
) { ) {
val recipient by Recipient.observable(callParticipant.recipient.id) val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id)))
.toFlowable(BackpressureStrategy.LATEST) .toFlowable(BackpressureStrategy.LATEST)
.toLiveData() .toLiveData()
.observeAsState(initial = callParticipant.recipient) .observeAsState(initial = callParticipant.recipient)

View file

@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
@ -28,12 +29,14 @@ data class CallParticipantsState(
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false), val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false),
val focusedParticipant: CallParticipant = CallParticipant.EMPTY, val focusedParticipant: CallParticipant = CallParticipant.EMPTY,
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE, val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
val reactions: List<GroupCallReactionEvent> = emptyList(),
val isInPipMode: Boolean = false, val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false, private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false, val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(), val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
private val foldableState: FoldableState = FoldableState.flat(), private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false, val isInOutgoingRingingMode: Boolean = false,
val recipient: Recipient = Recipient.UNKNOWN,
val ringGroup: Boolean = false, val ringGroup: Boolean = false,
val ringerRecipient: Recipient = Recipient.UNKNOWN, val ringerRecipient: Recipient = Recipient.UNKNOWN,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(), val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
@ -223,6 +226,7 @@ data class CallParticipantsState(
focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants), focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants),
localRenderState = localRenderState, localRenderState = localRenderState,
showVideoForOutgoing = newShowVideoForOutgoing, showVideoForOutgoing = newShowVideoForOutgoing,
recipient = webRtcViewModel.recipient,
remoteDevicesCount = webRtcViewModel.remoteDevicesCount, remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.ringGroup, ringGroup = webRtcViewModel.ringGroup,
isInOutgoingRingingMode = isInOutgoingRingingMode, isInOutgoingRingingMode = isInOutgoingRingingMode,
@ -269,7 +273,8 @@ data class CallParticipantsState(
return oldState.copy( return oldState.copy(
remoteParticipants = oldState.remoteParticipants.map { p -> p.copy(audioLevel = ephemeralState.remoteAudioLevels[p.callParticipantId]) }, remoteParticipants = oldState.remoteParticipants.map { p -> p.copy(audioLevel = ephemeralState.remoteAudioLevels[p.callParticipantId]) },
localParticipant = oldState.localParticipant.copy(audioLevel = ephemeralState.localAudioLevel), localParticipant = oldState.localParticipant.copy(audioLevel = ephemeralState.localAudioLevel),
focusedParticipant = oldState.focusedParticipant.copy(audioLevel = ephemeralState.remoteAudioLevels[oldState.focusedParticipant.callParticipantId]) focusedParticipant = oldState.focusedParticipant.copy(audioLevel = ephemeralState.remoteAudioLevels[oldState.focusedParticipant.callParticipantId]),
reactions = ephemeralState.getUnexpiredReactions()
) )
} }
@ -287,7 +292,7 @@ data class CallParticipantsState(
val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled) val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled)
var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE
if (!isInPip && isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) { if (!isInPip && isExpanded && localParticipant.isVideoEnabled) {
return WebRtcLocalRenderState.EXPANDED return WebRtcLocalRenderState.EXPANDED
} else if (displayLocal || showVideoForOutgoing) { } else if (displayLocal || showVideoForOutgoing) {
if (callState == WebRtcViewModel.State.CALL_CONNECTED || callState == WebRtcViewModel.State.CALL_RECONNECTING) { if (callState == WebRtcViewModel.State.CALL_CONNECTED || callState == WebRtcViewModel.State.CALL_RECONNECTING) {

View file

@ -84,7 +84,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
measureChild() measureChild()
val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_footer_gradient_barrier) val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_above_controls_guideline)
val pill: View = ViewCompat.requireViewById(contentView, R.id.call_state_pill) 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) // 54 is the top margin of the contentView (30) plus the desired padding (24)

View file

@ -0,0 +1,37 @@
/*
* 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_above_controls_guideline,
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_participants_recycler,
reactionBottomMargin = 20
);
@JvmField
val participantBottomViewEndSide: Int = if (participantBottomViewId == ConstraintSet.PARENT_ID) ConstraintSet.BOTTOM else ConstraintSet.TOP
}

View file

@ -1,18 +1,38 @@
package org.thoughtcrime.securesms.components.webrtc; package org.thoughtcrime.securesms.components.webrtc;
import android.graphics.Point;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.Animation;
import androidx.annotation.MainThread; import androidx.annotation.MainThread;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener;
import org.thoughtcrime.securesms.util.ViewUtil;
/** /**
* Helps manage the expansion and shrinking of the in-app pip. * Helps manage the expansion and shrinking of the in-app pip.
*/ */
@MainThread @MainThread
final class PictureInPictureExpansionHelper { 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;
private final View selfPip;
private final Point expandedDimensions;
private State state = State.IS_SHRUNKEN; private State state = State.IS_SHRUNKEN;
private Point defaultDimensions;
public PictureInPictureExpansionHelper(@NonNull View selfPip) {
this.selfPip = selfPip;
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));
}
public boolean isExpandedOrExpanding() { public boolean isExpandedOrExpanding() {
return state == State.IS_EXPANDED || state == State.IS_EXPANDING; return state == State.IS_EXPANDED || state == State.IS_EXPANDING;
@ -22,144 +42,79 @@ final class PictureInPictureExpansionHelper {
return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING; return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING;
} }
public void expand(@NonNull View toExpand, @NonNull Callback callback) { public void setDefaultSize(@NonNull Point dimensions, @NonNull Callback callback) {
if (defaultDimensions.equals(dimensions)) {
return;
}
defaultDimensions = dimensions;
if (isExpandedOrExpanding()) { if (isExpandedOrExpanding()) {
return; return;
} }
performExpandAnimation(toExpand, new Callback() { ViewGroup.LayoutParams layoutParams = selfPip.getLayoutParams();
if (layoutParams.width == defaultDimensions.x && layoutParams.height == defaultDimensions.y) {
callback.onAnimationHasFinished();
return;
}
resizeSelfPip(defaultDimensions, callback);
}
public void expand() {
if (isExpandedOrExpanding()) {
return;
}
resizeSelfPip(expandedDimensions, new Callback() {
@Override @Override
public void onAnimationWillStart() { public void onAnimationWillStart() {
state = State.IS_EXPANDING; state = State.IS_EXPANDING;
callback.onAnimationWillStart();
}
@Override
public void onPictureInPictureExpanded() {
callback.onPictureInPictureExpanded();
}
@Override
public void onPictureInPictureNotVisible() {
callback.onPictureInPictureNotVisible();
} }
@Override @Override
public void onAnimationHasFinished() { public void onAnimationHasFinished() {
state = State.IS_EXPANDED; state = State.IS_EXPANDED;
callback.onAnimationHasFinished();
} }
}); });
} }
public void shrink(@NonNull View toExpand, @NonNull Callback callback) { public void shrink() {
if (isShrunkenOrShrinking()) { if (isShrunkenOrShrinking()) {
return; return;
} }
performShrinkAnimation(toExpand, new Callback() { resizeSelfPip(defaultDimensions, new Callback() {
@Override @Override
public void onAnimationWillStart() { public void onAnimationWillStart() {
state = State.IS_SHRINKING; state = State.IS_SHRINKING;
callback.onAnimationWillStart();
}
@Override
public void onPictureInPictureExpanded() {
callback.onPictureInPictureExpanded();
}
@Override
public void onPictureInPictureNotVisible() {
callback.onPictureInPictureNotVisible();
} }
@Override @Override
public void onAnimationHasFinished() { public void onAnimationHasFinished() {
state = State.IS_SHRUNKEN; state = State.IS_SHRUNKEN;
callback.onAnimationHasFinished();
} }
}); });
} }
private void performExpandAnimation(@NonNull View target, @NonNull Callback callback) { private void resizeSelfPip(@NonNull Point dimension, @NonNull Callback callback) {
ViewGroup parent = (ViewGroup) target.getParent(); ResizeAnimation resizeAnimation = new ResizeAnimation(selfPip, dimension);
resizeAnimation.setDuration(PIP_RESIZE_DURATION_MS);
resizeAnimation.setAnimationListener(new SimpleAnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
callback.onAnimationWillStart();
}
float x = target.getX(); @Override
float y = target.getY(); public void onAnimationEnd(Animation animation) {
float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth(); callback.onAnimationHasFinished();
float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight(); }
float scale = Math.max(scaleX, scaleY); });
callback.onAnimationWillStart(); selfPip.clearAnimation();
selfPip.startAnimation(resizeAnimation);
target.animate()
.setDuration(200)
.x((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f)
.y((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f)
.scaleX(scale)
.scaleY(scale)
.withEndAction(() -> {
callback.onPictureInPictureExpanded();
target.animate()
.setDuration(100)
.alpha(0f)
.withEndAction(() -> {
callback.onPictureInPictureNotVisible();
target.setX(x);
target.setY(y);
target.setScaleX(0f);
target.setScaleY(0f);
target.setAlpha(1f);
target.animate()
.setDuration(200)
.scaleX(1f)
.scaleY(1f)
.withEndAction(callback::onAnimationHasFinished);
});
});
}
private void performShrinkAnimation(@NonNull View target, @NonNull Callback callback) {
ViewGroup parent = (ViewGroup) target.getParent();
float x = target.getX();
float y = target.getY();
float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth();
float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight();
float scale = Math.max(scaleX, scaleY);
callback.onAnimationWillStart();
target.animate()
.setDuration(200)
.scaleX(0f)
.scaleY(0f)
.withEndAction(() -> {
target.setX((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f);
target.setY((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f);
target.setAlpha(0f);
target.setScaleX(scale);
target.setScaleY(scale);
callback.onPictureInPictureNotVisible();
target.animate()
.setDuration(100)
.alpha(1f)
.withEndAction(() -> {
callback.onPictureInPictureExpanded();
target.animate()
.scaleX(1f)
.scaleY(1f)
.x(x)
.y(y)
.withEndAction(callback::onAnimationHasFinished);
});
});
} }
enum State { enum State {
@ -174,24 +129,12 @@ final class PictureInPictureExpansionHelper {
* Called when an animation (shrink or expand) will begin. This happens before any animation * Called when an animation (shrink or expand) will begin. This happens before any animation
* is executed. * is executed.
*/ */
void onAnimationWillStart(); default void onAnimationWillStart() {}
/**
* Called when the PiP is covering the whole screen. This is when any staging / teardown of the
* large local renderer should occur.
*/
void onPictureInPictureExpanded();
/**
* Called when the PiP is not visible on the screen anymore. This is when any staging / teardown
* of the pip should occur.
*/
void onPictureInPictureNotVisible();
/** /**
* Called when the animation is complete. Useful for e.g. adjusting the pip's final location to * 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. * make sure it is respecting the screen space available.
*/ */
void onAnimationHasFinished(); default void onAnimationHasFinished() {}
} }
} }

View file

@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.components.webrtc; package org.thoughtcrime.securesms.components.webrtc;
import android.animation.Animator;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.graphics.Point; import android.graphics.Point;
import android.view.GestureDetector; import android.view.GestureDetector;
import android.view.Gravity;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.VelocityTracker; import android.view.VelocityTracker;
import android.view.View; import android.view.View;
@ -11,19 +11,16 @@ import android.view.ViewConfiguration;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Interpolator; import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.view.GestureDetectorCompat; import androidx.core.view.GestureDetectorCompat;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout; import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
import java.util.Arrays; import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Queue;
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener { public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
@ -31,18 +28,15 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator(); private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator();
private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator(); private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator();
private final ViewGroup parent; private final ViewGroup parent;
private final View child; private final View child;
private final int framePadding; private final int framePadding;
private final Queue<Runnable> runAfterFling;
private int pipWidth; private int pipWidth;
private int pipHeight; private int pipHeight;
private int activePointerId = MotionEvent.INVALID_POINTER_ID; private int activePointerId = MotionEvent.INVALID_POINTER_ID;
private float lastTouchX; private float lastTouchX;
private float lastTouchY; private float lastTouchY;
private boolean isDragging;
private boolean isAnimating;
private int extraPaddingTop; private int extraPaddingTop;
private int extraPaddingBottom; private int extraPaddingBottom;
private double projectionX; private double projectionX;
@ -51,6 +45,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
private int maximumFlingVelocity; private int maximumFlingVelocity;
private boolean isLockedToBottomEnd; private boolean isLockedToBottomEnd;
private Interpolator interpolator; private Interpolator interpolator;
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
public static PictureInPictureGestureHelper applyTo(@NonNull View child) { public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
@ -111,7 +106,6 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width); 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.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height);
this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity(); this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity();
this.runAfterFling = new LinkedList<>();
this.interpolator = ADJUST_INTERPOLATOR; this.interpolator = ADJUST_INTERPOLATOR;
} }
@ -122,10 +116,18 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
public void setTopVerticalBoundary(int topBoundary) { public void setTopVerticalBoundary(int topBoundary) {
extraPaddingTop = topBoundary - parent.getTop(); 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 setBottomVerticalBoundary(int bottomBoundary) { public void setBottomVerticalBoundary(int bottomBoundary) {
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary; extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
layoutParams.setMargins(layoutParams.leftMargin, layoutParams.topMargin, layoutParams.rightMargin, extraPaddingBottom + framePadding);
child.setLayoutParams(layoutParams);
} }
private boolean onGestureFinished(MotionEvent e) { private boolean onGestureFinished(MotionEvent e) {
@ -139,43 +141,20 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
return false; return false;
} }
public void adjustPip() {
pipWidth = child.getMeasuredWidth();
pipHeight = child.getMeasuredHeight();
if (isAnimating) {
interpolator = ADJUST_INTERPOLATOR;
fling();
} else if (!isDragging) {
interpolator = ADJUST_INTERPOLATOR;
onFling(null, null, 0, 0);
}
}
public void lockToBottomEnd() { public void lockToBottomEnd() {
isLockedToBottomEnd = true; isLockedToBottomEnd = true;
fling();
} }
public void enableCorners() { public void enableCorners() {
isLockedToBottomEnd = false; isLockedToBottomEnd = false;
} }
public void performAfterFling(@NonNull Runnable runnable) {
if (isAnimating) {
runAfterFling.add(runnable);
} else {
runnable.run();
}
}
@Override @Override
public boolean onDown(MotionEvent e) { public boolean onDown(MotionEvent e) {
activePointerId = e.getPointerId(0); activePointerId = e.getPointerId(0);
lastTouchX = e.getX(0) + child.getX(); lastTouchX = e.getX(0) + child.getX();
lastTouchY = e.getY(0) + child.getY(); lastTouchY = e.getY(0) + child.getY();
isDragging = true;
pipWidth = child.getMeasuredWidth(); pipWidth = child.getMeasuredWidth();
pipHeight = child.getMeasuredHeight(); pipHeight = child.getMeasuredHeight();
interpolator = FLING_INTERPOLATOR; interpolator = FLING_INTERPOLATOR;
@ -185,6 +164,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
@Override @Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (isLockedToBottomEnd) {
return false;
}
int pointerIndex = e2.findPointerIndex(activePointerId); int pointerIndex = e2.findPointerIndex(activePointerId);
if (pointerIndex == -1) { if (pointerIndex == -1) {
@ -192,10 +175,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
return false; return false;
} }
float x = e2.getX(pointerIndex) + child.getX(); float x = e2.getX(pointerIndex) + child.getX();
float y = e2.getY(pointerIndex) + child.getY(); float y = e2.getY(pointerIndex) + child.getY();
float dx = x - lastTouchX; float dx = x - lastTouchX;
float dy = y - lastTouchY; float dy = y - lastTouchY;
child.setTranslationX(child.getTranslationX() + dx); child.setTranslationX(child.getTranslationX() + dx);
child.setTranslationY(child.getTranslationY() + dy); child.setTranslationY(child.getTranslationY() + dy);
@ -208,6 +191,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
@Override @Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isLockedToBottomEnd) {
return false;
}
if (velocityTracker != null) { if (velocityTracker != null) {
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
@ -225,92 +212,75 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
@Override @Override
public boolean onSingleTapUp(MotionEvent e) { public boolean onSingleTapUp(MotionEvent e) {
isDragging = false;
child.performClick(); child.performClick();
return true; return true;
} }
private void fling() { private void fling() {
Point projection = new Point((int) projectionX, (int) projectionY); Point projection = new Point((int) projectionX, (int) projectionY);
Point nearestCornerPosition = findNearestCornerPosition(projection); Corner nearestCornerPosition = findNearestCornerPosition(projection);
isAnimating = true; FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
isDragging = false; layoutParams.gravity = nearestCornerPosition.gravity;
if (currentCornerPosition != null && currentCornerPosition != nearestCornerPosition) {
adjustTranslationFrameOfReference(child, currentCornerPosition, nearestCornerPosition);
}
currentCornerPosition = nearestCornerPosition;
child.setLayoutParams(layoutParams);
child.animate() child.animate()
.translationX(getTranslationXForPoint(nearestCornerPosition)) .translationX(0)
.translationY(getTranslationYForPoint(nearestCornerPosition)) .translationY(0)
.setDuration(250) .setDuration(250)
.setInterpolator(interpolator) .setInterpolator(interpolator)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
isAnimating = false;
Iterator<Runnable> afterFlingRunnables = runAfterFling.iterator();
while (afterFlingRunnables.hasNext()) {
Runnable runnable = afterFlingRunnables.next();
runnable.run();
afterFlingRunnables.remove();
}
}
})
.start(); .start();
} }
private Point findNearestCornerPosition(Point projection) { private Corner findNearestCornerPosition(Point projection) {
if (isLockedToBottomEnd) { if (isLockedToBottomEnd) {
return ViewUtil.isLtr(parent) ? calculateBottomRightCoordinates(parent) return ViewUtil.isLtr(parent) ? Corner.BOTTOM_RIGHT
: calculateBottomLeftCoordinates(parent); : Corner.BOTTOM_LEFT;
} }
Point maxPoint = null; CornerPoint maxPoint = null;
double maxDistance = Double.MAX_VALUE; double maxDistance = Double.MAX_VALUE;
for (Point point : Arrays.asList(calculateTopLeftCoordinates(), for (CornerPoint cornerPoint : Arrays.asList(calculateTopLeftCoordinates(),
calculateTopRightCoordinates(parent), calculateTopRightCoordinates(parent),
calculateBottomLeftCoordinates(parent), calculateBottomLeftCoordinates(parent),
calculateBottomRightCoordinates(parent))) calculateBottomRightCoordinates(parent))) {
{ double distance = distance(cornerPoint.point, projection);
double distance = distance(point, projection);
if (distance < maxDistance) { if (distance < maxDistance) {
maxDistance = distance; maxDistance = distance;
maxPoint = point; maxPoint = cornerPoint;
} }
} }
return maxPoint; //noinspection DataFlowIssue
return maxPoint.corner;
} }
private float getTranslationXForPoint(Point destination) { private CornerPoint calculateTopLeftCoordinates() {
return destination.x - child.getLeft(); return new CornerPoint(new Point(framePadding, framePadding + extraPaddingTop),
Corner.TOP_LEFT);
} }
private float getTranslationYForPoint(Point destination) { private CornerPoint calculateTopRightCoordinates(@NonNull ViewGroup parent) {
return destination.y - child.getTop(); return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, framePadding + extraPaddingTop),
Corner.TOP_RIGHT);
} }
private Point calculateTopLeftCoordinates() { private CornerPoint calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
return new Point(framePadding, return new CornerPoint(new Point(framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
framePadding + extraPaddingTop); Corner.BOTTOM_LEFT);
} }
private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) { private CornerPoint calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
framePadding + extraPaddingTop); Corner.BOTTOM_RIGHT);
}
private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
return new Point(framePadding,
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
}
private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
} }
private static float project(float initialVelocity) { private static float project(float initialVelocity) {
@ -321,9 +291,80 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
} }
/** Borrowed from ScrollView */
/**
* 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;
}
}
private enum Corner {
TOP_LEFT(Gravity.TOP | Gravity.START, true, true),
TOP_RIGHT(Gravity.TOP | Gravity.END, false, true),
BOTTOM_LEFT(Gravity.BOTTOM | Gravity.START, true, false),
BOTTOM_RIGHT(Gravity.BOTTOM | Gravity.END, 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 { private static class ViscousFluidInterpolator implements Interpolator {
/** Controls the viscous fluid effect (how much of it). */ /**
* Controls the viscous fluid effect (how much of it).
*/
private static final float VISCOUS_FLUID_SCALE = 8.0f; private static final float VISCOUS_FLUID_SCALE = 8.0f;
private static final float VISCOUS_FLUID_NORMALIZE; private static final float VISCOUS_FLUID_NORMALIZE;
@ -340,10 +381,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
private static float viscousFluid(float x) { private static float viscousFluid(float x) {
x *= VISCOUS_FLUID_SCALE; x *= VISCOUS_FLUID_SCALE;
if (x < 1.0f) { if (x < 1.0f) {
x -= (1.0f - (float)Math.exp(-x)); x -= (1.0f - (float) Math.exp(-x));
} else { } else {
float start = 0.36787944117f; // 1/e == exp(-1) float start = 0.36787944117f; // 1/e == exp(-1)
x = 1.0f - (float)Math.exp(1.0f - x); x = 1.0f - (float) Math.exp(1.0f - x);
x = start + x * (1.0f - start); x = start + x * (1.0f - start);
} }
return x; return x;

View file

@ -1,16 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc; package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context; import android.content.Context;
import android.graphics.ColorMatrix; import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter; import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point; import android.graphics.Point;
import android.graphics.Rect;
import android.os.Build; import android.os.Build;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.animation.Animation;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
@ -25,14 +28,9 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet; import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline; import androidx.constraintlayout.widget.Guideline;
import androidx.core.util.Consumer; import androidx.core.util.Consumer;
import androidx.core.view.ViewKt;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.AutoTransition;
import androidx.transition.Transition;
import androidx.transition.TransitionManager;
import androidx.transition.TransitionSet;
import androidx.viewpager2.widget.MarginPageTransformer; import androidx.viewpager2.widget.MarginPageTransformer;
import androidx.viewpager2.widget.ViewPager2; import androidx.viewpager2.widget.ViewPager2;
@ -46,19 +44,19 @@ import org.signal.core.util.SetUtil;
import org.signal.core.util.ThreadUtil; import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
import org.thoughtcrime.securesms.components.AccessibleToggleButton; import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener;
import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection; import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
import org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout;
import org.thoughtcrime.securesms.util.BlurTransformation; import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.ThrottledDebouncer; import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
@ -72,7 +70,7 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
public class WebRtcCallView extends ConstraintLayout { public class WebRtcCallView extends InsetAwareConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class); private static final String TAG = Log.tag(WebRtcCallView.class);
@ -80,10 +78,6 @@ public class WebRtcCallView extends ConstraintLayout {
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8; private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16; private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
public static final int FADE_OUT_DELAY = 5000;
public static final int PIP_RESIZE_DURATION = 300;
public static final int CONTROLS_HEIGHT = 98;
private WebRtcAudioOutputToggleButton audioToggle; private WebRtcAudioOutputToggleButton audioToggle;
private AccessibleToggleButton videoToggle; private AccessibleToggleButton videoToggle;
private AccessibleToggleButton micToggle; private AccessibleToggleButton micToggle;
@ -96,7 +90,6 @@ public class WebRtcCallView extends ConstraintLayout {
private TextView recipientName; private TextView recipientName;
private TextView status; private TextView status;
private TextView incomingRingStatus; private TextView incomingRingStatus;
private ConstraintLayout parent;
private ConstraintLayout participantsParent; private ConstraintLayout participantsParent;
private ControlsListener controlsListener; private ControlsListener controlsListener;
private RecipientId recipientId; private RecipientId recipientId;
@ -117,23 +110,25 @@ public class WebRtcCallView extends ConstraintLayout {
private Stub<FrameLayout> groupCallSpeakerHint; private Stub<FrameLayout> groupCallSpeakerHint;
private Stub<View> groupCallFullStub; private Stub<View> groupCallFullStub;
private View errorButton; private View errorButton;
private int pagerBottomMarginDp;
private boolean controlsVisible = true; private boolean controlsVisible = true;
private Guideline showParticipantsGuideline; private Guideline showParticipantsGuideline;
private Guideline topFoldGuideline; private Guideline topFoldGuideline;
private Guideline callScreenTopFoldGuideline; private Guideline callScreenTopFoldGuideline;
private AvatarImageView largeHeaderAvatar; private AvatarImageView largeHeaderAvatar;
private Guideline statusBarGuideline;
private Guideline navigationBarGuideline;
private int navBarBottomInset; private int navBarBottomInset;
private View fullScreenShade; private View fullScreenShade;
private Toolbar collapsedToolbar; private Toolbar collapsedToolbar;
private Toolbar headerToolbar; private Toolbar headerToolbar;
private Stub<PendingParticipantsView> pendingParticipantsViewStub; private Stub<PendingParticipantsView> pendingParticipantsViewStub;
private Stub<View> callLinkWarningCard; private Stub<View> callLinkWarningCard;
private RecyclerView groupReactionsFeed;
private MultiReactionBurstLayout reactionViews;
private Guideline aboveControlsGuideline;
private WebRtcCallParticipantsPagerAdapter pagerAdapter; private WebRtcCallParticipantsPagerAdapter pagerAdapter;
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
private WebRtcReactionsRecyclerAdapter reactionsAdapter;
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper; private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
private PendingParticipantsView.Listener pendingParticipantsViewListener; private PendingParticipantsView.Listener pendingParticipantsViewListener;
@ -147,12 +142,10 @@ public class WebRtcCallView extends ConstraintLayout {
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS); private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
private WebRtcControls controls = WebRtcControls.NONE; private WebRtcControls controls = WebRtcControls.NONE;
private final Runnable fadeOutRunnable = () -> {
if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls();
};
private CallParticipantsViewState lastState; private CallParticipantsViewState lastState;
private ContactPhoto previousLocalAvatar; private ContactPhoto previousLocalAvatar;
private LayoutPositions previousLayoutPositions = null;
public WebRtcCallView(@NonNull Context context) { public WebRtcCallView(@NonNull Context context) {
this(context, null); this(context, null);
@ -181,7 +174,6 @@ public class WebRtcCallView extends ConstraintLayout {
recipientName = findViewById(R.id.call_screen_recipient_name); recipientName = findViewById(R.id.call_screen_recipient_name);
status = findViewById(R.id.call_screen_status); status = findViewById(R.id.call_screen_status);
incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status); incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status);
parent = findViewById(R.id.call_screen);
participantsParent = findViewById(R.id.call_screen_participants_parent); participantsParent = findViewById(R.id.call_screen_participants_parent);
answer = findViewById(R.id.call_screen_answer_call); answer = findViewById(R.id.call_screen_answer_call);
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label); answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
@ -203,30 +195,36 @@ public class WebRtcCallView extends ConstraintLayout {
topFoldGuideline = findViewById(R.id.fold_top_guideline); topFoldGuideline = findViewById(R.id.fold_top_guideline);
callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline); callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline);
largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar); largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar);
statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline);
navigationBarGuideline = findViewById(R.id.call_screen_navigation_bar_guideline);
fullScreenShade = findViewById(R.id.call_screen_full_shade); fullScreenShade = findViewById(R.id.call_screen_full_shade);
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text); collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text); headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients)); pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients));
callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning)); 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);
aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline);
View decline = findViewById(R.id.call_screen_decline_call); View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label); View answerLabel = findViewById(R.id.call_screen_answer_call_label);
View declineLabel = findViewById(R.id.call_screen_decline_call_label); View declineLabel = findViewById(R.id.call_screen_decline_call_label);
callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4))); callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4)));
pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls); pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls);
recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter(); recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter();
reactionsAdapter = new WebRtcReactionsRecyclerAdapter();
callParticipantsPager.setAdapter(pagerAdapter); callParticipantsPager.setAdapter(pagerAdapter);
callParticipantsRecycler.setAdapter(recyclerAdapter); callParticipantsRecycler.setAdapter(recyclerAdapter);
groupReactionsFeed.setAdapter(reactionsAdapter);
DefaultItemAnimator animator = new DefaultItemAnimator(); DefaultItemAnimator animator = new DefaultItemAnimator();
animator.setSupportsChangeAnimations(false); animator.setSupportsChangeAnimations(false);
callParticipantsRecycler.setItemAnimator(animator); callParticipantsRecycler.setItemAnimator(animator);
groupReactionsFeed.addItemDecoration(new WebRtcReactionsAlphaItemDecoration());
groupReactionsFeed.setItemAnimator(new WebRtcReactionsItemAnimator());
callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override @Override
public void onPageSelected(int position) { public void onPageSelected(int position) {
@ -287,7 +285,7 @@ public class WebRtcCallView extends ConstraintLayout {
answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame);
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(); pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(smallLocalRenderFrame);
smallLocalRenderFrame.setOnClickListener(v -> { smallLocalRenderFrame.setOnClickListener(v -> {
if (controlsListener != null) { if (controlsListener != null) {
@ -360,25 +358,6 @@ public class WebRtcCallView extends ConstraintLayout {
rotatableControls.add(ringToggle); rotatableControls.add(ringToggle);
} }
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (controls.isFadeOutEnabled()) {
scheduleFadeOut();
}
}
@Override
protected boolean fitSystemWindows(Rect insets) {
if (insets.top != 0) {
statusBarGuideline.setGuidelineBegin(insets.top);
}
navigationBarGuideline.setGuidelineEnd(insets.bottom);
return true;
}
@Override @Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) { public WindowInsets onApplyWindowInsets(WindowInsets insets) {
navBarBottomInset = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; navBarBottomInset = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
@ -398,19 +377,11 @@ public class WebRtcCallView extends ConstraintLayout {
pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getTop()); pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getTop());
} else { } else {
pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getBottom()); pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getBottom());
pictureInPictureGestureHelper.setBottomVerticalBoundary(videoToggle.getTop()); pictureInPictureGestureHelper.setBottomVerticalBoundary(findViewById(R.id.call_controls_info_parent).getTop());
} }
} else { } else {
pictureInPictureGestureHelper.clearVerticalBoundaries(); pictureInPictureGestureHelper.clearVerticalBoundaries();
} }
pictureInPictureGestureHelper.adjustPip();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
cancelFadeOut();
} }
public void rotateControls(int degrees) { public void rotateControls(int degrees) {
@ -489,21 +460,23 @@ public class WebRtcCallView extends ConstraintLayout {
pagerAdapter.submitList(pages); pagerAdapter.submitList(pages);
recyclerAdapter.submitList(state.getListParticipants()); recyclerAdapter.submitList(state.getListParticipants());
reactionsAdapter.submitList(state.getReactions());
reactionViews.displayReactions(state.getReactions());
boolean displaySmallSelfPipInLandscape = !isPortrait && isLandscapeEnabled; boolean displaySmallSelfPipInLandscape = !isPortrait && isLandscapeEnabled;
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant(), displaySmallSelfPipInLandscape); updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape);
if (state.isLargeVideoGroup() && !state.isInPipMode() && !state.isFolded()) { if (state.isLargeVideoGroup() && !state.isInPipMode() && !state.isFolded()) {
layoutParticipantsForLargeCount(); adjustLayoutForLargeCount();
} else { } else {
layoutParticipantsForSmallCount(); adjustLayoutForSmallCount();
} }
} }
public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state,
@NonNull CallParticipant localCallParticipant, @NonNull CallParticipant localCallParticipant,
@NonNull CallParticipant focusedParticipant,
boolean displaySmallSelfPipInLandscape) boolean displaySmallSelfPipInLandscape)
{ {
largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
@ -520,17 +493,19 @@ public class WebRtcCallView extends ConstraintLayout {
smallLocalRender.setRenderInPip(true); smallLocalRender.setRenderInPip(true);
if (state == WebRtcLocalRenderState.EXPANDED) { if (state == WebRtcLocalRenderState.EXPANDED) {
expandPip(localCallParticipant, focusedParticipant); pictureInPictureExpansionHelper.expand();
smallLocalRender.setCallParticipant(focusedParticipant);
return; return;
} else if ((state == WebRtcLocalRenderState.SMALL_RECTANGLE || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) { } else if ((state.isAnySmall() || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) {
shrinkPip(localCallParticipant); pictureInPictureExpansionHelper.shrink();
return;
} else { if (state != WebRtcLocalRenderState.GONE) {
smallLocalRender.setCallParticipant(localCallParticipant); return;
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); }
} }
smallLocalRender.setCallParticipant(localCallParticipant);
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
switch (state) { switch (state) {
case GONE: case GONE:
largeLocalRender.attachBroadcastVideoSink(null); largeLocalRender.attachBroadcastVideoSink(null);
@ -674,7 +649,7 @@ public class WebRtcCallView extends ConstraintLayout {
topFoldGuideline.setGuidelineEnd(webRtcControls.getFold()); topFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold()); callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
} else { } else {
showParticipantsGuideline.setGuidelineBegin(((LayoutParams) statusBarGuideline.getLayoutParams()).guideBegin); showParticipantsGuideline.setGuidelineBegin(((LayoutParams) getStatusBarGuideline().getLayoutParams()).guideBegin);
showParticipantsGuideline.setGuidelineEnd(-1); showParticipantsGuideline.setGuidelineEnd(-1);
topFoldGuideline.setGuidelineEnd(0); topFoldGuideline.setGuidelineEnd(0);
callScreenTopFoldGuideline.setGuidelineEnd(0); callScreenTopFoldGuideline.setGuidelineEnd(0);
@ -774,21 +749,9 @@ public class WebRtcCallView extends ConstraintLayout {
visibleViewSet.add(ringToggle); visibleViewSet.add(ringToggle);
} }
if (webRtcControls.displayReactions()) {
if (webRtcControls.isFadeOutEnabled()) { visibleViewSet.add(reactionViews);
if (!controls.isFadeOutEnabled()) { visibleViewSet.add(groupReactionsFeed);
scheduleFadeOut();
}
} else {
cancelFadeOut();
if (controlsListener != null) {
controlsListener.showSystemUI();
}
}
if (webRtcControls.adjustForFold() && webRtcControls.isFadeOutEnabled() && !controls.adjustForFold()) {
scheduleFadeOut();
} }
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold(); boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
@ -806,11 +769,6 @@ public class WebRtcCallView extends ConstraintLayout {
(!webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.GONE) || (!webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.GONE) ||
forceUpdate) forceUpdate)
{ {
if (controlsListener != null) {
controlsListener.showSystemUI();
}
throttledDebouncer.publish(() -> fadeInNewUiState(webRtcControls.displaySmallOngoingCallButtons(), webRtcControls.showSmallHeader())); throttledDebouncer.publish(() -> fadeInNewUiState(webRtcControls.displaySmallOngoingCallButtons(), webRtcControls.showSmallHeader()));
} }
@ -831,62 +789,6 @@ public class WebRtcCallView extends ConstraintLayout {
} }
} }
private void expandPip(@NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) {
pictureInPictureExpansionHelper.expand(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() {
@Override
public void onAnimationWillStart() {
largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink());
}
@Override
public void onPictureInPictureExpanded() {
largeLocalRenderFrame.setVisibility(View.VISIBLE);
largeLocalRenderNoVideo.setVisibility(View.GONE);
largeLocalRenderNoVideoAvatar.setVisibility(View.GONE);
}
@Override
public void onPictureInPictureNotVisible() {
smallLocalRender.setCallParticipant(focusedParticipant);
smallLocalRender.setMirror(false);
}
@Override
public void onAnimationHasFinished() {
pictureInPictureGestureHelper.adjustPip();
}
});
}
private void shrinkPip(@NonNull CallParticipant localCallParticipant) {
pictureInPictureExpansionHelper.shrink(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() {
@Override
public void onAnimationWillStart() {
}
@Override
public void onPictureInPictureExpanded() {
largeLocalRenderFrame.setVisibility(View.GONE);
largeLocalRender.attachBroadcastVideoSink(null);
}
@Override
public void onPictureInPictureNotVisible() {
smallLocalRender.setCallParticipant(localCallParticipant);
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
if (!localCallParticipant.isVideoEnabled()) {
smallLocalRenderFrame.setVisibility(View.GONE);
}
}
@Override
public void onAnimationHasFinished() {
pictureInPictureGestureHelper.adjustPip();
}
});
}
private void animatePipToLargeRectangle(boolean isLandscape) { private void animatePipToLargeRectangle(boolean isLandscape) {
final Point dimens; final Point dimens;
if (isLandscape) { if (isLandscape) {
@ -895,167 +797,76 @@ public class WebRtcCallView extends ConstraintLayout {
dimens = new Point(ViewUtil.dpToPx(90), ViewUtil.dpToPx(160)); dimens = new Point(ViewUtil.dpToPx(90), ViewUtil.dpToPx(160));
} }
SimpleAnimationListener animationListener = new SimpleAnimationListener() { pictureInPictureExpansionHelper.setDefaultSize(dimens, new PictureInPictureExpansionHelper.Callback() {
@Override @Override
public void onAnimationEnd(Animation animation) { public void onAnimationHasFinished() {
pictureInPictureGestureHelper.enableCorners(); pictureInPictureGestureHelper.enableCorners();
pictureInPictureGestureHelper.adjustPip();
} }
}; });
ViewGroup.LayoutParams layoutParams = smallLocalRenderFrame.getLayoutParams();
if (layoutParams.width == dimens.x && layoutParams.height == dimens.y) {
animationListener.onAnimationEnd(null);
return;
}
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, dimens.x, dimens.y);
animation.setDuration(PIP_RESIZE_DURATION);
animation.setAnimationListener(animationListener);
smallLocalRenderFrame.startAnimation(animation);
} }
private void animatePipToSmallRectangle() { private void animatePipToSmallRectangle() {
pictureInPictureGestureHelper.lockToBottomEnd(); pictureInPictureExpansionHelper.setDefaultSize(new Point(ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)), new PictureInPictureExpansionHelper.Callback() {
@Override
pictureInPictureGestureHelper.performAfterFling(() -> { public void onAnimationHasFinished() {
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)); pictureInPictureGestureHelper.lockToBottomEnd();
animation.setDuration(PIP_RESIZE_DURATION); }
animation.setAnimationListener(new SimpleAnimationListener() {
@Override
public void onAnimationEnd(Animation animation) {
pictureInPictureGestureHelper.adjustPip();
}
});
smallLocalRenderFrame.startAnimation(animation);
}); });
} }
private void toggleControls() { private void toggleControls() {
if (controls.isFadeOutEnabled() && largeHeader.getVisibility() == VISIBLE) { controlsListener.toggleControls();
fadeOutControls();
} else {
fadeInControls();
}
} }
private void fadeOutControls() { private void adjustLayoutForSmallCount() {
fadeControls(ConstraintSet.GONE); adjustLayoutPositions(LayoutPositions.SMALL_GROUP);
controlsListener.onControlsFadeOut();
} }
private void fadeInControls() { private void adjustLayoutForLargeCount() {
fadeControls(ConstraintSet.VISIBLE); adjustLayoutPositions(LayoutPositions.LARGE_GROUP);
scheduleFadeOut();
} }
private void layoutParticipantsForSmallCount() { private void adjustLayoutPositions(@NonNull LayoutPositions layoutPositions) {
pagerBottomMarginDp = 0; if (previousLayoutPositions == layoutPositions) {
layoutParticipants();
}
private void layoutParticipantsForLargeCount() {
pagerBottomMarginDp = 104;
layoutParticipants();
}
private int withControlsHeight(int margin) {
if (margin == 0) {
return 0;
}
return (controlsVisible || controls.adjustForFold()) ? margin + CONTROLS_HEIGHT : margin;
}
private void layoutParticipants() {
int desiredMargin = ViewUtil.dpToPx(withControlsHeight(pagerBottomMarginDp));
if (ViewKt.getMarginBottom(callParticipantsPager) == desiredMargin) {
return; return;
} }
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS); previousLayoutPositions = layoutPositions;
TransitionManager.beginDelayedTransition(participantsParent, transition);
ConstraintSet constraintSet = new ConstraintSet(); ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(participantsParent); constraintSet.clone(this);
constraintSet.setMargin(R.id.call_screen_participants_pager, ConstraintSet.BOTTOM, desiredMargin); constraintSet.connect(R.id.call_screen_participants_parent,
constraintSet.applyTo(participantsParent); ConstraintSet.BOTTOM,
} layoutPositions.participantBottomViewId,
layoutPositions.participantBottomViewEndSide,
ViewUtil.dpToPx(layoutPositions.participantBottomMargin));
private void fadeControls(int visibility) { constraintSet.connect(R.id.call_screen_reactions_feed,
controlsVisible = visibility == VISIBLE; ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
Transition transition = new AutoTransition().setOrdering(TransitionSet.ORDERING_TOGETHER) constraintSet.applyTo(this);
.setDuration(TRANSITION_DURATION_MILLIS);
TransitionManager.endTransitions(parent);
if (controlsListener != null) {
if (controlsVisible) {
controlsListener.showSystemUI();
} else {
controlsListener.hideSystemUI();
}
}
TransitionManager.beginDelayedTransition(parent, transition);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : controlsToFade()) {
constraintSet.setVisibility(view.getId(), visibility);
}
adjustParticipantsRecycler(constraintSet);
constraintSet.applyTo(parent);
layoutParticipants();
}
private Set<View> controlsToFade() {
if (controls.adjustForFold()) {
return Sets.intersection(topViews, visibleViewSet);
} else {
return visibleViewSet;
}
} }
private void fadeInNewUiState(boolean useSmallMargins, boolean showSmallHeader) { private void fadeInNewUiState(boolean useSmallMargins, boolean showSmallHeader) {
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
TransitionManager.beginDelayedTransition(parent, transition);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : SetUtil.difference(allTimeVisibleViews, visibleViewSet)) { for (View view : SetUtil.difference(allTimeVisibleViews, visibleViewSet)) {
constraintSet.setVisibility(view.getId(), ConstraintSet.GONE); view.setVisibility(GONE);
} }
for (View view : visibleViewSet) { for (View view : visibleViewSet) {
constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE); view.setVisibility(VISIBLE);
if (adjustableMarginsSet.contains(view)) { if (adjustableMarginsSet.contains(view)) {
constraintSet.setMargin(view.getId(), MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
ConstraintSet.END, params.setMarginEnd(ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP
ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP : LARGE_ONGOING_CALL_BUTTON_MARGIN_DP));
: LARGE_ONGOING_CALL_BUTTON_MARGIN_DP)); view.setLayoutParams(params);
} }
} }
adjustParticipantsRecycler(constraintSet);
constraintSet.applyTo(parent);
if (showSmallHeader) { if (showSmallHeader) {
collapsedToolbar.setEnabled(true); collapsedToolbar.setEnabled(true);
collapsedToolbar.setAlpha(1); collapsedToolbar.setAlpha(1);
@ -1073,28 +884,6 @@ public class WebRtcCallView extends ConstraintLayout {
} }
} }
private void adjustParticipantsRecycler(@NonNull ConstraintSet constraintSet) {
if (controlsVisible || controls.adjustForFold()) {
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, R.id.call_screen_video_toggle, ConstraintSet.TOP);
} else {
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
}
constraintSet.setHorizontalBias(R.id.call_screen_participants_recycler, controls.adjustForFold() ? 0.5f : 1f);
}
private void scheduleFadeOut() {
cancelFadeOut();
if (getHandler() == null) return;
getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY);
}
private void cancelFadeOut() {
if (getHandler() == null) return;
getHandler().removeCallbacks(fadeOutRunnable);
}
private static <T> void runIfNonNull(@Nullable T listener, @NonNull Consumer<T> listenerConsumer) { private static <T> void runIfNonNull(@Nullable T listener, @NonNull Consumer<T> listenerConsumer) {
if (listener != null) { if (listener != null) {
listenerConsumer.accept(listener); listenerConsumer.accept(listener);
@ -1133,10 +922,15 @@ public class WebRtcCallView extends ConstraintLayout {
ringToggle.setActivated(enabled); ringToggle.setActivated(enabled);
} }
public void onControlTopChanged(int top) {
pictureInPictureGestureHelper.setBottomVerticalBoundary(top);
aboveControlsGuideline.setGuidelineBegin(top);
}
public interface ControlsListener { public interface ControlsListener {
void onStartCall(boolean isVideoCall); void onStartCall(boolean isVideoCall);
void onCancelStartCall(); void onCancelStartCall();
void onControlsFadeOut();
void showSystemUI(); void showSystemUI();
void hideSystemUI(); void hideSystemUI();
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
@ -1154,5 +948,6 @@ public class WebRtcCallView extends ConstraintLayout {
void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed); void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed);
void onCallInfoClicked(); void onCallInfoClicked();
void onNavigateUpClicked(); void onNavigateUpClicked();
void toggleControls();
} }
} }

View file

@ -253,10 +253,6 @@ public class WebRtcCallViewModel extends ViewModel {
public void onLocalPictureInPictureClicked() { public void onLocalPictureInPictureClicked() {
CallParticipantsState state = participantsState.getValue(); CallParticipantsState state = participantsState.getValue();
if (state.getGroupCallState() != WebRtcViewModel.GroupCallState.IDLE) {
return;
}
participantsState.onNext(CallParticipantsState.setExpanded(participantsState.getValue(), participantsState.onNext(CallParticipantsState.setExpanded(participantsState.getValue(),
state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED)); state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED));
} }

View file

@ -100,23 +100,28 @@ public final class WebRtcControls {
isCallLink); isCallLink);
} }
boolean displayErrorControls() { /** This is only true at the very start of a call and will then never be true again */
public boolean hideControlsSheetInitially() {
return displayIncomingCallButtons() || callState == CallState.NONE;
}
public boolean displayErrorControls() {
return isError(); return isError();
} }
boolean displayStartCallControls() { public boolean displayStartCallControls() {
return isPreJoin(); return isPreJoin();
} }
boolean adjustForFold() { public boolean adjustForFold() {
return foldableState.isFolded(); return foldableState.isFolded();
} }
@Px int getFold() { public @Px int getFold() {
return foldableState.getFoldPoint(); return foldableState.getFoldPoint();
} }
@StringRes int getStartCallButtonText() { public @StringRes int getStartCallButtonText() {
if (isGroupCall()) { if (isGroupCall()) {
if (groupCallState == GroupCallState.FULL) { if (groupCallState == GroupCallState.FULL) {
return R.string.WebRtcCallView__call_is_full; return R.string.WebRtcCallView__call_is_full;
@ -127,86 +132,90 @@ public final class WebRtcControls {
return R.string.WebRtcCallView__start_call; return R.string.WebRtcCallView__start_call;
} }
boolean isStartCallEnabled() { public boolean isStartCallEnabled() {
return groupCallState != GroupCallState.FULL; return groupCallState != GroupCallState.FULL;
} }
boolean displayGroupCallFull() { public boolean displayGroupCallFull() {
return groupCallState == GroupCallState.FULL; return groupCallState == GroupCallState.FULL;
} }
@NonNull String getGroupCallFullMessage(@NonNull Context context) { public @NonNull String getGroupCallFullMessage(@NonNull Context context) {
if (participantLimit != null) { if (participantLimit != null) {
return context.getString(R.string.WebRtcCallView__the_maximum_number_of_d_participants_has_been_Reached_for_this_call, participantLimit); return context.getString(R.string.WebRtcCallView__the_maximum_number_of_d_participants_has_been_Reached_for_this_call, participantLimit);
} }
return ""; return "";
} }
boolean displayGroupMembersButton() { public boolean displayGroupMembersButton() {
return (groupCallState.isAtLeast(GroupCallState.CONNECTING) && hasAtLeastOneRemote) || groupCallState.isAtLeast(GroupCallState.FULL); return (groupCallState.isAtLeast(GroupCallState.CONNECTING) && hasAtLeastOneRemote) || groupCallState.isAtLeast(GroupCallState.FULL);
} }
boolean displayEndCall() { public boolean displayEndCall() {
return isAtLeastOutgoing() || callState == CallState.RECONNECTING; return isAtLeastOutgoing() || callState == CallState.RECONNECTING;
} }
boolean displayMuteAudio() { public boolean displayMuteAudio() {
return isPreJoin() || isAtLeastOutgoing(); return isPreJoin() || isAtLeastOutgoing();
} }
boolean displayVideoToggle() { public boolean displayVideoToggle() {
return isPreJoin() || isAtLeastOutgoing(); return isPreJoin() || isAtLeastOutgoing();
} }
boolean displayAudioToggle() { public boolean displayAudioToggle() {
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothHeadsetAvailableForAudioToggle() || isWiredHeadsetAvailableForAudioToggle()); return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothHeadsetAvailableForAudioToggle() || isWiredHeadsetAvailableForAudioToggle());
} }
boolean displayCameraToggle() { public boolean displayCameraToggle() {
return (isPreJoin() || isAtLeastOutgoing()) && isLocalVideoEnabled && isMoreThanOneCameraAvailable; return (isPreJoin() || isAtLeastOutgoing()) && isLocalVideoEnabled && isMoreThanOneCameraAvailable;
} }
boolean displayRemoteVideoRecycler() { public boolean displayRemoteVideoRecycler() {
return isOngoing(); return isOngoing();
} }
boolean displayAnswerWithoutVideo() { public boolean displayAnswerWithoutVideo() {
return isIncoming() && isRemoteVideoEnabled; return isIncoming() && isRemoteVideoEnabled;
} }
boolean displayIncomingCallButtons() { public boolean displayIncomingCallButtons() {
return isIncoming(); return isIncoming();
} }
boolean isEarpieceAvailableForAudioToggle() { public boolean isEarpieceAvailableForAudioToggle() {
return !isLocalVideoEnabled; return !isLocalVideoEnabled;
} }
boolean isBluetoothHeadsetAvailableForAudioToggle() { public boolean isBluetoothHeadsetAvailableForAudioToggle() {
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH); return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
} }
boolean isWiredHeadsetAvailableForAudioToggle() { public boolean isWiredHeadsetAvailableForAudioToggle() {
return availableDevices.contains(SignalAudioManager.AudioDevice.WIRED_HEADSET); return availableDevices.contains(SignalAudioManager.AudioDevice.WIRED_HEADSET);
} }
boolean isFadeOutEnabled() { public boolean isFadeOutEnabled() {
return isAtLeastOutgoing() && isRemoteVideoEnabled && callState != CallState.RECONNECTING; return isAtLeastOutgoing() && isRemoteVideoEnabled && callState != CallState.RECONNECTING;
} }
boolean displaySmallOngoingCallButtons() { public boolean displaySmallOngoingCallButtons() {
return isAtLeastOutgoing() && displayAudioToggle() && displayCameraToggle(); return isAtLeastOutgoing() && displayAudioToggle() && displayCameraToggle();
} }
boolean displayLargeOngoingCallButtons() { public boolean displayLargeOngoingCallButtons() {
return isAtLeastOutgoing() && !(displayAudioToggle() && displayCameraToggle()); return isAtLeastOutgoing() && !(displayAudioToggle() && displayCameraToggle());
} }
boolean displayTopViews() { public boolean displayTopViews() {
return !isInPipMode; return !isInPipMode;
} }
@NonNull WebRtcAudioOutput getAudioOutput() { public boolean displayReactions() {
return !isInPipMode;
}
public @NonNull WebRtcAudioOutput getAudioOutput() {
switch (activeDevice) { switch (activeDevice) {
case SPEAKER_PHONE: case SPEAKER_PHONE:
return WebRtcAudioOutput.SPEAKER; return WebRtcAudioOutput.SPEAKER;
@ -219,15 +228,15 @@ public final class WebRtcControls {
} }
} }
boolean showSmallHeader() { public boolean showSmallHeader() {
return isAtLeastOutgoing(); return isAtLeastOutgoing();
} }
boolean showFullScreenShade() { public boolean showFullScreenShade() {
return isPreJoin() || isIncoming(); return isPreJoin() || isIncoming();
} }
boolean displayRingToggle() { public boolean displayRingToggle() {
return isPreJoin() && isGroupCall() && !isCallLink && !hasAtLeastOneRemote; return isPreJoin() && isGroupCall() && !isCallLink && !hasAtLeastOneRemote;
} }

View file

@ -6,5 +6,9 @@ public enum WebRtcLocalRenderState {
SMALLER_RECTANGLE, SMALLER_RECTANGLE,
LARGE, LARGE,
LARGE_NO_VIDEO, LARGE_NO_VIDEO,
EXPANDED EXPANDED;
public boolean isAnySmall() {
return this == SMALL_RECTANGLE || this == SMALLER_RECTANGLE;
}
} }

View file

@ -0,0 +1,26 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.graphics.Canvas
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
/**
* This fades the top 2 reactions slightly inside their recyclerview.
*/
class WebRtcReactionsAlphaItemDecoration : ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
for (i in 0..parent.childCount) {
val child = parent.getChildAt(i) ?: continue
when (parent.getChildAdapterPosition(child)) {
WebRtcReactionsRecyclerAdapter.MAX_REACTION_NUMBER - 1 -> child.alpha = 0.7f
WebRtcReactionsRecyclerAdapter.MAX_REACTION_NUMBER - 2 -> child.alpha = 0.9f
else -> child.alpha = 1f
}
}
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.logging.Log
/**
* Reactions item animator based on [ConversationItemAnimator]
*/
class WebRtcReactionsItemAnimator : RecyclerView.ItemAnimator() {
private data class TweeningInfo(
val startValue: Float,
val endValue: Float
) {
fun lerp(progress: Float): Float {
return startValue + progress * (endValue - startValue)
}
}
private data class AnimationInfo(
val sharedAnimator: ValueAnimator,
val tweeningInfo: TweeningInfo
)
private val pendingSlideAnimations: MutableMap<RecyclerView.ViewHolder, TweeningInfo> = mutableMapOf()
private val slideAnimations: MutableMap<RecyclerView.ViewHolder, AnimationInfo> = mutableMapOf()
override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean {
if (!pendingSlideAnimations.containsKey(viewHolder) &&
!slideAnimations.containsKey(viewHolder)
) {
pendingSlideAnimations[viewHolder] = TweeningInfo(0f, viewHolder.itemView.height.toFloat())
dispatchAnimationStarted(viewHolder)
return true
}
dispatchAnimationFinished(viewHolder)
return false
}
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
if (viewHolder.absoluteAdapterPosition > 1) {
dispatchAnimationFinished(viewHolder)
return false
}
return animateSlide(viewHolder, preLayoutInfo, postLayoutInfo)
}
private fun animateSlide(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
if (slideAnimations.containsKey(viewHolder)) {
dispatchAnimationFinished(viewHolder)
return false
}
val translationY = if (preLayoutInfo == null) {
postLayoutInfo.bottom - postLayoutInfo.top
} else {
preLayoutInfo.top - postLayoutInfo.top
}.toFloat()
if (translationY == 0f) {
viewHolder.itemView.translationY = 0f
dispatchAnimationFinished(viewHolder)
return false
}
viewHolder.itemView.translationY = translationY
pendingSlideAnimations[viewHolder] = TweeningInfo(translationY, 0f)
dispatchAnimationStarted(viewHolder)
return true
}
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
return if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder)) {
dispatchAnimationFinished(viewHolder)
false
} else {
animateSlide(viewHolder, preLayoutInfo, postLayoutInfo)
}
}
override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
if (oldHolder != newHolder) {
dispatchAnimationFinished(oldHolder)
}
return animatePersistence(newHolder, preLayoutInfo, postLayoutInfo)
}
override fun runPendingAnimations() {
Log.d(TAG, "Starting ${pendingSlideAnimations.size} animations.")
runPendingSlideAnimations()
}
private fun runPendingSlideAnimations() {
val animators: MutableList<Animator> = mutableListOf()
for ((viewHolder, tweeningInfo) in pendingSlideAnimations) {
val animator = ValueAnimator.ofFloat(0f, 1f)
slideAnimations[viewHolder] = AnimationInfo(animator, tweeningInfo)
animator.duration = 150L
animator.addUpdateListener {
if (viewHolder in slideAnimations) {
viewHolder.itemView.translationY = tweeningInfo.lerp(it.animatedFraction)
(viewHolder.itemView.parent as RecyclerView?)?.invalidate()
}
}
animator.doOnEnd {
if (viewHolder in slideAnimations) {
handleAnimationEnd(viewHolder)
}
}
animators.add(animator)
}
AnimatorSet().apply {
playTogether(animators)
start()
}
pendingSlideAnimations.clear()
}
private fun handleAnimationEnd(viewHolder: RecyclerView.ViewHolder) {
viewHolder.itemView.translationY = 0f
slideAnimations.remove(viewHolder)
dispatchAnimationFinished(viewHolder)
dispatchFinishedWhenDone()
}
override fun endAnimation(item: RecyclerView.ViewHolder) {
endSlideAnimation(item)
}
override fun endAnimations() {
endSlideAnimations()
dispatchAnimationsFinished()
}
override fun isRunning(): Boolean {
return slideAnimations.values.any { it.sharedAnimator.isRunning }
}
override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) {
val parent = (viewHolder.itemView.parent as? RecyclerView)
parent?.post { parent.invalidate() }
}
private fun endSlideAnimation(item: RecyclerView.ViewHolder) {
slideAnimations[item]?.sharedAnimator?.cancel()
}
private fun endSlideAnimations() {
slideAnimations.values.map { it.sharedAnimator }.forEach {
it.cancel()
}
}
private fun dispatchFinishedWhenDone() {
if (!isRunning) {
Log.d(TAG, "Finished running animations.")
dispatchAnimationsFinished()
}
}
companion object {
private val TAG = Log.tag(WebRtcReactionsItemAnimator::class.java)
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
/**
* RecyclerView adapter for the reactions feed. This takes in a list of [GroupCallReactionEvent] and renders them onto the screen.
* This adapter also encapsulates logic for whether the reaction should be displayed, such as expiration and maximum visible count.
*/
class WebRtcReactionsRecyclerAdapter : ListAdapter<GroupCallReactionEvent, WebRtcReactionsRecyclerAdapter.ViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.webrtc_call_reaction_recycler_item, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
override fun submitList(list: MutableList<GroupCallReactionEvent>?) {
if (list == null) {
super.submitList(null)
} else {
super.submitList(
list.filter { it.getExpirationTimestamp() > System.currentTimeMillis() }
.sortedBy { it.timestamp }
.takeLast(MAX_REACTION_NUMBER)
.reversed()
)
}
}
class ViewHolder(val itemView: View) : RecyclerView.ViewHolder(itemView) {
private val emojiView: EmojiImageView = itemView.findViewById(R.id.webrtc_call_reaction_emoji_view)
private val textView: EmojiTextView = itemView.findViewById(R.id.webrtc_call_reaction_name_textview)
fun bind(item: GroupCallReactionEvent) {
emojiView.setImageEmoji(item.reaction)
textView.text = item.sender.getRecipientDisplayNameDeviceAgnostic(itemView.context)
itemView.isClickable = false
textView.isClickable = false
emojiView.isClickable = false
}
}
private class DiffCallback : DiffUtil.ItemCallback<GroupCallReactionEvent>() {
override fun areItemsTheSame(oldItem: GroupCallReactionEvent, newItem: GroupCallReactionEvent): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: GroupCallReactionEvent, newItem: GroupCallReactionEvent): Boolean {
return oldItem == newItem
}
}
companion object {
const val MAX_REACTION_NUMBER = 5
}
}

View file

@ -0,0 +1,342 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
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.viewinterop.AndroidView
import androidx.lifecycle.toLiveData
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
* said call (e.g., raise hand, kick, etc)
*/
object CallInfoView {
@Composable
fun View(webRtcCallViewModel: WebRtcCallViewModel, modifier: Modifier) {
val state: ParticipantsState by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.map { state ->
ParticipantsState(
inCallLobby = state.callState == WebRtcViewModel.State.CALL_PRE_JOIN,
ringGroup = state.ringGroup,
includeSelf = state.groupCallState === WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED || state.groupCallState === WebRtcViewModel.GroupCallState.IDLE,
participantCount = if (state.participantCount.isPresent) state.participantCount.asLong.toInt() else 0,
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
localParticipant = state.localParticipant,
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
callRecipient = state.recipient
)
}
.subscribeAsState(ParticipantsState())
SignalTheme(
isDarkMode = true
) {
Surface {
CallInfo(state = state, modifier = modifier)
}
}
}
}
@Preview
@Composable
private fun CallInfoPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallInfo(
state = ParticipantsState(remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)))
)
}
}
}
@Composable
private fun CallInfo(
state: ParticipantsState,
modifier: Modifier = Modifier
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
item {
Text(
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 24.dp)
)
}
item {
Box(
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 52.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Text(
text = getCallSheetLabel(state),
style = MaterialTheme.typography.titleSmall
)
}
}
if (!state.inCallLobby || state.isOngoing()) {
items(
items = state.participantsForList,
key = { it.callParticipantId },
contentType = { null }
) {
CallParticipantRow(
callParticipant = it,
isSelfAdmin = false,
onBlockClicked = {}
)
}
} else if (state.isGroupCall()) {
items(
items = state.groupMembers,
key = { it.member.id.toLong() },
contentType = { null }
) {
GroupMemberRow(
groupMember = it,
isSelfAdmin = false
)
}
} else {
item {
CallParticipantRow(
initialRecipient = state.callRecipient,
name = state.callRecipient.getShortDisplayName(LocalContext.current),
showIcons = false,
isVideoEnabled = false,
isMicrophoneEnabled = false,
isSelfAdmin = false,
onBlockClicked = {}
)
}
}
}
}
@Composable
private fun getCallSheetLabel(state: ParticipantsState): String {
return if (!state.inCallLobby || state.isOngoing()) {
pluralStringResource(id = R.plurals.CallParticipantsListDialog_in_this_call, count = state.participantCountForDisplay, state.participantCountForDisplay)
} else if (state.isGroupCall()) {
val groupSize = state.groupMembers.size
if (state.ringGroup) {
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_ring, count = groupSize, groupSize)
} else {
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_notify, count = groupSize, groupSize)
}
} else {
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_ring, count = 1, 1)
}
}
@Preview
@Composable
private fun CallParticipantRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallParticipantRow(
CallParticipant(recipient = Recipient.UNKNOWN),
isSelfAdmin = true
) {}
}
}
}
@Composable
private fun CallParticipantRow(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (CallParticipant) -> Unit
) {
CallParticipantRow(
initialRecipient = callParticipant.recipient,
name = callParticipant.getShortRecipientDisplayName(LocalContext.current),
showIcons = true,
isVideoEnabled = callParticipant.isVideoEnabled,
isMicrophoneEnabled = callParticipant.isMicrophoneEnabled,
isSelfAdmin = isSelfAdmin,
onBlockClicked = { onBlockClicked(callParticipant) }
)
}
@Composable
private fun CallParticipantRow(
initialRecipient: Recipient,
name: String,
showIcons: Boolean,
isVideoEnabled: Boolean,
isMicrophoneEnabled: Boolean,
isSelfAdmin: Boolean,
onBlockClicked: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState(initial = initialRecipient)
if (LocalInspectionMode.current) {
Spacer(
modifier = Modifier
.size(40.dp)
.background(color = Color.Red, shape = CircleShape)
)
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(recipient)
}
}
Spacer(modifier = Modifier.width(24.dp))
Text(
text = name,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
if (showIcons && !isVideoEnabled) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_24),
contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
if (showIcons && !isMicrophoneEnabled) {
if (!isVideoEnabled) {
Spacer(modifier = Modifier.width(16.dp))
}
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_mic_slash_24),
contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
if (showIcons && isSelfAdmin && !recipient.isSelf) {
if (!isMicrophoneEnabled) {
Spacer(modifier = Modifier.width(16.dp))
}
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
contentDescription = null,
modifier = Modifier
.clickable(onClick = onBlockClicked)
.align(Alignment.CenterVertically)
)
}
}
}
@Composable
private fun GroupMemberRow(
groupMember: GroupMemberEntry.FullMember,
isSelfAdmin: Boolean
) {
CallParticipantRow(
initialRecipient = groupMember.member,
name = groupMember.member.getShortDisplayName(LocalContext.current),
showIcons = false,
isVideoEnabled = false,
isMicrophoneEnabled = false,
isSelfAdmin = isSelfAdmin
) {}
}
private data class ParticipantsState(
val inCallLobby: Boolean = false,
val ringGroup: Boolean = true,
val includeSelf: Boolean = false,
val participantCount: Int = 0,
val remoteParticipants: List<CallParticipant> = emptyList(),
val localParticipant: CallParticipant? = null,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
val callRecipient: Recipient = Recipient.UNKNOWN
) {
val participantsForList: List<CallParticipant> = if (includeSelf && localParticipant != null) {
listOf(localParticipant) + remoteParticipants
} else {
remoteParticipants
}
val participantCountForDisplay: Int = if (participantCount == 0) {
participantsForList.size
} else {
participantCount
}
fun isGroupCall(): Boolean {
return groupMembers.isNotEmpty()
}
fun isOngoing(): Boolean {
return remoteParticipants.isNotEmpty()
}
}

View file

@ -0,0 +1,267 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import android.content.res.ColorStateList
import android.os.Handler
import android.view.View
import android.widget.FrameLayout
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.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
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.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
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.
*/
class ControlsAndInfoController(
private val webRtcCallView: WebRtcCallView,
private val viewModel: WebRtcCallViewModel
) : Disposable {
companion object {
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 HIDE_CONTROL_DELAY = 5.seconds.inWholeMilliseconds
}
private val disposables = CompositeDisposable()
private val coordinator: CoordinatorLayout
private val frame: FrameLayout
private val behavior: BottomSheetBehavior<View>
private val callInfoComposeView: ComposeView
private val callControls: ConstraintLayout
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
private val handler: Handler?
get() = webRtcCallView.handler
private var previousCallControlHeight = 0
private var controlPeakHeight = 0
private var controlState: WebRtcControls = WebRtcControls.NONE
init {
val infoTranslationDistance = 24f.dp
coordinator = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
frame = webRtcCallView.findViewById(R.id.call_controls_info_parent)
behavior = BottomSheetBehavior.from(frame)
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, Modifier.nestedScroll(nestedScrollInterop))
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = infoTranslationDistance
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallView.context, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_HIDDEN
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
coordinator.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
val guidelineTop = max(frame.top, (bottom - top) - behavior.peekHeight)
webRtcCallView.post { webRtcCallView.onControlTopChanged(guidelineTop) }
}
callControls.viewTreeObserver.addOnGlobalLayoutListener {
if (callControls.height > 0 && callControls.height != previousCallControlHeight) {
previousCallControlHeight = callControls.height
controlPeakHeight = callControls.height + callControls.y.toInt()
behavior.peekHeight = controlPeakHeight
frame.minimumHeight = coordinator.height / 2
behavior.maxHeight = (coordinator.height.toFloat() * 0.66f).toInt()
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
webRtcCallView.post { webRtcCallView.onControlTopChanged(guidelineTop) }
}
}
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
cancelScheduledHide()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = infoTranslationDistance - (infoTranslationDistance * callInfoComposeView.alpha)
webRtcCallView.onControlTopChanged(max(frame.top, coordinator.height - behavior.peekHeight))
}
})
webRtcCallView.addWindowInsetsListener(object : InsetAwareConstraintLayout.WindowInsetsListener {
override fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int) {
if (navigationBar > 0) {
callControls.padding(bottom = navigationBar)
callInfoComposeView.padding(bottom = navigationBar)
}
}
})
}
fun addVisibilityListener(listener: BottomSheetVisibilityListener): Boolean {
return bottomSheetVisibilityListeners.add(listener)
}
fun showCallInfo() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
fun showControls() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
bottomSheetVisibilityListeners.forEach { it.onShown() }
}
private fun hide(delay: Long = 0L) {
if (delay == 0L) {
if (controlState.isFadeOutEnabled || controlState == WebRtcControls.PIP) {
behavior.isHideable = true
behavior.state = BottomSheetBehavior.STATE_HIDDEN
bottomSheetVisibilityListeners.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 updateControls(newControlState: WebRtcControls) {
val previousState = controlState
controlState = newControlState
if (controlState == WebRtcControls.PIP) {
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 onScheduledHide() {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED && !isDisposed) {
hide()
}
}
private fun cancelScheduledHide() {
handler?.removeCallbacks(scheduleHideControlsRunnable)
}
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))
}
}
override fun dispose() {
disposables.dispose()
}
override fun isDisposed(): Boolean {
return disposables.isDisposed
}
interface BottomSheetVisibilityListener {
fun onShown()
fun onHidden()
}
}

View file

@ -16,7 +16,7 @@ public class CallParticipantsListHeader implements MappingModel<CallParticipants
} }
@NonNull String getHeader(@NonNull Context context) { @NonNull String getHeader(@NonNull Context context) {
return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call_d_people, participantCount, participantCount); return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call, participantCount, participantCount);
} }
@Override @Override

View file

@ -47,6 +47,14 @@ data class CallParticipant constructor(
} }
} }
fun getRecipientDisplayNameDeviceAgnostic(context: Context): String {
return if (recipient.isSelf) {
context.getString(R.string.CallParticipant__you)
} else {
recipient.getDisplayName(context)
}
}
fun getShortRecipientDisplayName(context: Context): String { fun getShortRecipientDisplayName(context: Context): String {
return if (recipient.isSelf && isPrimary) { return if (recipient.isSelf && isPrimary) {
context.getString(R.string.CallParticipant__you) context.getString(R.string.CallParticipant__you)

View file

@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.events
import java.util.concurrent.TimeUnit
/**
* This is a data class to represent a reaction coming in over the wire in the format we need (mapped to a [CallParticipant]) in a way that can be easily
* compared across Rx streams.
*/
data class GroupCallReactionEvent(val sender: CallParticipant, val reaction: String, val timestamp: Long) {
fun getExpirationTimestamp(): Long {
return timestamp + TimeUnit.SECONDS.toMillis(LIFESPAN_SECONDS)
}
companion object {
const val LIFESPAN_SECONDS = 4L
}
}

View file

@ -95,7 +95,8 @@ public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor {
return ephemeralState.copy( return ephemeralState.copy(
CallParticipant.AudioLevel.fromRawAudioLevel(localLevel), CallParticipant.AudioLevel.fromRawAudioLevel(localLevel),
callParticipantId.map(participantId -> Collections.singletonMap(participantId, CallParticipant.AudioLevel.fromRawAudioLevel(remoteLevel))) callParticipantId.map(participantId -> Collections.singletonMap(participantId, CallParticipant.AudioLevel.fromRawAudioLevel(remoteLevel)))
.orElse(Collections.emptyMap()) .orElse(Collections.emptyMap()),
ephemeralState.getUnexpiredReactions()
); );
} }

View file

@ -14,6 +14,7 @@ import org.signal.ringrtc.GroupCall;
import org.signal.ringrtc.PeekInfo; import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.GroupCallReactionEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -25,7 +26,10 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** /**
* Process actions for when the call has at least once been connected and joined. * Process actions for when the call has at least once been connected and joined.
@ -135,7 +139,7 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
} }
} }
return ephemeralState.copy(localAudioLevel, remoteAudioLevels); return ephemeralState.copy(localAudioLevel, remoteAudioLevels, ephemeralState.getUnexpiredReactions());
} }
@Override @Override
@ -197,4 +201,30 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return terminateGroupCall(currentState); return terminateGroupCall(currentState);
} }
@Override
protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List<GroupCall.Reaction> reactions) {
List<GroupCallReactionEvent> reactionList = ephemeralState.getUnexpiredReactions();
Map<CallParticipantId, CallParticipant> participants = currentState.getCallInfoState().getRemoteCallParticipantsMap();
for (GroupCall.Reaction reaction : reactions) {
final GroupCallReactionEvent event = createGroupCallReaction(participants, reaction);
if (event != null) {
reactionList.add(event);
}
}
return ephemeralState.copy(ephemeralState.getLocalAudioLevel(), ephemeralState.getRemoteAudioLevels(), reactionList);
}
@Nullable
private GroupCallReactionEvent createGroupCallReaction(Map<CallParticipantId, CallParticipant> participants, final GroupCall.Reaction reaction) {
CallParticipantId participantId = participants.keySet().stream().filter(participant -> participant.getDemuxId() == reaction.demuxId).findFirst().orElse(null);
if (participantId == null) {
Log.v(TAG, "Could not find CallParticipantId in list of call participants based on demuxId for reaction.");
return null;
}
return new GroupCallReactionEvent(participants.get(participantId), reaction.value, System.currentTimeMillis());
}
} }

View file

@ -295,6 +295,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
process((s, p) -> p.handleScreenOffChange(s)); process((s, p) -> p.handleScreenOffChange(s));
} }
public void react() {
process((s, p) -> p.handleSendGroupReact(s));
}
public void postStateUpdate(@NonNull WebRtcServiceState state) { public void postStateUpdate(@NonNull WebRtcServiceState state) {
EventBus.getDefault().postSticky(new WebRtcViewModel(state)); EventBus.getDefault().postSticky(new WebRtcViewModel(state));
} }
@ -898,7 +902,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
@Override @Override
public void onReactions(@NonNull GroupCall groupCall, List<Reaction> reactions) { public void onReactions(@NonNull GroupCall groupCall, List<Reaction> reactions) {
// TODO: Implement handling of reactions. if (FeatureFlags.groupCallReactions()) {
processStateless(s -> serviceState.getActionProcessor().handleGroupCallReaction(serviceState, s, reactions));
}
} }
@Override @Override

View file

@ -546,6 +546,11 @@ public abstract class WebRtcActionProcessor {
return currentState; return currentState;
} }
protected @NonNull WebRtcServiceState handleSendGroupReact(@NonNull WebRtcServiceState currentState) {
Log.i(tag, "react not processed");
return currentState;
}
public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) { public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) {
Log.i(tag, "handleCameraSwitchCompleted not processed"); Log.i(tag, "handleCameraSwitchCompleted not processed");
return currentState; return currentState;
@ -729,6 +734,11 @@ public abstract class WebRtcActionProcessor {
return currentState; return currentState;
} }
protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List<GroupCall.Reaction> reactions) {
Log.i(tag, "handleGroupCallReaction not processed");
return ephemeralState;
}
protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) { protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) {
Log.i(tag, "handleGroupRequestMembershipProof not processed"); Log.i(tag, "handleGroupRequestMembershipProof not processed");
return currentState; return currentState;

View file

@ -2,11 +2,18 @@ package org.thoughtcrime.securesms.service.webrtc.state
import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
/** /**
* The state of the call system which contains data which changes frequently. * The state of the call system which contains data which changes frequently.
*/ */
data class WebRtcEphemeralState( data class WebRtcEphemeralState(
val localAudioLevel: CallParticipant.AudioLevel = CallParticipant.AudioLevel.LOWEST, val localAudioLevel: CallParticipant.AudioLevel = CallParticipant.AudioLevel.LOWEST,
val remoteAudioLevels: Map<CallParticipantId, CallParticipant.AudioLevel> = emptyMap() val remoteAudioLevels: Map<CallParticipantId, CallParticipant.AudioLevel> = emptyMap(),
) private val reactions: List<GroupCallReactionEvent> = emptyList()
) {
fun getUnexpiredReactions(): List<GroupCallReactionEvent> {
return reactions.filter { System.currentTimeMillis() < it.getExpirationTimestamp() }
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stories.viewer.reply.reaction
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.view.children
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
import kotlin.time.Duration.Companion.seconds
class MultiReactionBurstLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val cooldownTimes = mutableMapOf<String, Long>()
private var nextViewIndex = 0
init {
repeat(MAX_SIMULTANEOUS_REACTIONS) {
addView(OnReactionSentView(context))
}
}
fun displayReactions(reactions: List<GroupCallReactionEvent>) {
if (children.count() == 0) {
throw IllegalStateException("You must add views before displaying reactions!")
}
reactions.filter {
if (it.getExpirationTimestamp() < System.currentTimeMillis()) {
return@filter false
}
val cutoffTimestamp = cooldownTimes[EmojiUtil.getCanonicalRepresentation(it.reaction)] ?: return@filter true
return@filter cutoffTimestamp < it.timestamp
}
.groupBy { EmojiUtil.getCanonicalRepresentation(it.reaction) }
.filter { it.value.groupBy { event -> event.sender }.size >= REACTION_COUNT_THRESHOLD }
.values
.map { it.sortedBy { event -> event.timestamp } }
.map { it[REACTION_COUNT_THRESHOLD - 1] }
.sortedBy { it.timestamp }
.take(MAX_SIMULTANEOUS_REACTIONS - cooldownTimes.filter { it.value > System.currentTimeMillis() }.size)
.forEach {
val reactionView = getNextReactionView()
reactionView.playForEmoji(it.reaction)
cooldownTimes[EmojiUtil.getCanonicalRepresentation(it.reaction)] = it.timestamp + cooldownDuration.inWholeMilliseconds
}
}
private fun getNextReactionView(): OnReactionSentView {
val v = getChildAt(nextViewIndex) as OnReactionSentView
nextViewIndex = (nextViewIndex + 1) % MAX_SIMULTANEOUS_REACTIONS
return v
}
companion object {
private const val REACTION_COUNT_THRESHOLD = 3
private const val MAX_SIMULTANEOUS_REACTIONS = 3
private val cooldownDuration = 2.seconds
}
}

View file

@ -116,6 +116,7 @@ public final class FeatureFlags {
private static final String IDEAL_DONATIONS = "android.ideal.donations.5"; private static final String IDEAL_DONATIONS = "android.ideal.donations.5";
public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions"; public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions";
public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions"; public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
private static final String CALLING_REACTIONS = "android.calling.reactions";
/** /**
* We will only store remote values for flags in this set. If you want a flag to be controllable * We will only store remote values for flags in this set. If you want a flag to be controllable
@ -183,7 +184,8 @@ public final class FeatureFlags {
SEPA_DEBIT_DONATIONS, SEPA_DEBIT_DONATIONS,
IDEAL_DONATIONS, IDEAL_DONATIONS,
IDEAL_ENABLED_REGIONS, IDEAL_ENABLED_REGIONS,
SEPA_ENABLED_REGIONS SEPA_ENABLED_REGIONS,
CALLING_REACTIONS
); );
@VisibleForTesting @VisibleForTesting
@ -253,7 +255,8 @@ public final class FeatureFlags {
PROMPT_BATTERY_SAVER, PROMPT_BATTERY_SAVER,
USERNAMES, USERNAMES,
CRASH_PROMPT_CONFIG, CRASH_PROMPT_CONFIG,
BLOCK_SSE BLOCK_SSE,
CALLING_REACTIONS
); );
/** /**
@ -659,6 +662,13 @@ public final class FeatureFlags {
return getString(SEPA_ENABLED_REGIONS, ""); return getString(SEPA_ENABLED_REGIONS, "");
} }
/**
* Whether or not group call reactions are enabled.
*/
public static boolean groupCallReactions() {
return getBoolean(CALLING_REACTIONS, false);
}
/** Only for rendering debug info. */ /** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() { public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES); return new TreeMap<>(REMOTE_VALUES);

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M7.5 4.14l1.4 1.4c0.23-1.5 1.53-2.67 3.1-2.67 1.73 0 3.13 1.4 3.13 3.13v5.5c0 0.09 0 0.17-0.02 0.26l1.46 1.45c0.2-0.53 0.3-1.1 0.3-1.71V6c0-2.7-2.18-4.88-4.87-4.88-2.03 0-3.77 1.25-4.5 3.02Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12.93 16.29l-1.78-1.78c-1.04-0.3-1.86-1.12-2.16-2.16l-1.87-1.87v1.02c0 2.7 2.19 4.88 4.88 4.88 0.32 0 0.63-0.04 0.93-0.1Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M14.34 17.7l1.33 1.33c-0.86 0.42-1.8 0.7-2.8 0.8v1.3h3.38c0.48 0 0.88 0.39 0.88 0.87s-0.4 0.88-0.88 0.88h-8.5c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88h3.38v-1.29c-4.22-0.44-7.5-4-7.5-8.33v-1c0-0.48 0.39-0.88 0.87-0.88s0.88 0.4 0.88 0.88v1c0 3.66 2.96 6.63 6.62 6.63 0.82 0 1.61-0.16 2.34-0.43Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M19.18 15.82l-1.29-1.29c0.47-0.9 0.73-1.94 0.73-3.03v-1c0-0.48 0.4-0.88 0.88-0.88s0.88 0.4 0.88 0.88v1c0 1.58-0.44 3.06-1.2 4.32Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M4.12 2.88c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l17 17c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24l-17-17Z"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M2.37 2.13c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l18.5 18.5c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24L2.37 2.13Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M1.17 7.26C1.2 6.75 1.3 6.29 1.49 5.86l1.43 1.42V7.4C2.87 7.86 2.87 8.45 2.87 9.3v5.4c0 0.85 0 1.44 0.03 1.9 0.04 0.45 0.1 0.69 0.2 0.86 0.2 0.4 0.53 0.73 0.93 0.93 0.17 0.1 0.41 0.16 0.86 0.2 0.46 0.03 1.05 0.04 1.9 0.04h4.9c0.85 0 1.44 0 1.9-0.04 0.23-0.02 0.4-0.05 0.55-0.08l1.33 1.32-0.22 0.12c-0.47 0.24-0.96 0.34-1.52 0.38-0.53 0.05-1.2 0.05-2 0.05H6.76c-0.8 0-1.47 0-2-0.05C4.2 20.3 3.7 20.2 3.24 19.95c-0.73-0.37-1.32-0.96-1.7-1.7-0.23-0.46-0.33-0.95-0.37-1.5-0.05-0.54-0.05-1.2-0.04-2.01V9.26c0-0.8 0-1.47 0.04-2Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M15.63 13.27V9.3c0-0.85 0-1.44-0.04-1.9-0.04-0.45-0.1-0.69-0.2-0.86-0.2-0.4-0.53-0.73-0.93-0.93-0.17-0.1-0.41-0.16-0.86-0.2-0.46-0.03-1.05-0.04-1.9-0.04H7.73L6 3.63h0.77 4.98c0.8 0 1.47 0 2 0.04 0.56 0.04 1.05 0.14 1.52 0.38 0.73 0.37 1.32 0.96 1.7 1.7 0.23 0.46 0.33 0.95 0.37 1.5 0.04 0.46 0.04 1 0.04 1.64l2.8-2.8c1.19-1.18 3.2-0.35 3.2 1.32v9.18c0 1.48-1.6 2.31-2.78 1.64l-4.96-4.96ZM17.36 12c0 0.4 0.17 0.8 0.46 1.09l3.58 3.58 0.06 0.04h0.08l0.06-0.05c0-0.01 0.02-0.03 0.02-0.07V7.4c0-0.04-0.02-0.06-0.02-0.07L21.55 7.3l-0.08-0.01s-0.03 0-0.06 0.04l-3.58 3.58c-0.3 0.29-0.45 0.68-0.45 1.09Z"/>
</vector>

View file

@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/call_controls_info_coordinator"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_height="match_parent"
tools:layout_width="match_parent">
<FrameLayout
android:id="@+id/call_controls_info_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_colorSurface"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<ImageView
android:id="@+id/handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/bottom_sheet_handle" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/call_info_compose"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="38dp"
android:orientation="vertical" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/call_controls_constraint_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="48dp"
android:layout_marginTop="38dp">
<org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutputToggleButton
android:id="@+id/call_screen_speaker_toggle"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:contentDescription="@string/WebRtcCallView__toggle_speaker"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_camera_direction_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/webrtc_call_screen_speaker_toggle"
tools:visibility="visible" />
<ImageView
android:id="@+id/call_screen_camera_direction_toggle"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:clickable="false"
android:contentDescription="@string/WebRtcCallView__toggle_camera_direction"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_video_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_speaker_toggle"
app:srcCompat="@drawable/webrtc_call_screen_camera_toggle"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_video_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_video_toggle"
android:contentDescription="@string/WebRtcCallView__toggle_camera"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_mic_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_camera_direction_toggle"
tools:checked="true"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_audio_mic_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_mic_toggle"
android:contentDescription="@string/WebRtcCallView__toggle_mute"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_ring_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_video_toggle"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_audio_ring_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_ring_toggle"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_end_call"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_audio_mic_toggle" />
<ImageView
android:id="@+id/call_screen_end_call"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:clickable="false"
android:contentDescription="@string/WebRtcCallView__end_call"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_audio_ring_toggle"
app:srcCompat="@drawable/webrtc_call_screen_hangup"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/call_screen_start_call_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="32dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/call_screen_start_call_start_call"
style="@style/Widget.Signal.Button.Flat"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:minWidth="160dp"
android:text="@string/WebRtcCallView__start_call"
android:textAllCaps="false"
android:textColor="@color/core_green_text_button"
app:backgroundTint="@color/signal_light_colorPrimary"
app:cornerRadius="28dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="36dp"
android:layout_margin="8dp"
android:layout_gravity="center_vertical"
android:orientation="horizontal">
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/webrtc_call_reaction_emoji_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"/>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/webrtc_call_reaction_name_textview"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:background="@drawable/transparent_black_pill" />
</LinearLayout>

View file

@ -5,6 +5,8 @@
tools:parentTag="org.thoughtcrime.securesms.components.webrtc.WebRtcCallView" tools:parentTag="org.thoughtcrime.securesms.components.webrtc.WebRtcCallView"
tools:viewBindingIgnore="true"> tools:viewBindingIgnore="true">
<include layout="@layout/system_ui_guidelines" />
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/fold_top_guideline" android:id="@+id/fold_top_guideline"
android:layout_width="0dp" android:layout_width="0dp"
@ -12,11 +14,18 @@
android:orientation="horizontal" android:orientation="horizontal"
app:layout_constraintGuide_end="0dp" /> app:layout_constraintGuide_end="0dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/call_screen_above_controls_guideline"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="horizontal"
tools:layout_constraintGuide_end="200dp" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/call_screen_participants_parent" android:id="@+id/call_screen_participants_parent"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/fold_top_guideline" app:layout_constraintBottom_toTopOf="@id/call_screen_participants_recycler"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent">
@ -86,10 +95,24 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout <org.thoughtcrime.securesms.components.recyclerview.NoTouchingRecyclerView
android:id="@+id/call_screen" android:id="@+id/call_screen_reactions_feed"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="match_parent"> android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:clickable="false"
android:orientation="vertical"
android:scrollbars="none"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/call_screen_above_controls_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:reverseLayout="true"
tools:itemCount="2"
tools:listitem="@layout/webrtc_call_reaction_recycler_item" />
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/call_screen_show_participants_guideline" android:id="@+id/call_screen_show_participants_guideline"
@ -103,7 +126,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:orientation="horizontal" android:orientation="horizontal"
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline" app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
app:layout_constraintGuide_end="0dp" /> app:layout_constraintGuide_end="0dp" />
<View <View
@ -115,20 +138,6 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/call_screen_status_bar_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:layout_constraintGuide_begin="48dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/call_screen_navigation_bar_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:layout_constraintGuide_end="48dp" />
<Space <Space
android:id="@+id/call_screen_footer_gradient_spacer" android:id="@+id/call_screen_footer_gradient_spacer"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -150,15 +159,16 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/call_screen_participants_recycler" android:id="@+id/call_screen_participants_recycler"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="72dp" android:layout_height="72dp"
android:layout_marginStart="16dp" android:paddingStart="16dp"
android:clipToPadding="false"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:orientation="horizontal" android:orientation="horizontal"
android:visibility="gone" android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@id/call_screen_video_toggle" app:layout_constraintBottom_toTopOf="@id/call_screen_above_controls_guideline"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1" app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -183,12 +193,11 @@
android:layout_height="@dimen/picture_in_picture_gesture_helper_pip_height" android:layout_height="@dimen/picture_in_picture_gesture_helper_pip_height"
android:background="@null" android:background="@null"
android:clipChildren="true" android:clipChildren="true"
android:translationX="100000dp" android:layout_gravity="end|bottom"
android:translationY="100000dp" android:layout_marginStart="@dimen/picture_in_picture_gesture_helper_frame_padding"
android:layout_marginEnd="@dimen/picture_in_picture_gesture_helper_frame_padding"
android:visibility="gone" android:visibility="gone"
app:cardCornerRadius="8dp" app:cardCornerRadius="8dp"
tools:translationX="0dp"
tools:translationY="0dp"
tools:visibility="visible"> tools:visibility="visible">
<include <include
@ -205,7 +214,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height" android:layout_height="@dimen/signal_m3_toolbar_height"
android:minHeight="@dimen/signal_m3_toolbar_height" android:minHeight="@dimen/signal_m3_toolbar_height"
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" app:layout_constraintTop_toTopOf="@id/status_bar_guideline"
app:menu="@menu/webrtc_toolbar_menu" app:menu="@menu/webrtc_toolbar_menu"
app:navigationIcon="@drawable/ic_arrow_left_24" /> app:navigationIcon="@drawable/ic_arrow_left_24" />
@ -214,7 +223,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/signal_m3_toolbar_height" android:layout_height="@dimen/signal_m3_toolbar_height"
android:minHeight="@dimen/signal_m3_toolbar_height" android:minHeight="@dimen/signal_m3_toolbar_height"
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" app:layout_constraintTop_toTopOf="@id/status_bar_guideline"
app:menu="@menu/webrtc_toolbar_menu" app:menu="@menu/webrtc_toolbar_menu"
app:navigationIcon="@drawable/ic_arrow_left_24" app:navigationIcon="@drawable/ic_arrow_left_24"
app:subtitleTextAppearance="@style/Signal.Text.BodyMedium" app:subtitleTextAppearance="@style/Signal.Text.BodyMedium"
@ -228,107 +237,7 @@
android:layout_marginEnd="64dp" android:layout_marginEnd="64dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" /> app:layout_constraintTop_toTopOf="@id/status_bar_guideline" />
<org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutputToggleButton
android:id="@+id/call_screen_speaker_toggle"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:contentDescription="@string/WebRtcCallView__toggle_speaker"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_camera_direction_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/webrtc_call_screen_speaker_toggle"
tools:visibility="visible" />
<ImageView
android:id="@+id/call_screen_camera_direction_toggle"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:clickable="false"
android:contentDescription="@string/WebRtcCallView__toggle_camera_direction"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_video_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_speaker_toggle"
app:srcCompat="@drawable/webrtc_call_screen_camera_toggle"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_video_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_video_toggle"
android:contentDescription="@string/WebRtcCallView__toggle_camera"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_mic_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_camera_direction_toggle"
tools:checked="true"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_audio_mic_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_mic_toggle"
android:contentDescription="@string/WebRtcCallView__toggle_mute"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_ring_toggle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_video_toggle"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AccessibleToggleButton
android:id="@+id/call_screen_audio_ring_toggle"
style="@style/WebRtcCallV2CompoundButton"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:background="@drawable/webrtc_call_screen_ring_toggle"
android:stateListAnimator="@null"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toStartOf="@id/call_screen_end_call"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_audio_mic_toggle" />
<ImageView
android:id="@+id/call_screen_end_call"
android:layout_width="@dimen/webrtc_button_size"
android:layout_height="@dimen/webrtc_button_size"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="30dp"
android:clickable="false"
android:contentDescription="@string/WebRtcCallView__end_call"
android:scaleType="fitXY"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/call_screen_audio_ring_toggle"
app:srcCompat="@drawable/webrtc_call_screen_hangup"
tools:visibility="visible" />
<ImageView <ImageView
android:id="@+id/call_screen_decline_call" android:id="@+id/call_screen_decline_call"
@ -338,7 +247,7 @@
android:layout_marginBottom="65dp" android:layout_marginBottom="65dp"
android:contentDescription="@string/WebRtcCallScreen__decline" android:contentDescription="@string/WebRtcCallScreen__decline"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline" app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
app:layout_constraintEnd_toStartOf="@id/call_screen_answer_call" app:layout_constraintEnd_toStartOf="@id/call_screen_answer_call"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -367,7 +276,7 @@
android:layout_marginBottom="65dp" android:layout_marginBottom="65dp"
android:contentDescription="@string/WebRtcCallScreen__answer" android:contentDescription="@string/WebRtcCallScreen__answer"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline" app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@id/call_screen_decline_call" app:layout_constraintStart_toEndOf="@id/call_screen_decline_call"
@ -415,34 +324,16 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
tools:visibility="gone" /> tools:visibility="gone" />
<LinearLayout <org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout
android:id="@+id/call_screen_start_call_controls" android:id="@+id/call_screen_reactions_container"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="0dp"
android:gravity="center" android:clickable="false"
android:orientation="horizontal" android:focusable="false"
android:paddingStart="16dp" app:layout_constraintBottom_toBottomOf="@id/call_screen_reactions_feed"
android:paddingEnd="16dp" app:layout_constraintEnd_toEndOf="@id/call_screen_reactions_feed"
android:paddingBottom="32dp" app:layout_constraintStart_toStartOf="@id/call_screen_reactions_feed"
android:visibility="gone" app:layout_constraintTop_toTopOf="@id/call_screen_reactions_feed" />
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/call_screen_start_call_start_call"
style="@style/Widget.Signal.Button.Flat"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:minWidth="160dp"
android:text="@string/WebRtcCallView__start_call"
android:textAllCaps="false"
android:textColor="@color/core_green_text_button"
app:backgroundTint="@color/signal_light_colorPrimary"
app:cornerRadius="28dp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/call_screen_error_cancel" android:id="@+id/call_screen_error_cancel"
@ -458,7 +349,7 @@
android:textColor="@color/core_white" android:textColor="@color/core_white"
android:visibility="gone" android:visibility="gone"
app:backgroundTint="@color/transparent_white_40" app:backgroundTint="@color/transparent_white_40"
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline" app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
tools:visibility="gone" /> tools:visibility="gone" />
@ -505,5 +396,5 @@
app:barrierDirection="top" app:barrierDirection="top"
app:constraint_referenced_ids="call_screen_answer_call,call_screen_decline_call,call_screen_audio_mic_toggle,call_screen_camera_direction_toggle,call_screen_video_toggle,call_screen_answer_without_video,call_screen_speaker_toggle,call_screen_end_call" /> app:constraint_referenced_ids="call_screen_answer_call,call_screen_decline_call,call_screen_audio_mic_toggle,call_screen_camera_direction_toggle,call_screen_video_toggle,call_screen_answer_without_video,call_screen_speaker_toggle,call_screen_end_call" />
</androidx.constraintlayout.widget.ConstraintLayout> <include layout="@layout/webrtc_call_controls"/>
</merge> </merge>

View file

@ -1848,9 +1848,17 @@
<string name="WebRtcAudioOutputBottomSheet__earpiece_icon_content_description">An icon representing a device\'s earpiece.</string> <string name="WebRtcAudioOutputBottomSheet__earpiece_icon_content_description">An icon representing a device\'s earpiece.</string>
<!-- CallParticipantsListDialog --> <!-- CallParticipantsListDialog -->
<plurals name="CallParticipantsListDialog_in_this_call_d_people"> <plurals name="CallParticipantsListDialog_in_this_call">
<item quantity="one">In this call · %1$d person</item> <item quantity="one">In this call (%1$d)</item>
<item quantity="other">In this call · %1$d people</item> <item quantity="other">In this call (%1$d)</item>
</plurals>
<plurals name="CallParticipantsListDialog__signal_will_ring">
<item quantity="one">Signal will Ring (%1$d)</item>
<item quantity="other">Signal will Ring (%1$d)</item>
</plurals>
<plurals name="CallParticipantsListDialog__signal_will_notify">
<item quantity="one">Signal will Notify (%1$d)</item>
<item quantity="other">Signal will Notify (%1$d)</item>
</plurals> </plurals>
<!-- CallParticipantView --> <!-- CallParticipantView -->