diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt index 951fda3903..d0ef2df193 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt @@ -15,6 +15,7 @@ import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.webrtc.v2.PendingParticipantsListener import org.thoughtcrime.securesms.fonts.SignalSymbols import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection @@ -32,7 +33,7 @@ class PendingParticipantsView @JvmOverloads constructor( inflate(context, R.layout.pending_participant_view, this) } - var listener: Listener? = null + var listener: PendingParticipantsListener? = null private val avatar: AvatarImageView = findViewById(R.id.pending_participants_avatar) private val name: TextView = findViewById(R.id.pending_participants_name) @@ -81,26 +82,4 @@ class PendingParticipantsView @JvmOverloads constructor( visible = true } - - interface Listener { - /** - * Display the sheet containing the request for the top level participant - */ - fun onLaunchRecipientSheet(pendingRecipient: Recipient) - - /** - * Given recipient should be admitted to the call - */ - fun onAllowPendingRecipient(pendingRecipient: Recipient) - - /** - * Given recipient should be rejected from the call - */ - fun onRejectPendingRecipient(pendingRecipient: Recipient) - - /** - * Display the sheet containing all of the requests for the given call - */ - fun onLaunchPendingRequestsSheet() - } } 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 c8a029402b..2ad82b4117 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 @@ -22,7 +22,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.Toolbar; @@ -51,6 +50,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AccessibleToggleButton; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout; +import org.thoughtcrime.securesms.components.webrtc.v2.CallScreenControlsListener; +import org.thoughtcrime.securesms.components.webrtc.v2.PendingParticipantsListener; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.events.CallParticipant; @@ -60,7 +61,7 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.CameraState; -import org.thoughtcrime.securesms.service.webrtc.state.PendingParticipantsState; +import org.thoughtcrime.securesms.components.webrtc.v2.PendingParticipantsState; import org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout; import org.thoughtcrime.securesms.util.BlurTransformation; import org.thoughtcrime.securesms.util.ThrottledDebouncer; @@ -94,7 +95,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { private TextView recipientName; private TextView status; private TextView incomingRingStatus; - private ControlsListener controlsListener; + private CallScreenControlsListener controlsListener; private RecipientId recipientId; private ImageView answer; private TextView answerWithoutVideoLabel; @@ -137,7 +138,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; private WebRtcReactionsRecyclerAdapter reactionsAdapter; private PictureInPictureExpansionHelper pictureInPictureExpansionHelper; - private PendingParticipantsView.Listener pendingParticipantsViewListener; + private PendingParticipantsListener pendingParticipantsViewListener; private final Set incomingCallViews = new HashSet<>(); private final Set topViews = new HashSet<>(); @@ -284,18 +285,18 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated())); }); - cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged)); - smallLocalRender.findViewById(R.id.call_participant_switch_camera).setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged)); + cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onCameraDirectionChanged)); + smallLocalRender.findViewById(R.id.call_participant_switch_camera).setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onCameraDirectionChanged)); overflow.setOnClickListener(v -> { - runIfNonNull(controlsListener, ControlsListener::onOverflowClicked); + runIfNonNull(controlsListener, CallScreenControlsListener::onOverflowClicked); }); - hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed)); - decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed)); + hangup.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onEndCallPressed)); + decline.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onDenyCallPressed)); - answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed)); - answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); + answer.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onAcceptCallPressed)); + answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, CallScreenControlsListener::onAcceptCallWithVoiceOnlyPressed)); pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(smallLocalRenderFrame, state -> { @@ -430,7 +431,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { } } - public void setControlsListener(@Nullable ControlsListener controlsListener) { + public void setControlsListener(@Nullable CallScreenControlsListener controlsListener) { this.controlsListener = controlsListener; } @@ -442,7 +443,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { micToggle.setChecked(hasAudioPermission() && isMicEnabled, false); } - public void setPendingParticipantsViewListener(@Nullable PendingParticipantsView.Listener listener) { + public void setPendingParticipantsViewListener(@Nullable PendingParticipantsListener listener) { pendingParticipantsViewListener = listener; } @@ -647,52 +648,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { } } - private void setStatus(@StringRes int statusRes) { - setStatus(getContext().getString(statusRes)); - } - - public void setStatusFromHangupType(@NonNull HangupMessage.Type hangupType) { - switch (hangupType) { - case NORMAL: - case NEED_PERMISSION: - setStatus(R.string.RedPhone_ending_call); - break; - case ACCEPTED: - setStatus(R.string.WebRtcCallActivity__answered_on_a_linked_device); - break; - case DECLINED: - setStatus(R.string.WebRtcCallActivity__declined_on_a_linked_device); - break; - case BUSY: - setStatus(R.string.WebRtcCallActivity__busy_on_a_linked_device); - break; - default: - throw new IllegalStateException("Unknown hangup type: " + hangupType); - } - } - - public void setStatusFromGroupCallState(@NonNull WebRtcViewModel.GroupCallState groupCallState) { - switch (groupCallState) { - case DISCONNECTED: - setStatus(R.string.WebRtcCallView__disconnected); - break; - case RECONNECTING: - setStatus(R.string.WebRtcCallView__reconnecting); - break; - case CONNECTED_AND_JOINING: - setStatus(R.string.WebRtcCallView__joining); - break; - case CONNECTED_AND_PENDING: - setStatus(R.string.WebRtcCallView__waiting_to_be_let_in); - break; - case CONNECTING: - case CONNECTED_AND_JOINED: - case CONNECTED: - setStatus(""); - break; - } - } - public void setWebRtcControls(@NonNull WebRtcControls webRtcControls) { Set lastVisibleSet = new HashSet<>(visibleViewSet); @@ -978,27 +933,4 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { public void onControlTopChanged() { } - - public interface ControlsListener { - void onStartCall(boolean isVideoCall); - void onCancelStartCall(); - void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); - @RequiresApi(31) - void onAudioOutputChanged31(@NonNull WebRtcAudioDevice audioOutput); - void onVideoChanged(boolean isVideoEnabled); - void onMicChanged(boolean isMicEnabled); - void onOverflowClicked(); - void onCameraDirectionChanged(); - void onEndCallPressed(); - void onDenyCallPressed(); - void onAcceptCallWithVoiceOnlyPressed(); - void onAcceptCallPressed(); - void onPageChanged(@NonNull CallParticipantsState.SelectedPage page); - void onLocalPictureInPictureClicked(); - void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed); - void onCallInfoClicked(); - void onNavigateUpClicked(); - void toggleControls(); - void onAudioPermissionsRequested(Runnable onGranted); - } } 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 5a0b05e0c3..a021e00bc1 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 @@ -130,7 +130,7 @@ public final class WebRtcControls { public @Px int getFold() { return foldableState.getFoldPoint(); } - + public @StringRes int getStartCallButtonText() { if (isGroupCall()) { if (groupCallState == GroupCallState.FULL) { 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 index 425e882f0d..1b1d295e9c 100644 --- 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 @@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView import org.thoughtcrime.securesms.components.webrtc.WebRtcControls +import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsVisibilityListener import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult @@ -125,7 +126,7 @@ class ControlsAndInfoController private constructor( } private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() } - private val bottomSheetVisibilityListeners = mutableSetOf() + private val callControlsVisibilityListeners = mutableSetOf() private val handler: Handler? get() = webRtcCallView.handler @@ -246,8 +247,8 @@ class ControlsAndInfoController private constructor( callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE } - fun addVisibilityListener(listener: BottomSheetVisibilityListener): Boolean { - return bottomSheetVisibilityListeners.add(listener) + fun addVisibilityListener(listener: CallControlsVisibilityListener): Boolean { + return callControlsVisibilityListeners.add(listener) } fun onStateRestored() { @@ -279,7 +280,7 @@ class ControlsAndInfoController private constructor( behavior.isHideable = false behavior.state = BottomSheetBehavior.STATE_COLLAPSED - bottomSheetVisibilityListeners.forEach { it.onShown() } + callControlsVisibilityListeners.forEach { it.onShown() } } private fun hide(delay: Long = 0L) { @@ -288,7 +289,7 @@ class ControlsAndInfoController private constructor( behavior.isHideable = true behavior.state = BottomSheetBehavior.STATE_HIDDEN - bottomSheetVisibilityListeners.forEach { it.onHidden() } + callControlsVisibilityListeners.forEach { it.onHidden() } } } else { cancelScheduledHide() @@ -490,9 +491,4 @@ class ControlsAndInfoController private constructor( return controlHeight != this.controlHeight || coordinatorHeight != this.coordinatorHeight } } - - interface BottomSheetVisibilityListener { - fun onShown() - fun onHidden() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index 942ea732d6..9745cd4688 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import android.Manifest import android.content.pm.PackageManager import android.content.res.Configuration +import android.os.Build import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,7 +48,8 @@ import org.thoughtcrime.securesms.util.RemoteConfig fun CallControls( displayVideoTooltip: Boolean, callControlsState: CallControlsState, - callControlsCallback: CallControlsCallback, + callScreenControlsListener: CallScreenControlsListener, + callScreenSheetDisplayListener: CallScreenSheetDisplayListener, modifier: Modifier = Modifier ) { val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT @@ -87,11 +89,21 @@ fun CallControls( } } + val onSelectedAudioDeviceChanged: (WebRtcAudioDevice) -> Unit = remember { + { + if (Build.VERSION.SDK_INT >= 31) { + callScreenControlsListener.onAudioOutputChanged31(it) + } else { + callScreenControlsListener.onAudioOutputChanged(it.webRtcAudioOutput) + } + } + } + CallAudioToggleButton( outputState = outputState, contentDescription = stringResource(id = R.string.WebRtcAudioOutputToggle__audio_output), - onSelectedDeviceChanged = callControlsCallback::onSelectedAudioDeviceChanged, - onSheetDisplayChanged = callControlsCallback::onAudioDeviceSheetDisplayChanged + onSelectedDeviceChanged = onSelectedAudioDeviceChanged, + onSheetDisplayChanged = callScreenSheetDisplayListener::onAudioDeviceSheetDisplayChanged ) } @@ -100,11 +112,11 @@ fun CallControls( CallScreenTooltipBox( text = stringResource(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video), displayTooltip = displayVideoTooltip, - onTooltipDismissed = callControlsCallback::onVideoTooltipDismissed + onTooltipDismissed = callScreenSheetDisplayListener::onVideoTooltipDismissed ) { ToggleVideoButton( isVideoEnabled = callControlsState.isVideoEnabled && hasCameraPermission, - onChange = callControlsCallback::onVideoToggleClick + onChange = callScreenControlsListener::onVideoChanged ) } } @@ -113,7 +125,7 @@ fun CallControls( if (callControlsState.displayMicToggle) { ToggleMicButton( isMicEnabled = callControlsState.isMicEnabled && hasRecordAudioPermission, - onChange = callControlsCallback::onMicToggleClick + onChange = callScreenControlsListener::onMicChanged ) } @@ -121,22 +133,24 @@ fun CallControls( ToggleRingButton( isRingEnabled = callControlsState.isGroupRingingEnabled, isRingAllowed = callControlsState.isGroupRingingAllowed, - onChange = callControlsCallback::onGroupRingingToggleClick + onChange = callScreenControlsListener::onRingGroupChanged ) } if (callControlsState.displayAdditionalActions) { - AdditionalActionsButton(onClick = callControlsCallback::onAdditionalActionsClick) + AdditionalActionsButton(onClick = callScreenControlsListener::onOverflowClicked) } if (callControlsState.displayEndCallButton) { - HangupButton(onClick = callControlsCallback::onEndCallClick) + HangupButton(onClick = callScreenControlsListener::onEndCallPressed) } if (callControlsState.displayStartCallButton && !isPortrait) { StartCallButton( text = stringResource(callControlsState.startCallButtonText), - onClick = { callControlsCallback.onStartCallClick(callControlsState.isVideoEnabled) } + onClick = { + callScreenControlsListener.onStartCall(callControlsState.isVideoEnabled) + } ) } } @@ -144,7 +158,9 @@ fun CallControls( if (callControlsState.displayStartCallButton && isPortrait) { StartCallButton( text = stringResource(callControlsState.startCallButtonText), - onClick = { callControlsCallback.onStartCallClick(callControlsState.isVideoEnabled) } + onClick = { + callScreenControlsListener.onStartCall(callControlsState.isVideoEnabled) + } ) } } @@ -170,7 +186,8 @@ fun CallControlsPreview() { displayEndCallButton = true ), displayVideoTooltip = false, - callControlsCallback = CallControlsCallback.Empty + callScreenControlsListener = CallScreenControlsListener.Empty, + callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty ) } } @@ -178,26 +195,12 @@ fun CallControlsPreview() { /** * Callbacks for call controls actions. */ -interface CallControlsCallback { +interface CallScreenSheetDisplayListener { fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) - fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) - fun onVideoToggleClick(enabled: Boolean) - fun onMicToggleClick(enabled: Boolean) - fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) - fun onAdditionalActionsClick() - fun onStartCallClick(isVideoCall: Boolean) - fun onEndCallClick() fun onVideoTooltipDismissed() - object Empty : CallControlsCallback { + object Empty : CallScreenSheetDisplayListener { override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) = Unit - override fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) = Unit - override fun onVideoToggleClick(enabled: Boolean) = Unit - override fun onMicToggleClick(enabled: Boolean) = Unit - override fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) = Unit - override fun onAdditionalActionsClick() = Unit - override fun onStartCallClick(isVideoCall: Boolean) = Unit - override fun onEndCallClick() = Unit override fun onVideoTooltipDismissed() = Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt new file mode 100644 index 0000000000..4280d8bbd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +interface CallControlsVisibilityListener { + fun onShown() + fun onHidden() + + companion object Empty : CallControlsVisibilityListener { + override fun onShown() = Unit + override fun onHidden() = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index 0115efbd3e..523fe1b723 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -34,8 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -81,8 +79,14 @@ fun CallScreen( webRtcCallState: WebRtcViewModel.State, callScreenState: CallScreenState, callControlsState: CallControlsState, - callControlsCallback: CallControlsCallback = CallControlsCallback.Empty, + callScreenController: CallScreenController = CallScreenController.rememberCallScreenController( + skipHiddenState = callControlsState.skipHiddenState, + onControlsToggled = {} + ), + callScreenControlsListener: CallScreenControlsListener = CallScreenControlsListener.Empty, + callScreenSheetDisplayListener: CallScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty, callParticipantsPagerState: CallParticipantsPagerState, + pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty, overflowParticipants: List, localParticipant: CallParticipant, localRenderState: WebRtcLocalRenderState, @@ -98,20 +102,8 @@ fun CallScreen( mutableFloatStateOf(0f) } - val skipHiddenState by rememberUpdatedState(newValue = callControlsState.skipHiddenState) - val valueChangeOperation: (SheetValue) -> Boolean = remember { - { - !(it == SheetValue.Hidden && skipHiddenState) - } - } - + val scaffoldState = remember(callScreenController) { callScreenController.scaffoldState } val scope = rememberCoroutineScope() - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - confirmValueChange = valueChangeOperation, - skipHiddenState = false - ) - ) val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT BoxWithConstraints { @@ -123,7 +115,7 @@ fun CallScreen( var peekHeight by remember { mutableFloatStateOf(88f) } BottomSheetScaffold( - scaffoldState = scaffoldState, + scaffoldState = callScreenController.scaffoldState, sheetDragHandle = null, sheetPeekHeight = peekHeight.dp, sheetMaxWidth = 540.dp, @@ -153,7 +145,8 @@ fun CallScreen( if (callControlsAlpha > 0f) { CallControls( callControlsState = callControlsState, - callControlsCallback = callControlsCallback, + callScreenControlsListener = callScreenControlsListener, + callScreenSheetDisplayListener = callScreenSheetDisplayListener, displayVideoTooltip = callScreenState.displayVideoTooltip, modifier = Modifier .fillMaxWidth() @@ -182,7 +175,8 @@ fun CallScreen( callControlsState = callControlsState, callScreenState = callScreenState, onPipClick = onLocalPictureInPictureClicked, - onControlsToggled = onControlsToggled + onControlsToggled = onControlsToggled, + callScreenController = callScreenController ) } @@ -202,7 +196,8 @@ fun CallScreen( callControlsState = callControlsState, callScreenState = callScreenState, onPipClick = onLocalPictureInPictureClicked, - onControlsToggled = onControlsToggled + onControlsToggled = onControlsToggled, + callScreenController = callScreenController ) } } @@ -251,6 +246,17 @@ fun CallScreen( .padding(bottom = padding) .padding(bottom = 20.dp) ) + + val state = remember(callScreenState.pendingParticipantsState) { + callScreenState.pendingParticipantsState + } + + if (state != null) { + PendingParticipants( + pendingParticipantsState = state, + pendingParticipantsListener = pendingParticipantsListener + ) + } } } @@ -272,6 +278,7 @@ private fun BoxScope.Viewport( scaffoldState: BottomSheetScaffoldState, callControlsState: CallControlsState, callScreenState: CallScreenState, + callScreenController: CallScreenController, onPipClick: () -> Unit, onControlsToggled: (Boolean) -> Unit ) { @@ -288,7 +295,7 @@ private fun BoxScope.Viewport( val scope = rememberCoroutineScope() val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingAudioToggleSheet) - LaunchedEffect(hideSheet) { + LaunchedEffect(callScreenController.restartTimerRequests, hideSheet) { if (hideSheet) { delay(5.seconds) scaffoldState.bottomSheetState.hide() @@ -309,13 +316,7 @@ private fun BoxScope.Viewport( .clickable( onClick = { scope.launch { - if (scaffoldState.bottomSheetState.isVisible) { - scaffoldState.bottomSheetState.hide() - onControlsToggled(false) - } else { - onControlsToggled(true) - scaffoldState.bottomSheetState.show() - } + callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) } }, enabled = !callControlsState.skipHiddenState diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenController.kt new file mode 100644 index 0000000000..9540431a65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenController.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue + +/** + * Collects and manages state objects for manipulating the call screen UI programatically. + */ +@OptIn(ExperimentalMaterial3Api::class) +class CallScreenController private constructor( + val scaffoldState: BottomSheetScaffoldState, + val onControlsToggled: (Boolean) -> Unit +) { + + var restartTimerRequests by mutableLongStateOf(0L) + + suspend fun handleEvent(event: Event) { + when (event) { + Event.SWITCH_TO_SPEAKER_VIEW -> {} // TODO [calling-v2] + Event.DISMISS_AUDIO_PICKER -> {} // TODO [calling-v2] + Event.TOGGLE_CONTROLS -> { + if (scaffoldState.bottomSheetState.isVisible) { + scaffoldState.bottomSheetState.hide() + onControlsToggled(false) + } else { + onControlsToggled(true) + scaffoldState.bottomSheetState.show() + } + } + Event.SHOW_CALL_INFO -> { + scaffoldState.bottomSheetState.expand() + } + Event.RESTART_HIDE_CONTROLS_TIMER -> { + restartTimerRequests += 1 + } + } + } + + companion object { + @Composable + fun rememberCallScreenController(skipHiddenState: Boolean, onControlsToggled: (Boolean) -> Unit): CallScreenController { + val skip by rememberUpdatedState(skipHiddenState) + val valueChangeOperation: (SheetValue) -> Boolean = remember { + { + !(it == SheetValue.Hidden && skip) + } + } + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + confirmValueChange = valueChangeOperation, + skipHiddenState = skip + ) + ) + + return remember(scaffoldState) { + CallScreenController( + scaffoldState = scaffoldState, + onControlsToggled = onControlsToggled + ) + } + } + } + + enum class Event { + SWITCH_TO_SPEAKER_VIEW, + DISMISS_AUDIO_PICKER, + TOGGLE_CONTROLS, + SHOW_CALL_INFO, + RESTART_HIDE_CONTROLS_TIMER + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt new file mode 100644 index 0000000000..2179799782 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput + +/** + * Mediator callbacks for call screen signals. + */ +interface CallScreenControlsListener { + fun onStartCall(isVideoCall: Boolean) + fun onCancelStartCall() + fun onAudioOutputChanged(audioOutput: WebRtcAudioOutput) + + @RequiresApi(31) + fun onAudioOutputChanged31(audioOutput: WebRtcAudioDevice) + fun onVideoChanged(isVideoEnabled: Boolean) + fun onMicChanged(isMicEnabled: Boolean) + fun onOverflowClicked() + fun onCameraDirectionChanged() + fun onEndCallPressed() + fun onDenyCallPressed() + fun onAcceptCallWithVoiceOnlyPressed() + fun onAcceptCallPressed() + fun onPageChanged(page: CallParticipantsState.SelectedPage) + fun onLocalPictureInPictureClicked() + fun onRingGroupChanged(ringGroup: Boolean, ringingAllowed: Boolean) + fun onCallInfoClicked() + fun onNavigateUpClicked() + fun toggleControls() + fun onAudioPermissionsRequested(onGranted: Runnable?) + + object Empty : CallScreenControlsListener { + override fun onStartCall(isVideoCall: Boolean) = Unit + override fun onCancelStartCall() = Unit + override fun onAudioOutputChanged(audioOutput: WebRtcAudioOutput) = Unit + override fun onAudioOutputChanged31(audioOutput: WebRtcAudioDevice) = Unit + override fun onVideoChanged(isVideoEnabled: Boolean) = Unit + override fun onMicChanged(isMicEnabled: Boolean) = Unit + override fun onOverflowClicked() = Unit + override fun onCameraDirectionChanged() = Unit + override fun onEndCallPressed() = Unit + override fun onDenyCallPressed() = Unit + override fun onAcceptCallWithVoiceOnlyPressed() = Unit + override fun onAcceptCallPressed() = Unit + override fun onPageChanged(page: CallParticipantsState.SelectedPage) = Unit + override fun onLocalPictureInPictureClicked() = Unit + override fun onRingGroupChanged(ringGroup: Boolean, ringingAllowed: Boolean) = Unit + override fun onCallInfoClicked() = Unit + override fun onNavigateUpClicked() = Unit + override fun toggleControls() = Unit + override fun onAudioPermissionsRequested(onGranted: Runnable?) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMediator.kt new file mode 100644 index 0000000000..5000e302c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMediator.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.content.Context +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate +import org.thoughtcrime.securesms.components.webrtc.WebRtcControls +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState +import org.whispersystems.signalservice.api.messages.calls.HangupMessage + +/** + * Mediates between the activity and the call screen to allow for a consistent API + * regardless of View or Compose implementation. + */ +interface CallScreenMediator { + + fun setWebRtcCallState(callState: WebRtcViewModel.State) + + fun setControlsAndInfoVisibilityListener(listener: CallControlsVisibilityListener) + fun onStateRestored() + fun toggleOverflowPopup() + fun restartHideControlsTimer() + fun showCallInfo() + fun toggleControls() + + fun setControlsListener(controlsListener: CallScreenControlsListener) + fun setMicEnabled(enabled: Boolean) + fun setStatus(status: String) + fun setRecipient(recipient: Recipient) + fun setWebRtcControls(webRtcControls: WebRtcControls) + fun updateCallParticipants(callParticipantsViewState: CallParticipantsViewState) + fun maybeDismissAudioPicker() + fun setPendingParticipantsViewListener(pendingParticipantsViewListener: PendingParticipantsListener) + fun updatePendingParticipantsList(pendingParticipantsList: PendingParticipantsState) + fun setRingGroup(ringGroup: Boolean) + fun switchToSpeakerView() + fun enableRingGroup(canRing: Boolean) + fun showSpeakerViewHint() + fun hideSpeakerViewHint() + fun showVideoTooltip(): Dismissible + fun showCameraTooltip(): Dismissible + fun onCallStateUpdate(callControlsChange: CallControlsChange) + fun dismissCallOverflowPopup() + fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) + fun enableParticipantUpdatePopup(enabled: Boolean) + fun enableCallStateUpdatePopup(enabled: Boolean) + fun showWifiToCellularPopupWindow() + + fun setStatusFromGroupCallState(context: Context, groupCallState: WebRtcViewModel.GroupCallState) { + when (groupCallState) { + WebRtcViewModel.GroupCallState.DISCONNECTED -> setStatus(context.getString(R.string.WebRtcCallView__disconnected)) + WebRtcViewModel.GroupCallState.CONNECTING, WebRtcViewModel.GroupCallState.CONNECTED, WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED -> { + setStatus("") + } + WebRtcViewModel.GroupCallState.RECONNECTING -> setStatus(context.getString(R.string.WebRtcCallView__reconnecting)) + WebRtcViewModel.GroupCallState.CONNECTED_AND_PENDING -> setStatus(context.getString(R.string.WebRtcCallView__waiting_to_be_let_in)) + WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING -> setStatus(context.getString(R.string.WebRtcCallView__joining)) + else -> Unit + } + } + + fun setStatusFromHangupType(context: Context, hangupType: HangupMessage.Type) { + when (hangupType) { + HangupMessage.Type.NORMAL, HangupMessage.Type.NEED_PERMISSION -> setStatus(context.getString(R.string.RedPhone_ending_call)) + HangupMessage.Type.ACCEPTED -> setStatus(context.getString(R.string.WebRtcCallActivity__answered_on_a_linked_device)) + HangupMessage.Type.DECLINED -> setStatus(context.getString(R.string.WebRtcCallActivity__declined_on_a_linked_device)) + HangupMessage.Type.BUSY -> setStatus(context.getString(R.string.WebRtcCallActivity__busy_on_a_linked_device)) + } + } + + companion object { + fun create(activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel): CallScreenMediator { + return if (RemoteConfig.newCallUi || (RemoteConfig.internalUser && SignalStore.internal.newCallingUi)) { + ComposeCallScreenMediator(activity, viewModel) + } else { + ViewCallScreenMediator(activity, viewModel) + } + } + } +} + +fun interface Dismissible { + fun dismiss() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt index d88a618838..aaeb88510b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -6,33 +6,27 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import org.thoughtcrime.securesms.recipients.RecipientId -import org.whispersystems.signalservice.api.messages.calls.HangupMessage -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * This contains higher level information that would have traditionally been directly * set on views. (Statuses, popups, etc.), allowing us to manage this from CallViewModel * * @param callRecipientId The recipient ID of the call target (1:1 recipient, call link, or group) - * @param hangup Set on call termination. * @param callControlsChange Update to display in a CallStateUpdate component. * @param callStatus Status text resource to display as call status. * @param isDisplayingAudioToggleSheet Whether the audio toggle sheet is currently displayed. Displaying this sheet should suppress hiding the controls. */ data class CallScreenState( val callRecipientId: RecipientId = RecipientId.UNKNOWN, - val hangup: Hangup? = null, val callControlsChange: CallControlsChange? = null, - val callStatus: CallString? = null, + val callStatus: String? = null, val isDisplayingAudioToggleSheet: Boolean = false, val displaySwitchCameraTooltip: Boolean = false, val displayVideoTooltip: Boolean = false, val displaySwipeToSpeakerHint: Boolean = false, - val displayWifiToCellularPopup: Boolean = false -) { - data class Hangup( - val hangupMessageType: HangupMessage.Type, - val delay: Duration = 1.seconds - ) -} + val displayWifiToCellularPopup: Boolean = false, + val displayAdditionalActionsPopup: Boolean = false, + val pendingParticipantsState: PendingParticipantsState? = null, + val isParticipantUpdatePopupEnabled: Boolean = false, + val isCallStateUpdatePopupEnabled: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt index 6a249dfd5d..0641b3d01d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt @@ -43,7 +43,7 @@ import org.thoughtcrime.securesms.recipients.Recipient @Composable fun CallScreenTopBar( callRecipient: Recipient, - callStatus: CallString?, + callStatus: String?, modifier: Modifier = Modifier, onNavigationClick: () -> Unit = {}, onCallInfoClick: () -> Unit = {} @@ -70,7 +70,7 @@ fun CallScreenTopBar( @Composable fun CallScreenPreJoinOverlay( callRecipient: Recipient, - callStatus: CallString?, + callStatus: String?, isLocalVideoEnabled: Boolean, modifier: Modifier = Modifier, onNavigationClick: () -> Unit = {}, @@ -103,7 +103,7 @@ fun CallScreenPreJoinOverlay( if (callStatus != null) { Text( - text = callStatus.renderToString(), + text = callStatus, style = MaterialTheme.typography.bodyMedium, color = Color.White, modifier = Modifier.padding(top = 8.dp) @@ -136,7 +136,7 @@ fun CallScreenPreJoinOverlay( @Composable private fun CallScreenTopAppBar( callRecipient: Recipient? = null, - callStatus: CallString? = null, + callStatus: String? = null, onNavigationClick: () -> Unit = {}, onCallInfoClick: () -> Unit = {} ) { @@ -162,7 +162,7 @@ private fun CallScreenTopAppBar( if (callStatus != null) { Text( - text = callStatus.renderToString(), + text = callStatus, style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow), modifier = Modifier.padding(top = 2.dp) ) @@ -212,7 +212,7 @@ fun CallScreenPreJoinOverlayPreview() { Previews.Preview { CallScreenPreJoinOverlay( callRecipient = Recipient(systemContactName = "Test User"), - callStatus = CallString.ResourceString(R.string.Recipient_unknown), + callStatus = stringResource(R.string.Recipient_unknown), isLocalVideoEnabled = false ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt deleted file mode 100644 index 514716550e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.webrtc.v2 - -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import org.thoughtcrime.securesms.recipients.Recipient - -/** - * Objects that can be rendered into a string, including Recipient display names - * and group info. This allows us to pass these objects through the view model without - * having to pass around context. - */ -sealed interface CallString { - @Composable - fun renderToString(): String - - data class RecipientDisplayName( - val recipient: Recipient - ) : CallString { - @Composable - override fun renderToString(): String { - return recipient.getDisplayName(LocalContext.current) - } - } - - data class ResourceString( - @StringRes val resource: Int - ) : CallString { - @Composable - override fun renderToString(): String { - return stringResource(id = resource) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt new file mode 100644 index 0000000000..7ca10eb0db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -0,0 +1,302 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.WebRtcControls +import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel +import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState +import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState + +/** + * Compose call screen wrapper + */ +class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel) : CallScreenMediator { + + companion object { + private val TAG = Log.tag(ComposeCallScreenMediator::class) + } + + private val callScreenViewModel = ViewModelProvider(activity)[CallScreenViewModel::class] + private val controlsAndInfoViewModel = ViewModelProvider(activity)[ControlsAndInfoViewModel::class] + private val callInfoCallbacks = CallInfoCallbacks(activity, controlsAndInfoViewModel) + + private val controlsListener = MutableStateFlow(CallScreenControlsListener.Empty) + private val controlsVisibilityListener = MutableStateFlow(CallControlsVisibilityListener.Empty) + private val pendingParticipantsViewListener = MutableStateFlow(PendingParticipantsListener.Empty) + + init { + activity.setContent { + val recipient by viewModel.getRecipientFlow().collectAsStateWithLifecycle(Recipient.UNKNOWN) + val webRtcCallState by callScreenViewModel.callState.collectAsStateWithLifecycle() + val callScreenState by callScreenViewModel.callScreenState.collectAsStateWithLifecycle() + val callControlsState by viewModel.getCallControlsState().collectAsStateWithLifecycle(CallControlsState()) + val callParticipantsViewState by callScreenViewModel.callParticipantsViewState.collectAsStateWithLifecycle() + val callParticipantsState = remember(callParticipantsViewState) { callParticipantsViewState.callParticipantsState } + val callParticipantsPagerState = remember(callParticipantsState) { + CallParticipantsPagerState( + callParticipants = callParticipantsState.gridParticipants, + focusedParticipant = callParticipantsState.focusedParticipant, + isRenderInPip = callParticipantsState.isInPipMode, + hideAvatar = callParticipantsState.hideAvatar + ) + } + val dialog by callScreenViewModel.dialog.collectAsStateWithLifecycle(CallScreenDialogType.NONE) + val callScreenControlsListener by controlsListener.collectAsStateWithLifecycle() + val callScreenSheetDisplayListener = remember { + object : CallScreenSheetDisplayListener { + override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) { + callScreenViewModel.callScreenState.update { it.copy(isDisplayingAudioToggleSheet = displayed) } + } + + override fun onVideoTooltipDismissed() { + callScreenViewModel.callScreenState.update { it.copy(displayVideoTooltip = false) } + } + } + } + + val callControlsVisibilityListener by controlsVisibilityListener.collectAsStateWithLifecycle() + val onControlsToggled: (Boolean) -> Unit = remember(controlsVisibilityListener) { + { + if (it) { + callControlsVisibilityListener.onShown() + } else { + callControlsVisibilityListener.onHidden() + } + } + } + + val pendingParticipantsListener by this.pendingParticipantsViewListener.collectAsStateWithLifecycle() + + val callScreenController = CallScreenController.rememberCallScreenController( + skipHiddenState = callControlsState.skipHiddenState, + onControlsToggled = onControlsToggled + ) + + LaunchedEffect(callScreenController) { + callScreenViewModel.callScreenControllerEvents.collectLatest { + callScreenController.handleEvent(it) + } + } + + SignalTheme(isDarkMode = true) { + CallScreen( + callRecipient = recipient, + webRtcCallState = webRtcCallState, + callScreenState = callScreenState, + callControlsState = callControlsState, + callScreenController = callScreenController, + callScreenControlsListener = callScreenControlsListener, + callScreenSheetDisplayListener = callScreenSheetDisplayListener, + callParticipantsPagerState = callParticipantsPagerState, + pendingParticipantsListener = pendingParticipantsListener, + overflowParticipants = callParticipantsState.listParticipants, + localParticipant = callParticipantsState.localParticipant, + localRenderState = callParticipantsState.localRenderState, + callScreenDialogType = dialog, + callInfoView = { + CallInfoView.View( + webRtcCallViewModel = viewModel, + controlsAndInfoViewModel = controlsAndInfoViewModel, + callbacks = callInfoCallbacks, + modifier = Modifier + .alpha(it) + ) + }, + raiseHandSnackbar = { + RaiseHandSnackbar.View( + webRtcCallViewModel = viewModel, + showCallInfoListener = { showCallInfo() }, + modifier = it + ) + }, + onNavigationClick = { activity.onBackPressedDispatcher.onBackPressed() }, + onLocalPictureInPictureClicked = viewModel::onLocalPictureInPictureClicked, + onControlsToggled = onControlsToggled, + onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } } + ) + } + } + } + + override fun setWebRtcCallState(callState: WebRtcViewModel.State) { + callScreenViewModel.callState.update { callState } + } + + override fun setControlsAndInfoVisibilityListener(listener: CallControlsVisibilityListener) { + controlsVisibilityListener.update { listener } + } + + override fun onStateRestored() { + Log.d(TAG, "Ignoring call to onStateRestored.") + } + + override fun toggleOverflowPopup() { + callScreenViewModel.callScreenState.update { + it.copy(displayAdditionalActionsPopup = !it.displayAdditionalActionsPopup) + } + } + + override fun restartHideControlsTimer() { + callScreenViewModel.emitControllerEvent(CallScreenController.Event.RESTART_HIDE_CONTROLS_TIMER) + } + + override fun showCallInfo() { + callScreenViewModel.emitControllerEvent(CallScreenController.Event.SHOW_CALL_INFO) + } + + override fun toggleControls() { + callScreenViewModel.emitControllerEvent(CallScreenController.Event.TOGGLE_CONTROLS) + } + + override fun setControlsListener(controlsListener: CallScreenControlsListener) { + this.controlsListener.update { controlsListener } + } + + override fun setMicEnabled(enabled: Boolean) { + Log.d(TAG, "Ignoring call to setMicEnabled.") + } + + override fun setStatus(status: String) { + callScreenViewModel.callScreenState.update { it.copy(callStatus = status) } + } + + override fun setRecipient(recipient: Recipient) { + callScreenViewModel.callScreenState.update { it.copy(callRecipientId = recipient.id) } + } + + override fun setWebRtcControls(webRtcControls: WebRtcControls) { + Log.d(TAG, "Ignoring call to setWebRtcControls.") + } + + override fun updateCallParticipants(callParticipantsViewState: CallParticipantsViewState) { + callScreenViewModel.callParticipantsViewState.update { callParticipantsViewState } + } + + override fun maybeDismissAudioPicker() { + callScreenViewModel.emitControllerEvent(CallScreenController.Event.DISMISS_AUDIO_PICKER) + } + + override fun setPendingParticipantsViewListener(pendingParticipantsViewListener: PendingParticipantsListener) { + this.pendingParticipantsViewListener.update { pendingParticipantsViewListener } + } + + override fun updatePendingParticipantsList(pendingParticipantsList: PendingParticipantsState) { + callScreenViewModel.callScreenState.update { it.copy(pendingParticipantsState = pendingParticipantsList) } + } + + /** + * This is a no-op since this state is controlled by [CallControlsState] + */ + override fun setRingGroup(ringGroup: Boolean) { + Log.d(TAG, "Ignoring call to setRingGroup.") + } + + override fun switchToSpeakerView() { + callScreenViewModel.emitControllerEvent(CallScreenController.Event.SWITCH_TO_SPEAKER_VIEW) + } + + /** + * This is a no-op since this state is controlled by [CallControlsState] + */ + override fun enableRingGroup(canRing: Boolean) { + Log.d(TAG, "Ignoring call to enableRingGroup.") + } + + override fun showSpeakerViewHint() { + callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = true) } + } + + override fun hideSpeakerViewHint() { + callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } + } + + override fun showVideoTooltip(): Dismissible { + callScreenViewModel.callScreenState.update { it.copy(displayVideoTooltip = true) } + + return Dismissible { + callScreenViewModel.callScreenState.update { it.copy(displayVideoTooltip = false) } + } + } + + override fun showCameraTooltip(): Dismissible { + callScreenViewModel.callScreenState.update { it.copy(displaySwitchCameraTooltip = true) } + + return Dismissible { + callScreenViewModel.callScreenState.update { it.copy(displaySwitchCameraTooltip = false) } + } + } + + override fun onCallStateUpdate(callControlsChange: CallControlsChange) { + callScreenViewModel.callScreenState.update { it.copy(callControlsChange = callControlsChange) } + } + + override fun dismissCallOverflowPopup() { + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsPopup = false) } + } + + override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) { + callScreenViewModel.callParticipantListUpdate.update { callParticipantListUpdate } + } + + override fun enableParticipantUpdatePopup(enabled: Boolean) { + callScreenViewModel.callScreenState.update { it.copy(isParticipantUpdatePopupEnabled = enabled) } + } + + override fun enableCallStateUpdatePopup(enabled: Boolean) { + callScreenViewModel.callScreenState.update { it.copy(isCallStateUpdatePopupEnabled = enabled) } + } + + override fun showWifiToCellularPopupWindow() { + callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = true) } + } + + /** + * State holder for compose call screen + */ + class CallScreenViewModel : ViewModel() { + val callScreenControllerEvents = MutableSharedFlow() + val callState = MutableStateFlow(WebRtcViewModel.State.IDLE) + val callScreenState = MutableStateFlow(CallScreenState()) + val dialog = MutableStateFlow(CallScreenDialogType.NONE) + val callParticipantsViewState = MutableStateFlow( + CallParticipantsViewState( + callParticipantsState = CallParticipantsState(), + ephemeralState = WebRtcEphemeralState(), + isPortrait = true, + isLandscapeEnabled = true + ) + ) + + val callParticipantListUpdate = MutableStateFlow(CallParticipantListUpdate.computeDeltaUpdate(emptyList(), emptyList())) + + fun emitControllerEvent(controllerEvent: CallScreenController.Event) { + viewModelScope.launch { callScreenControllerEvents.emit(controllerEvent) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipants.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipants.kt new file mode 100644 index 0000000000..0180c0d957 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipants.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView +import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection + +/** + * Re-implementation of PendingParticipantsView in compose. + */ +@Composable +fun PendingParticipants( + pendingParticipantsState: PendingParticipantsState, + pendingParticipantsListener: PendingParticipantsListener +) { + if (pendingParticipantsState.isInPipMode) { + return + } + + var hasDisplayedContent by remember { mutableStateOf(false) } + + if (hasDisplayedContent || pendingParticipantsState.pendingParticipantCollection.getUnresolvedPendingParticipants().isNotEmpty()) { + hasDisplayedContent = true + + AndroidView( + ::PendingParticipantsView + ) { view -> + view.listener = pendingParticipantsListener + view.applyState(pendingParticipantsState.pendingParticipantCollection) + } + } +} + +@SignalPreview +@Composable +fun PendingParticipantsPreview() { + Previews.Preview { + PendingParticipants( + pendingParticipantsState = PendingParticipantsState( + pendingParticipantCollection = PendingParticipantCollection(), + isInPipMode = false + ), + pendingParticipantsListener = PendingParticipantsListener.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsListener.kt new file mode 100644 index 0000000000..80e5550eec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsListener.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import org.thoughtcrime.securesms.recipients.Recipient + +interface PendingParticipantsListener { + /** + * Display the sheet containing the request for the top level participant + */ + fun onLaunchRecipientSheet(pendingRecipient: Recipient) + + /** + * Given recipient should be admitted to the call + */ + fun onAllowPendingRecipient(pendingRecipient: Recipient) + + /** + * Given recipient should be rejected from the call + */ + fun onRejectPendingRecipient(pendingRecipient: Recipient) + + /** + * Display the sheet containing all of the requests for the given call + */ + fun onLaunchPendingRequestsSheet() + + object Empty : PendingParticipantsListener { + override fun onLaunchRecipientSheet(pendingRecipient: Recipient) = Unit + override fun onAllowPendingRecipient(pendingRecipient: Recipient) = Unit + override fun onRejectPendingRecipient(pendingRecipient: Recipient) = Unit + override fun onLaunchPendingRequestsSheet() = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsState.kt similarity index 77% rename from app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsState.kt index be67cbc79c..bf0d4d9a40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PendingParticipantsState.kt @@ -1,9 +1,9 @@ /* - * Copyright 2024 Signal Messenger, LLC + * Copyright 2025 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.service.webrtc.state +package org.thoughtcrime.securesms.components.webrtc.v2 import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ViewCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ViewCallScreenMediator.kt new file mode 100644 index 0000000000..9c28e0c440 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ViewCallScreenMediator.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import org.signal.core.util.concurrent.LifecycleDisposable +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.TooltipPopup +import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow +import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow +import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView +import org.thoughtcrime.securesms.components.webrtc.WebRtcControls +import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState + +/** + * Wraps WebRtcCallView and supporting code into a mediator subclass + */ +class ViewCallScreenMediator( + private val activity: WebRtcCallActivity, + private val viewModel: WebRtcCallViewModel +) : CallScreenMediator { + private val callScreen: WebRtcCallView + private val participantUpdateWindow: CallParticipantsListUpdatePopupWindow + private val callStateUpdatePopupWindow: CallStateUpdatePopupWindow + private val callOverflowPopupWindow: CallOverflowPopupWindow + private val wifiToCellularPopupWindow: WifiToCellularPopupWindow + private val controlsAndInfo: ControlsAndInfoController + private val controlsAndInfoViewModel: ControlsAndInfoViewModel + private val lifecycleDisposable = LifecycleDisposable() + + init { + activity.setContentView(R.layout.webrtc_call_activity) + callScreen = activity.findViewById(R.id.callScreen) + + participantUpdateWindow = CallParticipantsListUpdatePopupWindow(callScreen) + callStateUpdatePopupWindow = CallStateUpdatePopupWindow(callScreen) + wifiToCellularPopupWindow = WifiToCellularPopupWindow(callScreen) + callOverflowPopupWindow = CallOverflowPopupWindow(activity, callScreen) { + val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot + state.localParticipant.isHandRaised + } + + activity.lifecycle.addObserver(participantUpdateWindow) + + controlsAndInfoViewModel = ViewModelProvider(activity)[ControlsAndInfoViewModel::class] + controlsAndInfo = ControlsAndInfoController(activity, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel) + + lifecycleDisposable.bindTo(activity.lifecycle) + lifecycleDisposable.add(controlsAndInfo) + } + + override fun setWebRtcCallState(callState: WebRtcViewModel.State) = Unit + + override fun setControlsAndInfoVisibilityListener(listener: CallControlsVisibilityListener) { + controlsAndInfo.addVisibilityListener(listener) + } + + override fun onStateRestored() { + controlsAndInfo.onStateRestored() + } + + override fun toggleOverflowPopup() { + controlsAndInfo.toggleOverflowPopup() + } + + override fun restartHideControlsTimer() { + controlsAndInfo.restartHideControlsTimer() + } + + override fun showCallInfo() { + controlsAndInfo.showCallInfo() + } + + override fun toggleControls() { + controlsAndInfo.toggleControls() + } + + override fun setControlsListener(controlsListener: CallScreenControlsListener) { + callScreen.setControlsListener(controlsListener) + } + + override fun setMicEnabled(enabled: Boolean) { + callScreen.setMicEnabled(enabled) + } + + override fun setRecipient(recipient: Recipient) { + controlsAndInfoViewModel.setRecipient(recipient) + callScreen.setRecipient(recipient) + } + + override fun setStatus(status: String) { + callScreen.setStatus(status) + } + + override fun setWebRtcControls(webRtcControls: WebRtcControls) { + callScreen.setWebRtcControls(webRtcControls) + controlsAndInfo.updateControls(webRtcControls) + } + + override fun updateCallParticipants(callParticipantsViewState: CallParticipantsViewState) { + callScreen.updateCallParticipants(callParticipantsViewState) + } + + override fun maybeDismissAudioPicker() { + callScreen.maybeDismissAudioPicker() + } + + override fun setPendingParticipantsViewListener(pendingParticipantsViewListener: PendingParticipantsListener) { + callScreen.setPendingParticipantsViewListener(pendingParticipantsViewListener) + } + + override fun updatePendingParticipantsList(pendingParticipantsList: PendingParticipantsState) { + callScreen.updatePendingParticipantsList(pendingParticipantsList) + } + + override fun setRingGroup(ringGroup: Boolean) { + callScreen.setRingGroup(ringGroup) + } + + override fun switchToSpeakerView() { + callScreen.switchToSpeakerView() + } + + override fun enableRingGroup(canRing: Boolean) { + callScreen.enableRingGroup(canRing) + } + + override fun showSpeakerViewHint() { + callScreen.showSpeakerViewHint() + } + + override fun hideSpeakerViewHint() { + callScreen.hideSpeakerViewHint() + } + + override fun showVideoTooltip(): Dismissible { + val tooltip = TooltipPopup.forTarget(callScreen.videoTooltipTarget) + .setBackgroundTint(ContextCompat.getColor(activity, R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(activity, R.color.core_white)) + .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video) + .setOnDismissListener { viewModel.onDismissedVideoTooltip() } + .show(TooltipPopup.POSITION_ABOVE) + + return Dismissible { + tooltip.dismiss() + } + } + + override fun showCameraTooltip(): Dismissible { + val tooltip = TooltipPopup.forTarget(callScreen.switchCameraTooltipTarget) + .setBackgroundTint(ContextCompat.getColor(activity, R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(activity, R.color.core_white)) + .setText(R.string.WebRtcCallActivity__flip_camera_tooltip) + .setOnDismissListener { + viewModel.onDismissedSwitchCameraTooltip() + } + .show(TooltipPopup.POSITION_ABOVE) + + return Dismissible { tooltip.dismiss() } + } + + override fun onCallStateUpdate(callControlsChange: CallControlsChange) { + callStateUpdatePopupWindow.onCallStateUpdate(callControlsChange) + } + + override fun dismissCallOverflowPopup() { + callOverflowPopupWindow.dismiss() + } + + override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) { + participantUpdateWindow.addCallParticipantListUpdate(callParticipantListUpdate) + } + + override fun enableParticipantUpdatePopup(enabled: Boolean) { + participantUpdateWindow.setEnabled(enabled) + } + + override fun enableCallStateUpdatePopup(enabled: Boolean) { + callStateUpdatePopupWindow.setEnabled(enabled) + } + + override fun showWifiToCellularPopupWindow() { + wifiToCellularPopupWindow.show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt index 14ce3f0090..faffe3f7af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt @@ -18,6 +18,7 @@ import android.os.Bundle import android.util.Rational import android.view.Surface import android.view.View +import android.view.ViewGroup import android.view.Window import android.view.WindowManager import androidx.activity.viewModels @@ -49,26 +50,17 @@ import org.signal.core.util.logging.Log import org.signal.ringrtc.GroupCall import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.components.sensors.Orientation import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender -import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow -import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber -import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil import org.thoughtcrime.securesms.components.webrtc.InCallStatus import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet -import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput -import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView import org.thoughtcrime.securesms.components.webrtc.WebRtcControls -import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow -import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController -import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -110,17 +102,11 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private const val VIBRATE_DURATION = 50 } - private lateinit var participantUpdateWindow: CallParticipantsListUpdatePopupWindow - private lateinit var callStateUpdatePopupWindow: CallStateUpdatePopupWindow - private lateinit var callOverflowPopupWindow: CallOverflowPopupWindow - private lateinit var wifiToCellularPopupWindow: WifiToCellularPopupWindow - private lateinit var fullscreenHelper: FullscreenHelper - private lateinit var callScreen: WebRtcCallView - private var videoTooltip: TooltipPopup? = null - private var switchCameraTooltip: TooltipPopup? = null + private lateinit var callScreen: CallScreenMediator + private var videoTooltip: Dismissible? = null + private var switchCameraTooltip: Dismissible? = null private val viewModel: WebRtcCallViewModel by viewModels() - private val controlsAndInfoViewModel: ControlsAndInfoViewModel by viewModels() private var enableVideoIfAvailable: Boolean = false private var hasWarnedAboutBluetooth: Boolean = false private lateinit var windowLayoutInfoConsumer: WindowLayoutInfoConsumer @@ -129,7 +115,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private lateinit var pipBuilderParams: PictureInPictureParams.Builder private val lifecycleDisposable = LifecycleDisposable() private var lastCallLinkDisconnectDialogShowTime: Long = 0L - private lateinit var controlsAndInfo: ControlsAndInfoController private var enterPipOnResume: Boolean = false private var lastProcessedIntentTimestamp = 0L private var previousEvent: WebRtcViewModel? = null @@ -161,7 +146,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re super.onCreate(savedInstanceState) requestWindowFeature(Window.FEATURE_NO_TITLE) - setContentView(R.layout.webrtc_call_activity) fullscreenHelper = FullscreenHelper(this) @@ -171,8 +155,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re initializeViewModel() initializePictureInPictureParams() - controlsAndInfo = ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel) - controlsAndInfo.addVisibilityListener(FadeCallback()) + callScreen.setControlsAndInfoVisibilityListener(FadeCallback()) fullscreenHelper.showAndHideWithSystemUI( window, @@ -181,8 +164,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re findViewById(R.id.webrtc_call_view_toolbar_no_text) ) - lifecycleDisposable.add(controlsAndInfo) - if (savedInstanceState == null) { logIntent(callIntent) @@ -227,7 +208,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - controlsAndInfo.onStateRestored() + callScreen.onStateRestored() } override fun onStart() { @@ -385,7 +366,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re override fun onReactWithAnyEmojiSelected(emoji: String) { AppDependencies.signalCallManager.react(emoji) - callOverflowPopupWindow.dismiss() + callScreen.dismissCallOverflowPopup() } override fun onProofCompleted() { @@ -406,7 +387,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re previousEvent = event viewModel.setRecipient(event.recipient) - controlsAndInfoViewModel.setRecipient(event.recipient) + callScreen.setRecipient(event.recipient) + callScreen.setWebRtcCallState(event.state) when (event.state) { WebRtcViewModel.State.IDLE -> Unit @@ -475,18 +457,10 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } private fun initializeResources() { - callScreen = findViewById(R.id.callScreen) + callScreen = CallScreenMediator.create(this, viewModel) callScreen.setControlsListener(ControlsListener()) - participantUpdateWindow = CallParticipantsListUpdatePopupWindow(callScreen) - callStateUpdatePopupWindow = CallStateUpdatePopupWindow(callScreen) - wifiToCellularPopupWindow = WifiToCellularPopupWindow(callScreen) - callOverflowPopupWindow = CallOverflowPopupWindow(this, callScreen) { - val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot - state.localParticipant.isHandRaised - } - - lifecycle.addObserver(participantUpdateWindow) + val viewRoot = rootView() } private fun initializeViewModel() { @@ -512,7 +486,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re launch { viewModel.getWebRtcControls().collectLatest { callScreen.setWebRtcControls(it) - controlsAndInfo.updateControls(it) } } @@ -538,12 +511,12 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re viewModel.callParticipantsState, viewModel.getEphemeralState().filterNotNull() ) { state, ephemeralState -> - CallParticipantsViewState(state, ephemeralState, orientation == Orientation.PORTRAIT_BOTTOM_EDGE, true, isStartedFromCallLink) + CallParticipantsViewState(state, ephemeralState, orientation == Orientation.PORTRAIT_BOTTOM_EDGE, true) }.collectLatest(callScreen::updateCallParticipants) } launch { - viewModel.getCallParticipantListUpdate().collectLatest(participantUpdateWindow::addCallParticipantListUpdate) + viewModel.getCallParticipantListUpdate().collectLatest(callScreen::onParticipantListUpdate) } launch { @@ -564,7 +537,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } - callScreen.viewTreeObserver.addOnGlobalLayoutListener { + rootView().viewTreeObserver.addOnGlobalLayoutListener { val state = viewModel.callParticipantsStateSnapshot if (state.needsNewRequestSizes()) { requestNewSizesThrottle.publish { AppDependencies.signalCallManager.updateRenderedResolutions() } @@ -573,8 +546,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re addOnPictureInPictureModeChangedListener { info -> viewModel.setIsInPipMode(info.isInPictureInPictureMode) - participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode) - callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode) + callScreen.enableParticipantUpdatePopup(!info.isInPictureInPictureMode) + callScreen.enableCallStateUpdatePopup(!info.isInPictureInPictureMode) if (info.isInPictureInPictureMode) { callScreen.maybeDismissAudioPicker() } @@ -793,7 +766,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private fun handleOutgoingCall(event: WebRtcViewModel) { if (event.groupState.isNotIdle) { - callScreen.setStatusFromGroupCallState(event.groupState) + callScreen.setStatusFromGroupCallState(this, event.groupState) } else { callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)) } @@ -809,7 +782,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private fun handleTerminate(recipient: Recipient, hangupType: HangupMessage.Type) { Log.i(TAG, "handleTerminate called: $hangupType") - callScreen.setStatusFromHangupType(hangupType) + callScreen.setStatusFromHangupType(this, hangupType) EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java) @@ -848,7 +821,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private fun handleCallConnected(event: WebRtcViewModel) { window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES) if (event.groupState.isNotIdleOrConnected) { - callScreen.setStatusFromGroupCallState(event.groupState) + callScreen.setStatusFromGroupCallState(this, event.groupState) } } @@ -891,22 +864,19 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re SafetyNumberBottomSheet.forCall(recipient.id).show(supportFragmentManager) } + private fun rootView(): ViewGroup = findViewById(android.R.id.content) + private fun handleViewModelEvent(event: CallEvent) { when (event) { is CallEvent.StartCall -> startCall(event.isVideoCall) is CallEvent.ShowGroupCallSafetyNumberChange -> SafetyNumberBottomSheet.forGroupCall(event.identityRecords).show(supportFragmentManager) is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView() - is CallEvent.ShowSwipeToSpeakerHint -> CallToastPopupWindow.show(callScreen) + is CallEvent.ShowSwipeToSpeakerHint -> CallToastPopupWindow.show(rootView()) is CallEvent.ShowVideoTooltip -> { if (isInPipMode()) return if (videoTooltip == null) { - videoTooltip = TooltipPopup.forTarget(callScreen.videoTooltipTarget) - .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) - .setTextColor(ContextCompat.getColor(this, R.color.core_white)) - .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video) - .setOnDismissListener { viewModel.onDismissedVideoTooltip() } - .show(TooltipPopup.POSITION_ABOVE) + videoTooltip = callScreen.showVideoTooltip() } } @@ -919,19 +889,14 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re is CallEvent.ShowWifiToCellularPopup -> { if (isInPipMode()) return - wifiToCellularPopupWindow.show() + callScreen.showWifiToCellularPopupWindow() } is CallEvent.ShowSwitchCameraTooltip -> { if (isInPipMode()) return if (switchCameraTooltip == null) { - switchCameraTooltip = TooltipPopup.forTarget(callScreen.switchCameraTooltipTarget) - .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) - .setTextColor(ContextCompat.getColor(this, R.color.core_white)) - .setText(R.string.WebRtcCallActivity__flip_camera_tooltip) - .setOnDismissListener { viewModel.onDismissedSwitchCameraTooltip() } - .show(TooltipPopup.POSITION_ABOVE) + switchCameraTooltip = callScreen.showCameraTooltip() } } @@ -1077,7 +1042,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } private fun delayedFinish(delayMillis: Long = STANDARD_DELAY_FINISH) { - callScreen.postDelayed(this::finish, delayMillis) + rootView().postDelayed(this::finish, delayMillis) } private fun displayRemovedFromCallLinkDialog() { @@ -1099,9 +1064,9 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private fun maybeDisplaySpeakerphonePopup(nextOutput: WebRtcAudioOutput) { val currentOutput = viewModel.getCurrentAudioOutput() if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) { - callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_OFF) + callScreen.onCallStateUpdate(CallControlsChange.SPEAKER_OFF) } else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) { - callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_ON) + callScreen.onCallStateUpdate(CallControlsChange.SPEAKER_ON) } } @@ -1123,7 +1088,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } - private inner class FadeCallback : ControlsAndInfoController.BottomSheetVisibilityListener { + private inner class FadeCallback : CallControlsVisibilityListener { override fun onShown() { fullscreenHelper.showSystemUI() } @@ -1137,7 +1102,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } - private inner class ControlsListener : WebRtcCallView.ControlsListener { + private inner class ControlsListener : CallScreenControlsListener { override fun onStartCall(isVideoCall: Boolean) { viewModel.startCall(isVideoCall) } @@ -1168,13 +1133,13 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re override fun onMicChanged(isMicEnabled: Boolean) { askAudioPermissions { - callStateUpdatePopupWindow.onCallStateUpdate(if (isMicEnabled) CallControlsChange.MIC_ON else CallControlsChange.MIC_OFF) + callScreen.onCallStateUpdate(if (isMicEnabled) CallControlsChange.MIC_ON else CallControlsChange.MIC_OFF) handleSetMuteAudio(!isMicEnabled) } } override fun onOverflowClicked() { - controlsAndInfo.toggleOverflowPopup() + callScreen.toggleOverflowPopup() } override fun onCameraDirectionChanged() { @@ -1207,21 +1172,21 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re override fun onLocalPictureInPictureClicked() { viewModel.onLocalPictureInPictureClicked() - controlsAndInfo.restartHideControlsTimer() + callScreen.restartHideControlsTimer() } override fun onRingGroupChanged(ringGroup: Boolean, ringingAllowed: Boolean) { if (ringingAllowed) { AppDependencies.signalCallManager.setRingGroup(ringGroup) - callStateUpdatePopupWindow.onCallStateUpdate(if (ringGroup) CallControlsChange.RINGING_ON else CallControlsChange.RINGING_OFF) + callScreen.onCallStateUpdate(if (ringGroup) CallControlsChange.RINGING_ON else CallControlsChange.RINGING_OFF) } else { AppDependencies.signalCallManager.setRingGroup(false) - callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.RINGING_DISABLED) + callScreen.onCallStateUpdate(CallControlsChange.RINGING_DISABLED) } } override fun onCallInfoClicked() { - controlsAndInfo.showCallInfo() + callScreen.showCallInfo() } override fun onNavigateUpClicked() { @@ -1231,7 +1196,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re override fun toggleControls() { val controlState = viewModel.getWebRtcControls().value if (!controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) { - controlsAndInfo.toggleControls() + callScreen.toggleControls() } } @@ -1240,7 +1205,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } - private inner class PendingParticipantsViewListener : PendingParticipantsView.Listener { + private inner class PendingParticipantsViewListener : PendingParticipantsListener { override fun onLaunchRecipientSheet(pendingRecipient: Recipient) { CallLinkIncomingRequestSheet.show(supportFragmentManager, pendingRecipient.id) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt index a437de1772..cdb72ce4ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt @@ -45,7 +45,6 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection -import org.thoughtcrime.securesms.service.webrtc.state.PendingParticipantsState import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState import org.thoughtcrime.securesms.util.NetworkUtil import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java index 6110d6af07..3cacd18854 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java @@ -94,6 +94,10 @@ public final class FullscreenHelper { boolean hide = (visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0; for (View view : views) { + if (view == null) { + continue; + } + view.animate() .alpha(hide ? 0 : 1) .withStartAction(() -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt index 84dec97aba..7debae8c96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt @@ -7,8 +7,7 @@ class CallParticipantsViewState( callParticipantsState: CallParticipantsState, ephemeralState: WebRtcEphemeralState, val isPortrait: Boolean, - val isLandscapeEnabled: Boolean, - val isStartedFromCallLink: Boolean + val isLandscapeEnabled: Boolean ) { val callParticipantsState = CallParticipantsState.update(callParticipantsState, ephemeralState) diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/BackListenerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/service/BackPendingParticipantsListenerTest.kt similarity index 98% rename from app/src/test/java/org/thoughtcrime/securesms/service/BackListenerTest.kt rename to app/src/test/java/org/thoughtcrime/securesms/service/BackPendingParticipantsListenerTest.kt index 6756863966..0f7413617b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/service/BackListenerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/service/BackPendingParticipantsListenerTest.kt @@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes -class BackListenerTest { +class BackPendingParticipantsListenerTest { @Test fun testBackupJitterExactlyWithinJitterWindow() {