Add CallScreenMediator pattern to facilitate moving from views to compose.

This commit is contained in:
Alex Hart
2025-02-11 12:22:03 -04:00
committed by Greyson Parrelli
parent f80ab7402a
commit 216c29c206
23 changed files with 989 additions and 310 deletions

View File

@@ -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()
}
}

View File

@@ -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<View> incomingCallViews = new HashSet<>();
private final Set<View> 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<View> 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);
}
}

View File

@@ -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) {

View File

@@ -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<BottomSheetVisibilityListener>()
private val callControlsVisibilityListeners = mutableSetOf<CallControlsVisibilityListener>()
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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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<CallParticipant>,
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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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)
}
}
}

View File

@@ -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>(CallScreenControlsListener.Empty)
private val controlsVisibilityListener = MutableStateFlow<CallControlsVisibilityListener>(CallControlsVisibilityListener.Empty)
private val pendingParticipantsViewListener = MutableStateFlow<PendingParticipantsListener>(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<CallScreenController.Event>()
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) }
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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(() -> {

View File

@@ -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)

View File

@@ -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() {