Implement the incoming call screen in compose.

This commit is contained in:
Alex Hart
2025-02-19 09:20:54 -04:00
committed by GitHub
parent ca6c9d76b2
commit 31d80ed200
11 changed files with 428 additions and 20 deletions

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.DarkPreview
@@ -28,6 +29,8 @@ import org.signal.core.ui.IconButtons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
private val defaultCallButtonIconSize: Dp = 24.dp
@Composable
private fun ToggleCallButton(
checked: Boolean,
@@ -67,7 +70,8 @@ private fun CallButton(
contentDescription: String?,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.secondaryContainer,
contentColor: Color = colorResource(id = R.color.signal_light_colorOnPrimary)
contentColor: Color = colorResource(id = R.color.signal_light_colorOnPrimary),
iconSize: Dp = defaultCallButtonIconSize
) {
val buttonSize = dimensionResource(id = R.dimen.webrtc_button_size)
IconButtons.IconButton(
@@ -82,7 +86,8 @@ private fun CallButton(
Icon(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(28.dp)
modifier = Modifier.size(iconSize),
tint = contentColor
)
}
}
@@ -152,13 +157,51 @@ fun AdditionalActionsButton(
@Composable
fun HangupButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
iconSize: Dp = defaultCallButtonIconSize
) {
CallButton(
onClick = onClick,
painter = painterResource(id = R.drawable.symbol_phone_down_fill_24),
contentDescription = stringResource(id = R.string.WebRtcCallView__end_call),
containerColor = colorResource(id = R.color.webrtc_hangup_background),
modifier = modifier,
iconSize = iconSize
)
}
@Composable
fun AcceptCallButton(
onClick: () -> Unit,
isVideoCall: Boolean,
modifier: Modifier = Modifier,
iconSize: Dp = defaultCallButtonIconSize
) {
CallButton(
onClick = onClick,
painter = if (isVideoCall) {
painterResource(id = R.drawable.symbol_video_fill_24)
} else {
painterResource(id = R.drawable.symbol_phone_fill_white_24)
},
contentDescription = stringResource(id = R.string.WebRtcCallScreen__answer),
containerColor = colorResource(id = R.color.webrtc_answer_background),
iconSize = iconSize,
modifier = modifier
)
}
@Composable
fun AnswerWithoutVideoButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
CallButton(
onClick = onClick,
painter = painterResource(id = R.drawable.symbol_video_slash_fill_24),
contentDescription = stringResource(id = R.string.WebRtcCallScreen__answer_without_video),
containerColor = Color.White,
contentColor = Color.Black,
modifier = modifier
)
}
@@ -261,6 +304,38 @@ private fun HangupButtonPreview() {
}
}
@DarkPreview
@Composable
private fun VideoAcceptCallButtonPreview() {
Previews.Preview {
AcceptCallButton(
onClick = {},
isVideoCall = true
)
}
}
@DarkPreview
@Composable
private fun AcceptCallButtonPreview() {
Previews.Preview {
AcceptCallButton(
onClick = {},
isVideoCall = false
)
}
}
@DarkPreview
@Composable
private fun AnswerWithoutVideoButtonPreview() {
Previews.Preview {
AnswerWithoutVideoButton(
onClick = {}
)
}
}
@DarkPreview
@Composable
private fun StartCallButtonPreview() {

View File

@@ -77,6 +77,7 @@ private const val SHEET_BOTTOM_PADDING = 16
fun CallScreen(
callRecipient: Recipient,
webRtcCallState: WebRtcViewModel.State,
isRemoteVideoOffer: Boolean,
callScreenState: CallScreenState,
callControlsState: CallControlsState,
callScreenController: CallScreenController = CallScreenController.rememberCallScreenController(
@@ -98,6 +99,16 @@ fun CallScreen(
onControlsToggled: (Boolean) -> Unit,
onCallScreenDialogDismissed: () -> Unit = {}
) {
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
IncomingCallScreen(
callRecipient = callRecipient,
isVideoCall = isRemoteVideoOffer,
callStatus = callScreenState.callStatus,
callScreenControlsListener = callScreenControlsListener
)
return
}
var peekPercentage by remember {
mutableFloatStateOf(0f)
}
@@ -284,8 +295,7 @@ private fun BoxScope.Viewport(
) {
if (webRtcCallState.isPreJoinOrNetworkUnavailable) {
LargeLocalVideoRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState
localParticipant = localParticipant
)
}
@@ -369,12 +379,10 @@ private fun BoxScope.Viewport(
*/
@Composable
private fun LargeLocalVideoRenderer(
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState
localParticipant: CallParticipant
) {
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.extraLarge)
@@ -407,7 +415,6 @@ private fun TinyLocalVideoRenderer(
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = modifier
.padding(16.dp)
.height(height)
@@ -449,7 +456,6 @@ private fun SmallMoveableLocalVideoRenderer(
) {
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium)
@@ -498,6 +504,7 @@ private fun CallScreenPreview() {
CallScreen(
callRecipient = Recipient(systemContactName = "Test User"),
webRtcCallState = WebRtcViewModel.State.CALL_CONNECTED,
isRemoteVideoOffer = false,
callScreenState = CallScreenState(),
callControlsState = CallControlsState(
displayMicToggle = true,

View File

@@ -53,6 +53,7 @@ interface CallScreenMediator {
fun enableParticipantUpdatePopup(enabled: Boolean)
fun enableCallStateUpdatePopup(enabled: Boolean)
fun showWifiToCellularPopupWindow()
fun hideMissingPermissionsNotice()
fun setStatusFromGroupCallState(context: Context, groupCallState: WebRtcViewModel.GroupCallState) {
when (groupCallState) {

View File

@@ -26,6 +26,7 @@ data class CallScreenState(
val displaySwipeToSpeakerHint: Boolean = false,
val displayWifiToCellularPopup: Boolean = false,
val displayAdditionalActionsPopup: Boolean = false,
val displayMissingPermissionsNotice: Boolean = false,
val pendingParticipantsState: PendingParticipantsState? = null,
val isParticipantUpdatePopupEnabled: Boolean = false,
val isCallStateUpdatePopupEnabled: Boolean = false

View File

@@ -134,11 +134,12 @@ fun CallScreenPreJoinOverlay(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CallScreenTopAppBar(
fun CallScreenTopAppBar(
callRecipient: Recipient? = null,
callStatus: String? = null,
onNavigationClick: () -> Unit = {},
onCallInfoClick: () -> Unit = {}
onCallInfoClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
val textShadow = remember {
Shadow(
@@ -148,6 +149,7 @@ private fun CallScreenTopAppBar(
}
TopAppBar(
modifier = modifier,
colors = TopAppBarDefaults.topAppBarColors().copy(
containerColor = Color.Transparent
),

View File

@@ -108,6 +108,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
CallScreen(
callRecipient = recipient,
webRtcCallState = webRtcCallState,
isRemoteVideoOffer = viewModel.isAnswerWithVideoAvailable(),
callScreenState = callScreenState,
callControlsState = callControlsState,
callScreenController = callScreenController,
@@ -276,6 +277,10 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = true) }
}
override fun hideMissingPermissionsNotice() {
callScreenViewModel.callScreenState.update { it.copy(displayMissingPermissionsNotice = false) }
}
/**
* State holder for compose call screen
*/

View File

@@ -0,0 +1,312 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.DarkPreview
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.recipients.Recipient
private val textShadow = Shadow(
color = Color(0f, 0f, 0f, 0.25f),
blurRadius = 4f
)
@Composable
fun IncomingCallScreen(
callRecipient: Recipient,
callStatus: String?,
isVideoCall: Boolean,
callScreenControlsListener: CallScreenControlsListener
) {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val callTypePadding = remember(isLandscape) {
if (isLandscape) {
PaddingValues(top = 0.dp, bottom = 20.dp)
} else {
PaddingValues(top = 22.dp, bottom = 30.dp)
}
}
Scaffold { contentPadding ->
GlideImage(
model = callRecipient.contactPhoto,
modifier = Modifier.fillMaxSize()
.blur(
radiusX = 25.dp,
radiusY = 25.dp,
edgeTreatment = BlurredEdgeTreatment.Rectangle
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black.copy(alpha = 0.4f))
) {}
CallScreenTopAppBar(
callRecipient = null,
callStatus = null,
onNavigationClick = callScreenControlsListener::onNavigateUpClicked,
onCallInfoClick = callScreenControlsListener::onCallInfoClicked,
modifier = Modifier.padding(contentPadding)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding)
.fillMaxSize()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(callTypePadding)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_signal_logo_small),
contentDescription = null,
modifier = Modifier.padding(end = 6.dp)
)
Text(
text = if (isVideoCall) {
stringResource(R.string.WebRtcCallView__signal_video_call)
} else {
stringResource(R.string.WebRtcCallView__signal_call)
},
style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow)
)
}
AvatarImage(
recipient = callRecipient,
modifier = Modifier.size(80.dp)
)
Text(
text = callRecipient.getDisplayName(LocalContext.current),
style = if (isLandscape) {
MaterialTheme.typography.titleLarge.copy(shadow = textShadow)
} else {
MaterialTheme.typography.headlineMedium.copy(shadow = textShadow)
},
color = Color.White,
modifier = Modifier.padding(top = 16.dp)
)
if (callStatus != null) {
Text(
text = callStatus,
style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow),
color = Color.White,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
if (isLandscape) {
LandscapeButtons(isVideoCall, callScreenControlsListener)
} else {
PortraitButtons(isVideoCall, callScreenControlsListener)
}
}
}
}
@Composable
private fun PortraitButtons(
isVideoCall: Boolean,
callScreenControlsListener: CallScreenControlsListener
) {
Column(
verticalArrangement = spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 64.dp)
.padding(bottom = 24.dp)
) {
if (isVideoCall) {
AnswerWithoutVideoButtonAndLabel(
onClick = callScreenControlsListener::onAcceptCallWithVoiceOnlyPressed
)
}
Row {
DeclineButtonAndLabel(
onClick = callScreenControlsListener::onDenyCallPressed
)
Spacer(modifier = Modifier.weight(1f))
AnswerCallButtonAndLabel(
isVideoCall = isVideoCall,
onClick = callScreenControlsListener::onAcceptCallPressed
)
}
}
}
@Composable
private fun LandscapeButtons(
isVideoCall: Boolean,
callScreenControlsListener: CallScreenControlsListener
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(20.dp),
horizontalArrangement = spacedBy(45.dp)
) {
DeclineButtonAndLabel(
onClick = callScreenControlsListener::onDenyCallPressed
)
if (isVideoCall) {
AnswerWithoutVideoButtonAndLabel(
onClick = callScreenControlsListener::onAcceptCallWithVoiceOnlyPressed
)
}
AnswerCallButtonAndLabel(
isVideoCall = isVideoCall,
onClick = callScreenControlsListener::onAcceptCallPressed
)
}
}
@Composable
private fun DeclineButtonAndLabel(
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(6.dp)
) {
HangupButton(
onClick = onClick,
iconSize = 32.dp,
modifier = Modifier.size(78.dp)
)
Text(
text = stringResource(R.string.WebRtcCallScreen__decline),
style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow)
)
}
}
@Composable
private fun AnswerWithoutVideoButtonAndLabel(
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(6.dp)
) {
Box(
modifier = Modifier.size(78.dp)
) {
AnswerWithoutVideoButton(
onClick = onClick,
modifier = Modifier
.size(56.dp)
.align(Alignment.Center)
)
}
Text(
text = stringResource(R.string.WebRtcCallScreen__answer_without_video),
style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow)
)
}
}
@Composable
private fun AnswerCallButtonAndLabel(
isVideoCall: Boolean,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(6.dp)
) {
AcceptCallButton(
isVideoCall = isVideoCall,
onClick = onClick,
iconSize = 32.dp,
modifier = Modifier.size(78.dp)
)
Text(
text = stringResource(R.string.WebRtcCallScreen__answer),
style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow)
)
}
}
@DarkPreview
@Preview(device = "spec:parent=pixel_5,orientation=landscape", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun IncomingVideoCallScreenPreview() {
Previews.Preview {
IncomingCallScreen(
callRecipient = Recipient(
systemContactName = "Test User"
),
callScreenControlsListener = CallScreenControlsListener.Empty,
isVideoCall = true,
callStatus = "Spiderman is calling the group"
)
}
}
@DarkPreview
@Preview(device = "spec:parent=pixel_5,orientation=landscape", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun IncomingAudioCallScreenPreview() {
Previews.Preview {
IncomingCallScreen(
callRecipient = Recipient(
systemContactName = "Test User"
),
callScreenControlsListener = CallScreenControlsListener.Empty,
isVideoCall = false,
callStatus = "Spiderman is calling the group"
)
}
}

View File

@@ -17,7 +17,6 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable
import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto
import org.thoughtcrime.securesms.events.CallParticipant
@@ -31,8 +30,8 @@ import org.webrtc.RendererCommon
@Composable
fun LocalParticipantRenderer(
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
force: Boolean = false
) {
BoxWithConstraints(
modifier = modifier
@@ -70,7 +69,7 @@ fun LocalParticipantRenderer(
modifier = Modifier.fillMaxSize()
)
if (localParticipant.isVideoEnabled) {
if (force || localParticipant.isVideoEnabled) {
AndroidView(
factory = ::TextureViewRenderer,
modifier = Modifier.fillMaxSize(),

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.concurrent.LifecycleDisposable
@@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoCont
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
/**
@@ -194,4 +196,8 @@ class ViewCallScreenMediator(
override fun showWifiToCellularPopupWindow() {
wifiToCellularPopupWindow.show()
}
override fun hideMissingPermissionsNotice() {
callScreen.findViewById<View>(R.id.missing_permissions_container).visible = false
}
}

View File

@@ -17,7 +17,6 @@ import android.os.Build
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
@@ -85,7 +84,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.VibrateUtil
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.ChosenAudioDeviceIdentifier
@@ -386,6 +384,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
viewModel.setRecipient(event.recipient)
callScreen.setRecipient(event.recipient)
event.isRemoteVideoOffer
callScreen.setWebRtcCallState(event.state)
when (event.state) {
@@ -669,7 +668,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
callPermissionsDialogController.requestCameraAndAudioPermission(
activity = this,
onAllGranted = onGranted,
onCameraGranted = { findViewById<View>(R.id.missing_permissions_container).visible = false },
onCameraGranted = { callScreen.hideMissingPermissionsNotice() },
onAudioDenied = this::handleDenyCall
)
}
@@ -677,7 +676,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private fun askCameraPermissions(onGranted: () -> Unit) {
callPermissionsDialogController.requestCameraPermission(this) {
onGranted()
findViewById<View>(R.id.missing_permissions_container).visible = false
callScreen.hideMissingPermissionsNotice()
}
}

View File

@@ -9,6 +9,7 @@
<color name="story_caption_gradient_start">#CC000000</color>
<color name="webrtc_hangup_background">#F07168</color>
<color name="webrtc_answer_background">#34C759</color>
<color name="transparent_black_05">#0D000000</color>
<color name="transparent_black_08">#14000000</color>