From a678555d8dcc1637f2225764f1485eea312d91c0 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 6 Dec 2023 13:35:19 -0500 Subject: [PATCH] Receive calling reactions support and control ux refactor. Co-authored-by: Nicholas --- .../bottomsheet/BottomSheetBehaviorHack.kt | 3 +- .../securesms/WebRtcCallActivity.java | 45 +- .../securesms/animation/ResizeAnimation.java | 5 + .../components/InsetAwareConstraintLayout.kt | 38 +- .../recyclerview/NoTouchingRecyclerView.kt | 31 ++ .../components/webrtc/CallLinkInfoSheet.kt | 7 +- .../webrtc/CallParticipantsState.kt | 9 +- .../webrtc/CallStateUpdatePopupWindow.kt | 2 +- .../components/webrtc/LayoutPositions.kt | 37 ++ .../PictureInPictureExpansionHelper.java | 179 +++----- .../webrtc/PictureInPictureGestureHelper.java | 239 ++++++----- .../components/webrtc/WebRtcCallView.java | 385 ++++-------------- .../webrtc/WebRtcCallViewModel.java | 4 - .../components/webrtc/WebRtcControls.java | 65 +-- .../webrtc/WebRtcLocalRenderState.java | 6 +- .../WebRtcReactionsAlphaItemDecoration.kt | 26 ++ .../webrtc/WebRtcReactionsItemAnimator.kt | 180 ++++++++ .../webrtc/WebRtcReactionsRecyclerAdapter.kt | 71 ++++ .../webrtc/controls/CallInfoView.kt | 342 ++++++++++++++++ .../controls/ControlsAndInfoController.kt | 267 ++++++++++++ .../CallParticipantsListHeader.java | 2 +- .../securesms/events/CallParticipant.kt | 8 + .../events/GroupCallReactionEvent.kt | 22 + .../webrtc/ConnectedCallActionProcessor.java | 3 +- .../webrtc/GroupConnectedActionProcessor.java | 32 +- .../service/webrtc/SignalCallManager.java | 8 +- .../service/webrtc/WebRtcActionProcessor.java | 10 + .../webrtc/state/WebRtcEphemeralState.kt | 11 +- .../reaction/MultiReactionBurstLayout.kt | 71 ++++ .../securesms/util/FeatureFlags.java | 14 +- .../main/res/drawable/symbol_mic_slash_24.xml | 21 + .../res/drawable/symbol_video_slash_24.xml | 15 + .../main/res/layout/webrtc_call_controls.xml | 182 +++++++++ .../webrtc_call_reaction_recycler_item.xml | 30 ++ app/src/main/res/layout/webrtc_call_view.xml | 215 +++------- app/src/main/res/values/strings.xml | 14 +- 36 files changed, 1852 insertions(+), 747 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/NoTouchingRecyclerView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/LayoutPositions.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsAlphaItemDecoration.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsItemAnimator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsRecyclerAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/events/GroupCallReactionEvent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/reaction/MultiReactionBurstLayout.kt create mode 100644 app/src/main/res/drawable/symbol_mic_slash_24.xml create mode 100644 app/src/main/res/drawable/symbol_video_slash_24.xml create mode 100644 app/src/main/res/layout/webrtc_call_controls.xml create mode 100644 app/src/main/res/layout/webrtc_call_reaction_recycler_item.xml diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BottomSheetBehaviorHack.kt b/app/src/main/java/com/google/android/material/bottomsheet/BottomSheetBehaviorHack.kt index a9b644e87e..8c2469bad6 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BottomSheetBehaviorHack.kt +++ b/app/src/main/java/com/google/android/material/bottomsheet/BottomSheetBehaviorHack.kt @@ -1,14 +1,13 @@ package com.google.android.material.bottomsheet import android.view.View -import android.widget.FrameLayout import java.lang.ref.WeakReference /** * Manually adjust the nested scrolling child for a given [BottomSheetBehavior]. */ object BottomSheetBehaviorHack { - fun setNestedScrollingChild(behavior: BottomSheetBehavior, view: View) { + fun setNestedScrollingChild(behavior: BottomSheetBehavior, view: View) { behavior.nestedScrollingChildRef = WeakReference(view) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 77670ca61a..2cd654d607 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -29,6 +29,7 @@ import android.media.AudioManager; import android.os.Build; import android.os.Bundle; import android.util.Rational; +import android.view.View; import android.view.Window; import android.view.WindowManager; @@ -53,8 +54,8 @@ import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.libsignal.protocol.IdentityKey; import org.signal.core.util.logging.Log; +import org.signal.libsignal.protocol.IdentityKey; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; 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.WebRtcControls; 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.requests.CallLinkIncomingRequestSheet; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; @@ -152,6 +154,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan private PictureInPictureParams.Builder pipBuilderParams; private LifecycleDisposable lifecycleDisposable; private long lastCallLinkDisconnectDialogShowTime; + private ControlsAndInfoController controlsAndInfo; private Disposable ephemeralStateDisposable = Disposable.empty(); @@ -161,7 +164,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan super.attachBaseContext(newBase); } - @SuppressLint("SourceLockedOrientationActivity") + @SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" }) @Override public void onCreate(Bundle savedInstanceState) { Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")"); @@ -189,6 +192,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan initializeViewModel(isLandscapeEnabled); 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()); if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) { @@ -431,7 +441,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan viewModel.setIsLandscapeEnabled(isLandscapeEnabled); viewModel.setIsInPipMode(isInPipMode()); 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); 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) .setOnDismissListener(() -> viewModel.onDismissedVideoTooltip()) .show(TooltipPopup.POSITION_ABOVE); - return; } } else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) { if (videoTooltip != null) { @@ -912,7 +924,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan private void handleCallPreJoin(@NonNull WebRtcViewModel event) { if (event.getGroupState().isNotIdle()) { - callScreen.setStatusFromGroupCallState(event.getGroupState()); callScreen.setRingGroup(event.shouldRingGroup()); if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) { @@ -946,10 +957,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } @Override - public void onControlsFadeOut() { - if (videoTooltip != null) { - videoTooltip.dismiss(); - } + public void toggleControls() { + controlsAndInfo.toggleControls(); } @Override @@ -1060,7 +1069,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan if (liveRecipient.get().isCallLink()) { CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId()); } 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(); + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java index 5308484398..f259aa9d3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.animation; +import android.graphics.Point; import android.view.View; import android.view.ViewGroup; import android.view.animation.Animation; @@ -16,6 +17,10 @@ public class ResizeAnimation extends Animation { private int startWidth; 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) { this.target = target; this.targetWidthPx = targetWidthPx; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt index 556f8baca3..dd6eb9845f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt @@ -51,13 +51,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor( 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 parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_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 listeners: MutableList = mutableListOf() + private val windowInsetsListeners: MutableSet = mutableSetOf() + private val keyboardStateListeners: MutableSet = mutableSetOf() private val keyboardAnimator = KeyboardInsetAnimator() private val displayMetrics = DisplayMetrics() private var overridingKeyboard: Boolean = false @@ -82,20 +83,35 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor( } fun addKeyboardStateListener(listener: KeyboardStateListener) { - listeners += listener + keyboardStateListeners += listener } 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) { val isLtr = ViewUtil.isLtr(this) - statusBarGuideline?.setGuidelineBegin(windowInsets.top) - navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom) - parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right) - parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left) + val statusBar = windowInsets.top + val navigationBar = windowInsets.bottom + val parentStart = if (isLtr) windowInsets.left else windowInsets.right + 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) { setKeyboardHeight(keyboardInsets.bottom) @@ -113,7 +129,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor( } if (previousKeyboardHeight != keyboardInsets.bottom) { - listeners.forEach { + keyboardStateListeners.forEach { if (previousKeyboardHeight <= 0) { it.onKeyboardShown() } else { @@ -191,6 +207,10 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor( 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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/NoTouchingRecyclerView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/NoTouchingRecyclerView.kt new file mode 100644 index 0000000000..d2b9cd6486 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/NoTouchingRecyclerView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt index 1998697f44..2f15f8f0eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt @@ -51,6 +51,7 @@ import androidx.lifecycle.toLiveData import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.collections.immutable.toImmutableList import org.signal.core.ui.BottomSheets @@ -121,10 +122,6 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() { @Composable override fun SheetContent() { val callLinkDetailsState by callLinkDetailsViewModel.state - val callParticipantsState by webRtcCallViewModel.callParticipantsState - .toFlowable(BackpressureStrategy.LATEST) - .toLiveData() - .observeAsState() val participants: List by webRtcCallViewModel.callParticipantsState .toFlowable(BackpressureStrategy.LATEST) @@ -332,7 +329,7 @@ private fun CallLinkMemberRow( .fillMaxWidth() .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) .toLiveData() .observeAsState(initial = callParticipant.recipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt index 8686355cba..8605ed8f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal +import org.thoughtcrime.securesms.events.GroupCallReactionEvent import org.thoughtcrime.securesms.events.WebRtcViewModel import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry import org.thoughtcrime.securesms.recipients.Recipient @@ -28,12 +29,14 @@ data class CallParticipantsState( val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false), val focusedParticipant: CallParticipant = CallParticipant.EMPTY, val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE, + val reactions: List = emptyList(), val isInPipMode: Boolean = false, private val showVideoForOutgoing: Boolean = false, val isViewingFocusedParticipant: Boolean = false, val remoteDevicesCount: OptionalLong = OptionalLong.empty(), private val foldableState: FoldableState = FoldableState.flat(), val isInOutgoingRingingMode: Boolean = false, + val recipient: Recipient = Recipient.UNKNOWN, val ringGroup: Boolean = false, val ringerRecipient: Recipient = Recipient.UNKNOWN, val groupMembers: List = emptyList(), @@ -223,6 +226,7 @@ data class CallParticipantsState( focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants), localRenderState = localRenderState, showVideoForOutgoing = newShowVideoForOutgoing, + recipient = webRtcViewModel.recipient, remoteDevicesCount = webRtcViewModel.remoteDevicesCount, ringGroup = webRtcViewModel.ringGroup, isInOutgoingRingingMode = isInOutgoingRingingMode, @@ -269,7 +273,8 @@ data class CallParticipantsState( return oldState.copy( remoteParticipants = oldState.remoteParticipants.map { p -> p.copy(audioLevel = ephemeralState.remoteAudioLevels[p.callParticipantId]) }, 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) var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE - if (!isInPip && isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) { + if (!isInPip && isExpanded && localParticipant.isVideoEnabled) { return WebRtcLocalRenderState.EXPANDED } else if (displayLocal || showVideoForOutgoing) { if (callState == WebRtcViewModel.State.CALL_CONNECTED || callState == WebRtcViewModel.State.CALL_RECONNECTING) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt index 5af2d95172..ffc8ab3907 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt @@ -84,7 +84,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow( 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) // 54 is the top margin of the contentView (30) plus the desired padding (24) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/LayoutPositions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/LayoutPositions.kt new file mode 100644 index 0000000000..e493f0efd2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/LayoutPositions.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java index d216407ee3..f211f4d586 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java @@ -1,18 +1,38 @@ package org.thoughtcrime.securesms.components.webrtc; +import android.graphics.Point; import android.view.View; import android.view.ViewGroup; +import android.view.animation.Animation; import androidx.annotation.MainThread; 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. */ @MainThread final class PictureInPictureExpansionHelper { + private static final int PIP_RESIZE_DURATION_MS = 300; + private static final int EXPANDED_PIP_WIDTH_DP = 170; + private static final int EXPANDED_PIP_HEIGHT_DP = 300; + + private final View selfPip; + private final Point expandedDimensions; + 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() { 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; } - 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()) { 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 public void onAnimationWillStart() { state = State.IS_EXPANDING; - callback.onAnimationWillStart(); - } - - @Override - public void onPictureInPictureExpanded() { - callback.onPictureInPictureExpanded(); - } - - @Override - public void onPictureInPictureNotVisible() { - callback.onPictureInPictureNotVisible(); } @Override public void onAnimationHasFinished() { state = State.IS_EXPANDED; - callback.onAnimationHasFinished(); } }); } - public void shrink(@NonNull View toExpand, @NonNull Callback callback) { + public void shrink() { if (isShrunkenOrShrinking()) { return; } - performShrinkAnimation(toExpand, new Callback() { + resizeSelfPip(defaultDimensions, new Callback() { @Override public void onAnimationWillStart() { state = State.IS_SHRINKING; - callback.onAnimationWillStart(); - } - - @Override - public void onPictureInPictureExpanded() { - callback.onPictureInPictureExpanded(); - } - - @Override - public void onPictureInPictureNotVisible() { - callback.onPictureInPictureNotVisible(); } @Override public void onAnimationHasFinished() { state = State.IS_SHRUNKEN; - callback.onAnimationHasFinished(); } }); } - private void performExpandAnimation(@NonNull View target, @NonNull Callback callback) { - ViewGroup parent = (ViewGroup) target.getParent(); + private void resizeSelfPip(@NonNull Point dimension, @NonNull Callback callback) { + 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(); - float y = target.getY(); - float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth(); - float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight(); - float scale = Math.max(scaleX, scaleY); + @Override + public void onAnimationEnd(Animation animation) { + callback.onAnimationHasFinished(); + } + }); - callback.onAnimationWillStart(); - - 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); - }); - }); + selfPip.clearAnimation(); + selfPip.startAnimation(resizeAnimation); } enum State { @@ -174,24 +129,12 @@ final class PictureInPictureExpansionHelper { * Called when an animation (shrink or expand) will begin. This happens before any animation * is executed. */ - 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(); + default void onAnimationWillStart() {} /** * Called when the animation is complete. Useful for e.g. adjusting the pip's final location to * make sure it is respecting the screen space available. */ - void onAnimationHasFinished(); + default void onAnimationHasFinished() {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java index 28ddf3aefa..e462a00aed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.components.webrtc; -import android.animation.Animator; import android.annotation.SuppressLint; import android.graphics.Point; import android.view.GestureDetector; +import android.view.Gravity; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; @@ -11,19 +11,16 @@ import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.Interpolator; +import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.core.view.GestureDetectorCompat; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout; import java.util.Arrays; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Queue; 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 ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator(); - private final ViewGroup parent; - private final View child; - private final int framePadding; - private final Queue runAfterFling; + private final ViewGroup parent; + private final View child; + private final int framePadding; private int pipWidth; private int pipHeight; - private int activePointerId = MotionEvent.INVALID_POINTER_ID; + private int activePointerId = MotionEvent.INVALID_POINTER_ID; private float lastTouchX; private float lastTouchY; - private boolean isDragging; - private boolean isAnimating; private int extraPaddingTop; private int extraPaddingBottom; private double projectionX; @@ -51,6 +45,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu private int maximumFlingVelocity; private boolean isLockedToBottomEnd; private Interpolator interpolator; + private Corner currentCornerPosition = Corner.BOTTOM_RIGHT; @SuppressLint("ClickableViewAccessibility") 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.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height); this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity(); - this.runAfterFling = new LinkedList<>(); this.interpolator = ADJUST_INTERPOLATOR; } @@ -122,10 +116,18 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu public void setTopVerticalBoundary(int topBoundary) { extraPaddingTop = topBoundary - parent.getTop(); + + ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams(); + layoutParams.setMargins(layoutParams.leftMargin, extraPaddingTop + framePadding, layoutParams.rightMargin, layoutParams.bottomMargin); + child.setLayoutParams(layoutParams); } public void setBottomVerticalBoundary(int 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) { @@ -139,43 +141,20 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu 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() { isLockedToBottomEnd = true; + fling(); } public void enableCorners() { isLockedToBottomEnd = false; } - public void performAfterFling(@NonNull Runnable runnable) { - if (isAnimating) { - runAfterFling.add(runnable); - } else { - runnable.run(); - } - } - @Override public boolean onDown(MotionEvent e) { activePointerId = e.getPointerId(0); lastTouchX = e.getX(0) + child.getX(); lastTouchY = e.getY(0) + child.getY(); - isDragging = true; pipWidth = child.getMeasuredWidth(); pipHeight = child.getMeasuredHeight(); interpolator = FLING_INTERPOLATOR; @@ -185,6 +164,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (isLockedToBottomEnd) { + return false; + } + int pointerIndex = e2.findPointerIndex(activePointerId); if (pointerIndex == -1) { @@ -192,10 +175,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu return false; } - float x = e2.getX(pointerIndex) + child.getX(); - float y = e2.getY(pointerIndex) + child.getY(); - float dx = x - lastTouchX; - float dy = y - lastTouchY; + float x = e2.getX(pointerIndex) + child.getX(); + float y = e2.getY(pointerIndex) + child.getY(); + float dx = x - lastTouchX; + float dy = y - lastTouchY; child.setTranslationX(child.getTranslationX() + dx); child.setTranslationY(child.getTranslationY() + dy); @@ -208,6 +191,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (isLockedToBottomEnd) { + return false; + } + if (velocityTracker != null) { velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); @@ -225,92 +212,75 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu @Override public boolean onSingleTapUp(MotionEvent e) { - isDragging = false; - child.performClick(); return true; } private void fling() { Point projection = new Point((int) projectionX, (int) projectionY); - Point nearestCornerPosition = findNearestCornerPosition(projection); + Corner nearestCornerPosition = findNearestCornerPosition(projection); - isAnimating = true; - isDragging = false; + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams(); + layoutParams.gravity = nearestCornerPosition.gravity; + + if (currentCornerPosition != null && currentCornerPosition != nearestCornerPosition) { + adjustTranslationFrameOfReference(child, currentCornerPosition, nearestCornerPosition); + } + currentCornerPosition = nearestCornerPosition; + + child.setLayoutParams(layoutParams); child.animate() - .translationX(getTranslationXForPoint(nearestCornerPosition)) - .translationY(getTranslationYForPoint(nearestCornerPosition)) + .translationX(0) + .translationY(0) .setDuration(250) .setInterpolator(interpolator) - .setListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - isAnimating = false; - - Iterator afterFlingRunnables = runAfterFling.iterator(); - while (afterFlingRunnables.hasNext()) { - Runnable runnable = afterFlingRunnables.next(); - - runnable.run(); - afterFlingRunnables.remove(); - } - } - }) .start(); } - private Point findNearestCornerPosition(Point projection) { + private Corner findNearestCornerPosition(Point projection) { if (isLockedToBottomEnd) { - return ViewUtil.isLtr(parent) ? calculateBottomRightCoordinates(parent) - : calculateBottomLeftCoordinates(parent); + return ViewUtil.isLtr(parent) ? Corner.BOTTOM_RIGHT + : Corner.BOTTOM_LEFT; } - Point maxPoint = null; - double maxDistance = Double.MAX_VALUE; + CornerPoint maxPoint = null; + double maxDistance = Double.MAX_VALUE; - for (Point point : Arrays.asList(calculateTopLeftCoordinates(), - calculateTopRightCoordinates(parent), - calculateBottomLeftCoordinates(parent), - calculateBottomRightCoordinates(parent))) - { - double distance = distance(point, projection); + for (CornerPoint cornerPoint : Arrays.asList(calculateTopLeftCoordinates(), + calculateTopRightCoordinates(parent), + calculateBottomLeftCoordinates(parent), + calculateBottomRightCoordinates(parent))) { + double distance = distance(cornerPoint.point, projection); if (distance < maxDistance) { maxDistance = distance; - maxPoint = point; + maxPoint = cornerPoint; } } - return maxPoint; + //noinspection DataFlowIssue + return maxPoint.corner; } - private float getTranslationXForPoint(Point destination) { - return destination.x - child.getLeft(); + private CornerPoint calculateTopLeftCoordinates() { + return new CornerPoint(new Point(framePadding, framePadding + extraPaddingTop), + Corner.TOP_LEFT); } - private float getTranslationYForPoint(Point destination) { - return destination.y - child.getTop(); + private CornerPoint calculateTopRightCoordinates(@NonNull ViewGroup parent) { + return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, framePadding + extraPaddingTop), + Corner.TOP_RIGHT); } - private Point calculateTopLeftCoordinates() { - return new Point(framePadding, - framePadding + extraPaddingTop); + private CornerPoint calculateBottomLeftCoordinates(@NonNull ViewGroup parent) { + return new CornerPoint(new Point(framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom), + Corner.BOTTOM_LEFT); } - private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) { - return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, - framePadding + extraPaddingTop); - } - - 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 CornerPoint calculateBottomRightCoordinates(@NonNull ViewGroup parent) { + return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom), + Corner.BOTTOM_RIGHT); } private static float project(float initialVelocity) { @@ -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)); } - /** 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 { - /** 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_NORMALIZE; @@ -340,10 +381,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu private static float viscousFluid(float x) { x *= VISCOUS_FLUID_SCALE; if (x < 1.0f) { - x -= (1.0f - (float)Math.exp(-x)); + x -= (1.0f - (float) Math.exp(-x)); } else { float start = 0.36787944117f; // 1/e == exp(-1) - x = 1.0f - (float)Math.exp(1.0f - x); + x = 1.0f - (float) Math.exp(1.0f - x); x = start + x * (1.0f - start); } return x; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index dd1060be18..765ab0a43f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -1,16 +1,19 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.webrtc; import android.content.Context; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.Point; -import android.graphics.Rect; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; -import android.view.animation.Animation; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; @@ -25,14 +28,9 @@ import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.constraintlayout.widget.Guideline; import androidx.core.util.Consumer; -import androidx.core.view.ViewKt; import androidx.core.view.WindowInsetsCompat; import androidx.recyclerview.widget.DefaultItemAnimator; 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.ViewPager2; @@ -46,19 +44,19 @@ import org.signal.core.util.SetUtil; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.animation.ResizeAnimation; import org.thoughtcrime.securesms.components.AccessibleToggleButton; 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.ProfileContactPhoto; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; -import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.CameraState; 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.ThrottledDebouncer; import org.thoughtcrime.securesms.util.ViewUtil; @@ -72,7 +70,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -public class WebRtcCallView extends ConstraintLayout { +public class WebRtcCallView extends InsetAwareConstraintLayout { 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 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 AccessibleToggleButton videoToggle; private AccessibleToggleButton micToggle; @@ -96,7 +90,6 @@ public class WebRtcCallView extends ConstraintLayout { private TextView recipientName; private TextView status; private TextView incomingRingStatus; - private ConstraintLayout parent; private ConstraintLayout participantsParent; private ControlsListener controlsListener; private RecipientId recipientId; @@ -117,23 +110,25 @@ public class WebRtcCallView extends ConstraintLayout { private Stub groupCallSpeakerHint; private Stub groupCallFullStub; private View errorButton; - private int pagerBottomMarginDp; private boolean controlsVisible = true; private Guideline showParticipantsGuideline; private Guideline topFoldGuideline; private Guideline callScreenTopFoldGuideline; private AvatarImageView largeHeaderAvatar; - private Guideline statusBarGuideline; - private Guideline navigationBarGuideline; private int navBarBottomInset; private View fullScreenShade; private Toolbar collapsedToolbar; private Toolbar headerToolbar; private Stub pendingParticipantsViewStub; private Stub callLinkWarningCard; + private RecyclerView groupReactionsFeed; + private MultiReactionBurstLayout reactionViews; + private Guideline aboveControlsGuideline; + private WebRtcCallParticipantsPagerAdapter pagerAdapter; private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; + private WebRtcReactionsRecyclerAdapter reactionsAdapter; private PictureInPictureExpansionHelper pictureInPictureExpansionHelper; private PendingParticipantsView.Listener pendingParticipantsViewListener; @@ -147,12 +142,10 @@ public class WebRtcCallView extends ConstraintLayout { private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS); private WebRtcControls controls = WebRtcControls.NONE; - private final Runnable fadeOutRunnable = () -> { - if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); - }; private CallParticipantsViewState lastState; private ContactPhoto previousLocalAvatar; + private LayoutPositions previousLayoutPositions = null; public WebRtcCallView(@NonNull Context context) { this(context, null); @@ -181,7 +174,6 @@ public class WebRtcCallView extends ConstraintLayout { recipientName = findViewById(R.id.call_screen_recipient_name); status = findViewById(R.id.call_screen_status); incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status); - parent = findViewById(R.id.call_screen); participantsParent = findViewById(R.id.call_screen_participants_parent); answer = findViewById(R.id.call_screen_answer_call); 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); callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline); 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); collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text); headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text); pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients)); callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning)); + groupReactionsFeed = findViewById(R.id.call_screen_reactions_feed); + reactionViews = findViewById(R.id.call_screen_reactions_container); + aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline); - View decline = findViewById(R.id.call_screen_decline_call); - View answerLabel = findViewById(R.id.call_screen_answer_call_label); - View declineLabel = findViewById(R.id.call_screen_decline_call_label); + View decline = findViewById(R.id.call_screen_decline_call); + View answerLabel = findViewById(R.id.call_screen_answer_call_label); + View declineLabel = findViewById(R.id.call_screen_decline_call_label); callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4))); - pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls); - recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter(); + pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls); + recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter(); + reactionsAdapter = new WebRtcReactionsRecyclerAdapter(); callParticipantsPager.setAdapter(pagerAdapter); callParticipantsRecycler.setAdapter(recyclerAdapter); + groupReactionsFeed.setAdapter(reactionsAdapter); DefaultItemAnimator animator = new DefaultItemAnimator(); animator.setSupportsChangeAnimations(false); callParticipantsRecycler.setItemAnimator(animator); + groupReactionsFeed.addItemDecoration(new WebRtcReactionsAlphaItemDecoration()); + groupReactionsFeed.setItemAnimator(new WebRtcReactionsItemAnimator()); + callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position) { @@ -287,7 +285,7 @@ public class WebRtcCallView extends ConstraintLayout { answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); - pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(); + pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(smallLocalRenderFrame); smallLocalRenderFrame.setOnClickListener(v -> { if (controlsListener != null) { @@ -360,25 +358,6 @@ public class WebRtcCallView extends ConstraintLayout { 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 public WindowInsets onApplyWindowInsets(WindowInsets insets) { navBarBottomInset = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; @@ -398,19 +377,11 @@ public class WebRtcCallView extends ConstraintLayout { pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getTop()); } else { pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getBottom()); - pictureInPictureGestureHelper.setBottomVerticalBoundary(videoToggle.getTop()); + pictureInPictureGestureHelper.setBottomVerticalBoundary(findViewById(R.id.call_controls_info_parent).getTop()); } } else { pictureInPictureGestureHelper.clearVerticalBoundaries(); } - - pictureInPictureGestureHelper.adjustPip(); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - cancelFadeOut(); } public void rotateControls(int degrees) { @@ -489,21 +460,23 @@ public class WebRtcCallView extends ConstraintLayout { pagerAdapter.submitList(pages); recyclerAdapter.submitList(state.getListParticipants()); + reactionsAdapter.submitList(state.getReactions()); + + reactionViews.displayReactions(state.getReactions()); 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()) { - layoutParticipantsForLargeCount(); + adjustLayoutForLargeCount(); } else { - layoutParticipantsForSmallCount(); + adjustLayoutForSmallCount(); } } public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant, - @NonNull CallParticipant focusedParticipant, boolean displaySmallSelfPipInLandscape) { largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); @@ -520,17 +493,19 @@ public class WebRtcCallView extends ConstraintLayout { smallLocalRender.setRenderInPip(true); if (state == WebRtcLocalRenderState.EXPANDED) { - expandPip(localCallParticipant, focusedParticipant); - smallLocalRender.setCallParticipant(focusedParticipant); + pictureInPictureExpansionHelper.expand(); return; - } else if ((state == WebRtcLocalRenderState.SMALL_RECTANGLE || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) { - shrinkPip(localCallParticipant); - return; - } else { - smallLocalRender.setCallParticipant(localCallParticipant); - smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + } else if ((state.isAnySmall() || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) { + pictureInPictureExpansionHelper.shrink(); + + if (state != WebRtcLocalRenderState.GONE) { + return; + } } + smallLocalRender.setCallParticipant(localCallParticipant); + smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + switch (state) { case GONE: largeLocalRender.attachBroadcastVideoSink(null); @@ -674,7 +649,7 @@ public class WebRtcCallView extends ConstraintLayout { topFoldGuideline.setGuidelineEnd(webRtcControls.getFold()); callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold()); } else { - showParticipantsGuideline.setGuidelineBegin(((LayoutParams) statusBarGuideline.getLayoutParams()).guideBegin); + showParticipantsGuideline.setGuidelineBegin(((LayoutParams) getStatusBarGuideline().getLayoutParams()).guideBegin); showParticipantsGuideline.setGuidelineEnd(-1); topFoldGuideline.setGuidelineEnd(0); callScreenTopFoldGuideline.setGuidelineEnd(0); @@ -774,21 +749,9 @@ public class WebRtcCallView extends ConstraintLayout { visibleViewSet.add(ringToggle); } - - if (webRtcControls.isFadeOutEnabled()) { - if (!controls.isFadeOutEnabled()) { - scheduleFadeOut(); - } - } else { - cancelFadeOut(); - - if (controlsListener != null) { - controlsListener.showSystemUI(); - } - } - - if (webRtcControls.adjustForFold() && webRtcControls.isFadeOutEnabled() && !controls.adjustForFold()) { - scheduleFadeOut(); + if (webRtcControls.displayReactions()) { + visibleViewSet.add(reactionViews); + visibleViewSet.add(groupReactionsFeed); } boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold(); @@ -806,11 +769,6 @@ public class WebRtcCallView extends ConstraintLayout { (!webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.GONE) || forceUpdate) { - - if (controlsListener != null) { - controlsListener.showSystemUI(); - } - 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) { final Point dimens; if (isLandscape) { @@ -895,167 +797,76 @@ public class WebRtcCallView extends ConstraintLayout { dimens = new Point(ViewUtil.dpToPx(90), ViewUtil.dpToPx(160)); } - SimpleAnimationListener animationListener = new SimpleAnimationListener() { + pictureInPictureExpansionHelper.setDefaultSize(dimens, new PictureInPictureExpansionHelper.Callback() { @Override - public void onAnimationEnd(Animation animation) { + public void onAnimationHasFinished() { 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() { - pictureInPictureGestureHelper.lockToBottomEnd(); - - pictureInPictureGestureHelper.performAfterFling(() -> { - ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)); - animation.setDuration(PIP_RESIZE_DURATION); - animation.setAnimationListener(new SimpleAnimationListener() { - @Override - public void onAnimationEnd(Animation animation) { - pictureInPictureGestureHelper.adjustPip(); - } - }); - - smallLocalRenderFrame.startAnimation(animation); + pictureInPictureExpansionHelper.setDefaultSize(new Point(ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)), new PictureInPictureExpansionHelper.Callback() { + @Override + public void onAnimationHasFinished() { + pictureInPictureGestureHelper.lockToBottomEnd(); + } }); } private void toggleControls() { - if (controls.isFadeOutEnabled() && largeHeader.getVisibility() == VISIBLE) { - fadeOutControls(); - } else { - fadeInControls(); - } + controlsListener.toggleControls(); } - private void fadeOutControls() { - fadeControls(ConstraintSet.GONE); - controlsListener.onControlsFadeOut(); + private void adjustLayoutForSmallCount() { + adjustLayoutPositions(LayoutPositions.SMALL_GROUP); } - private void fadeInControls() { - fadeControls(ConstraintSet.VISIBLE); - - scheduleFadeOut(); + private void adjustLayoutForLargeCount() { + adjustLayoutPositions(LayoutPositions.LARGE_GROUP); } - private void layoutParticipantsForSmallCount() { - pagerBottomMarginDp = 0; - - 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) { + private void adjustLayoutPositions(@NonNull LayoutPositions layoutPositions) { + if (previousLayoutPositions == layoutPositions) { return; } - Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS); - - TransitionManager.beginDelayedTransition(participantsParent, transition); + previousLayoutPositions = layoutPositions; ConstraintSet constraintSet = new ConstraintSet(); - constraintSet.clone(participantsParent); + constraintSet.clone(this); - constraintSet.setMargin(R.id.call_screen_participants_pager, ConstraintSet.BOTTOM, desiredMargin); - constraintSet.applyTo(participantsParent); - } + constraintSet.connect(R.id.call_screen_participants_parent, + ConstraintSet.BOTTOM, + layoutPositions.participantBottomViewId, + layoutPositions.participantBottomViewEndSide, + ViewUtil.dpToPx(layoutPositions.participantBottomMargin)); - private void fadeControls(int visibility) { - controlsVisible = visibility == VISIBLE; + constraintSet.connect(R.id.call_screen_reactions_feed, + ConstraintSet.BOTTOM, + layoutPositions.reactionBottomViewId, + ConstraintSet.TOP, + ViewUtil.dpToPx(layoutPositions.reactionBottomMargin)); - Transition transition = new AutoTransition().setOrdering(TransitionSet.ORDERING_TOGETHER) - .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 controlsToFade() { - if (controls.adjustForFold()) { - return Sets.intersection(topViews, visibleViewSet); - } else { - return visibleViewSet; - } + constraintSet.applyTo(this); } 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)) { - constraintSet.setVisibility(view.getId(), ConstraintSet.GONE); + view.setVisibility(GONE); } for (View view : visibleViewSet) { - constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE); + view.setVisibility(VISIBLE); if (adjustableMarginsSet.contains(view)) { - constraintSet.setMargin(view.getId(), - ConstraintSet.END, - ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP - : LARGE_ONGOING_CALL_BUTTON_MARGIN_DP)); + MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams(); + params.setMarginEnd(ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP + : LARGE_ONGOING_CALL_BUTTON_MARGIN_DP)); + view.setLayoutParams(params); } } - adjustParticipantsRecycler(constraintSet); - - constraintSet.applyTo(parent); - if (showSmallHeader) { collapsedToolbar.setEnabled(true); 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 void runIfNonNull(@Nullable T listener, @NonNull Consumer listenerConsumer) { if (listener != null) { listenerConsumer.accept(listener); @@ -1133,10 +922,15 @@ public class WebRtcCallView extends ConstraintLayout { ringToggle.setActivated(enabled); } + public void onControlTopChanged(int top) { + pictureInPictureGestureHelper.setBottomVerticalBoundary(top); + + aboveControlsGuideline.setGuidelineBegin(top); + } + public interface ControlsListener { void onStartCall(boolean isVideoCall); void onCancelStartCall(); - void onControlsFadeOut(); void showSystemUI(); void hideSystemUI(); void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); @@ -1154,5 +948,6 @@ public class WebRtcCallView extends ConstraintLayout { void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed); void onCallInfoClicked(); void onNavigateUpClicked(); + void toggleControls(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index da5438e5ca..d377c69ca5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -253,10 +253,6 @@ public class WebRtcCallViewModel extends ViewModel { public void onLocalPictureInPictureClicked() { CallParticipantsState state = participantsState.getValue(); - if (state.getGroupCallState() != WebRtcViewModel.GroupCallState.IDLE) { - return; - } - participantsState.onNext(CallParticipantsState.setExpanded(participantsState.getValue(), state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java index 16e97ef81b..4f8d7eb674 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java @@ -100,23 +100,28 @@ public final class WebRtcControls { 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(); } - boolean displayStartCallControls() { + public boolean displayStartCallControls() { return isPreJoin(); } - boolean adjustForFold() { + public boolean adjustForFold() { return foldableState.isFolded(); } - @Px int getFold() { + public @Px int getFold() { return foldableState.getFoldPoint(); } - @StringRes int getStartCallButtonText() { + public @StringRes int getStartCallButtonText() { if (isGroupCall()) { if (groupCallState == GroupCallState.FULL) { return R.string.WebRtcCallView__call_is_full; @@ -127,86 +132,90 @@ public final class WebRtcControls { return R.string.WebRtcCallView__start_call; } - boolean isStartCallEnabled() { + public boolean isStartCallEnabled() { return groupCallState != GroupCallState.FULL; } - boolean displayGroupCallFull() { + public boolean displayGroupCallFull() { return groupCallState == GroupCallState.FULL; } - @NonNull String getGroupCallFullMessage(@NonNull Context context) { + public @NonNull String getGroupCallFullMessage(@NonNull Context context) { if (participantLimit != null) { return context.getString(R.string.WebRtcCallView__the_maximum_number_of_d_participants_has_been_Reached_for_this_call, participantLimit); } return ""; } - boolean displayGroupMembersButton() { + public boolean displayGroupMembersButton() { return (groupCallState.isAtLeast(GroupCallState.CONNECTING) && hasAtLeastOneRemote) || groupCallState.isAtLeast(GroupCallState.FULL); } - boolean displayEndCall() { + public boolean displayEndCall() { return isAtLeastOutgoing() || callState == CallState.RECONNECTING; } - boolean displayMuteAudio() { + public boolean displayMuteAudio() { return isPreJoin() || isAtLeastOutgoing(); } - boolean displayVideoToggle() { + public boolean displayVideoToggle() { return isPreJoin() || isAtLeastOutgoing(); } - boolean displayAudioToggle() { + public boolean displayAudioToggle() { return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothHeadsetAvailableForAudioToggle() || isWiredHeadsetAvailableForAudioToggle()); } - boolean displayCameraToggle() { + public boolean displayCameraToggle() { return (isPreJoin() || isAtLeastOutgoing()) && isLocalVideoEnabled && isMoreThanOneCameraAvailable; } - boolean displayRemoteVideoRecycler() { + public boolean displayRemoteVideoRecycler() { return isOngoing(); } - boolean displayAnswerWithoutVideo() { + public boolean displayAnswerWithoutVideo() { return isIncoming() && isRemoteVideoEnabled; } - boolean displayIncomingCallButtons() { + public boolean displayIncomingCallButtons() { return isIncoming(); } - boolean isEarpieceAvailableForAudioToggle() { + public boolean isEarpieceAvailableForAudioToggle() { return !isLocalVideoEnabled; } - boolean isBluetoothHeadsetAvailableForAudioToggle() { + public boolean isBluetoothHeadsetAvailableForAudioToggle() { return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH); } - boolean isWiredHeadsetAvailableForAudioToggle() { + public boolean isWiredHeadsetAvailableForAudioToggle() { return availableDevices.contains(SignalAudioManager.AudioDevice.WIRED_HEADSET); } - boolean isFadeOutEnabled() { + public boolean isFadeOutEnabled() { return isAtLeastOutgoing() && isRemoteVideoEnabled && callState != CallState.RECONNECTING; } - boolean displaySmallOngoingCallButtons() { + public boolean displaySmallOngoingCallButtons() { return isAtLeastOutgoing() && displayAudioToggle() && displayCameraToggle(); } - boolean displayLargeOngoingCallButtons() { + public boolean displayLargeOngoingCallButtons() { return isAtLeastOutgoing() && !(displayAudioToggle() && displayCameraToggle()); } - boolean displayTopViews() { + public boolean displayTopViews() { return !isInPipMode; } - @NonNull WebRtcAudioOutput getAudioOutput() { + public boolean displayReactions() { + return !isInPipMode; + } + + public @NonNull WebRtcAudioOutput getAudioOutput() { switch (activeDevice) { case SPEAKER_PHONE: return WebRtcAudioOutput.SPEAKER; @@ -219,15 +228,15 @@ public final class WebRtcControls { } } - boolean showSmallHeader() { + public boolean showSmallHeader() { return isAtLeastOutgoing(); } - boolean showFullScreenShade() { + public boolean showFullScreenShade() { return isPreJoin() || isIncoming(); } - boolean displayRingToggle() { + public boolean displayRingToggle() { return isPreJoin() && isGroupCall() && !isCallLink && !hasAtLeastOneRemote; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java index f23df83ec9..b2de4757ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java @@ -6,5 +6,9 @@ public enum WebRtcLocalRenderState { SMALLER_RECTANGLE, LARGE, LARGE_NO_VIDEO, - EXPANDED + EXPANDED; + + public boolean isAnySmall() { + return this == SMALL_RECTANGLE || this == SMALLER_RECTANGLE; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsAlphaItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsAlphaItemDecoration.kt new file mode 100644 index 0000000000..c6fb0f58d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsAlphaItemDecoration.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsItemAnimator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsItemAnimator.kt new file mode 100644 index 0000000000..d366c96dff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsItemAnimator.kt @@ -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 = mutableMapOf() + private val slideAnimations: MutableMap = 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 = 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsRecyclerAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsRecyclerAdapter.kt new file mode 100644 index 0000000000..9854d8f8d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcReactionsRecyclerAdapter.kt @@ -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(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?) { + 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() { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt new file mode 100644 index 0000000000..bcf62d7645 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -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 = emptyList(), + val localParticipant: CallParticipant? = null, + val groupMembers: List = emptyList(), + val callRecipient: Recipient = Recipient.UNKNOWN +) { + + val participantsForList: List = 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt new file mode 100644 index 0000000000..e0784a409f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt @@ -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 + private val callInfoComposeView: ComposeView + private val callControls: ConstraintLayout + private val bottomSheetVisibilityListeners = mutableSetOf() + 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java index d8327dd6b2..1405f173a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java @@ -16,7 +16,7 @@ public class CallParticipantsListHeader implements MappingModel Collections.singletonMap(participantId, CallParticipant.AudioLevel.fromRawAudioLevel(remoteLevel))) - .orElse(Collections.emptyMap()) + .orElse(Collections.emptyMap()), + ephemeralState.getUnexpiredReactions() ); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java index 2c2e92e980..5a7e5e74b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -14,6 +14,7 @@ import org.signal.ringrtc.GroupCall; import org.signal.ringrtc.PeekInfo; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.GroupCallReactionEvent; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.keyvalue.SignalStore; 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.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.stream.Collectors; /** * 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 @@ -197,4 +201,30 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { return terminateGroupCall(currentState); } + + @Override + protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List reactions) { + List reactionList = ephemeralState.getUnexpiredReactions(); + Map 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 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()); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 2d999d5d01..b994a1e24f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -295,6 +295,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleScreenOffChange(s)); } + public void react() { + process((s, p) -> p.handleSendGroupReact(s)); + } + public void postStateUpdate(@NonNull WebRtcServiceState state) { EventBus.getDefault().postSticky(new WebRtcViewModel(state)); } @@ -898,7 +902,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. @Override public void onReactions(@NonNull GroupCall groupCall, List reactions) { - // TODO: Implement handling of reactions. + if (FeatureFlags.groupCallReactions()) { + processStateless(s -> serviceState.getActionProcessor().handleGroupCallReaction(serviceState, s, reactions)); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index 8c5b5f051c..381d52c805 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -546,6 +546,11 @@ public abstract class WebRtcActionProcessor { 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) { Log.i(tag, "handleCameraSwitchCompleted not processed"); return currentState; @@ -729,6 +734,11 @@ public abstract class WebRtcActionProcessor { return currentState; } + protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List reactions) { + Log.i(tag, "handleGroupCallReaction not processed"); + return ephemeralState; + } + protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) { Log.i(tag, "handleGroupRequestMembershipProof not processed"); return currentState; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt index a0a9da3f13..f1ce6f9ed8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt @@ -2,11 +2,18 @@ package org.thoughtcrime.securesms.service.webrtc.state import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipantId +import org.thoughtcrime.securesms.events.GroupCallReactionEvent /** * The state of the call system which contains data which changes frequently. */ data class WebRtcEphemeralState( val localAudioLevel: CallParticipant.AudioLevel = CallParticipant.AudioLevel.LOWEST, - val remoteAudioLevels: Map = emptyMap() -) + val remoteAudioLevels: Map = emptyMap(), + private val reactions: List = emptyList() +) { + + fun getUnexpiredReactions(): List { + return reactions.filter { System.currentTimeMillis() < it.getExpirationTimestamp() } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/reaction/MultiReactionBurstLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/reaction/MultiReactionBurstLayout.kt new file mode 100644 index 0000000000..0d1432f71b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/reaction/MultiReactionBurstLayout.kt @@ -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() + + private var nextViewIndex = 0 + + init { + repeat(MAX_SIMULTANEOUS_REACTIONS) { + addView(OnReactionSentView(context)) + } + } + + fun displayReactions(reactions: List) { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 13129279a5..51291686c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -116,6 +116,7 @@ public final class FeatureFlags { private static final String IDEAL_DONATIONS = "android.ideal.donations.5"; public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions"; 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 @@ -183,7 +184,8 @@ public final class FeatureFlags { SEPA_DEBIT_DONATIONS, IDEAL_DONATIONS, IDEAL_ENABLED_REGIONS, - SEPA_ENABLED_REGIONS + SEPA_ENABLED_REGIONS, + CALLING_REACTIONS ); @VisibleForTesting @@ -253,7 +255,8 @@ public final class FeatureFlags { PROMPT_BATTERY_SAVER, USERNAMES, CRASH_PROMPT_CONFIG, - BLOCK_SSE + BLOCK_SSE, + CALLING_REACTIONS ); /** @@ -659,6 +662,13 @@ public final class FeatureFlags { 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. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable/symbol_mic_slash_24.xml b/app/src/main/res/drawable/symbol_mic_slash_24.xml new file mode 100644 index 0000000000..af31e172fa --- /dev/null +++ b/app/src/main/res/drawable/symbol_mic_slash_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/symbol_video_slash_24.xml b/app/src/main/res/drawable/symbol_video_slash_24.xml new file mode 100644 index 0000000000..649abb476d --- /dev/null +++ b/app/src/main/res/drawable/symbol_video_slash_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/webrtc_call_controls.xml b/app/src/main/res/layout/webrtc_call_controls.xml new file mode 100644 index 0000000000..6c1a185c48 --- /dev/null +++ b/app/src/main/res/layout/webrtc_call_controls.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/webrtc_call_reaction_recycler_item.xml b/app/src/main/res/layout/webrtc_call_reaction_recycler_item.xml new file mode 100644 index 0000000000..4911401a02 --- /dev/null +++ b/app/src/main/res/layout/webrtc_call_reaction_recycler_item.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_view.xml b/app/src/main/res/layout/webrtc_call_view.xml index 0b6905a0b1..960fa58f91 100644 --- a/app/src/main/res/layout/webrtc_call_view.xml +++ b/app/src/main/res/layout/webrtc_call_view.xml @@ -5,6 +5,8 @@ tools:parentTag="org.thoughtcrime.securesms.components.webrtc.WebRtcCallView" tools:viewBindingIgnore="true"> + + + + @@ -86,10 +95,24 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + - - - - @@ -214,7 +223,7 @@ android:layout_width="match_parent" android:layout_height="@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:navigationIcon="@drawable/ic_arrow_left_24" app:subtitleTextAppearance="@style/Signal.Text.BodyMedium" @@ -228,107 +237,7 @@ android:layout_marginEnd="64dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" /> - - - - - - - - - - - - + app:layout_constraintTop_toTopOf="@id/status_bar_guideline" /> - - - - - + @@ -505,5 +396,5 @@ 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" /> - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cd330ec3d..d8a72831bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1848,9 +1848,17 @@ An icon representing a device\'s earpiece. - - In this call · %1$d person - In this call · %1$d people + + In this call (%1$d) + In this call (%1$d) + + + Signal will Ring (%1$d) + Signal will Ring (%1$d) + + + Signal will Notify (%1$d) + Signal will Notify (%1$d)