From f8d17b04cb4bccde6d2d093db1b5bf2d98a679fb Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 9 Jan 2026 14:17:32 -0400 Subject: [PATCH] Fix ringing screen when an outgoing call is placed and the camera is off. --- .../webrtc/v2/CallScreenJoiningOverlay.kt | 81 +++++++ .../webrtc/v2/CallScreenPreJoinOverlay.kt | 220 +++++++++++++----- 2 files changed, 241 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt index 2b3c409053..8a918f0e40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,15 +17,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layoutId +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 androidx.window.core.layout.WindowSizeClass import org.signal.core.ui.compose.AllNightPreviews import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R @@ -65,6 +71,21 @@ fun CallScreenJoiningOverlay( onCallInfoClick = onCallInfoClick ) + if (!isLocalVideoEnabled) { + val isCompactWidth = !currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + + if (isCompactWidth) { + YourCameraIsOff( + spacedBy = 8.dp, + modifier = Modifier.align(Alignment.Center) + ) + } else { + YourCameraIsOffLandscape( + modifier = Modifier.align(Alignment.Center) + ) + } + } + val showCameraToggle = isLocalVideoEnabled && isMoreThanOneCameraAvailable BottomControlsWithOptionalBar( @@ -140,6 +161,51 @@ private fun WaitingToBeLetInBar( } } +@Composable +private fun YourCameraIsOff( + spacedBy: Dp = 0.dp, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_video_slash_24), + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(bottom = spacedBy) + ) + + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) + } +} + +@Composable +private fun YourCameraIsOffLandscape( + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_video_slash_24), + contentDescription = null, + tint = Color.White + ) + + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) + } +} + @AllNightPreviews @Composable private fun CallScreenJoiningOverlayPreview() { @@ -192,3 +258,18 @@ private fun CallScreenJoiningOverlayWaitingPreview() { ) } } + +@AllNightPreviews +@Composable +private fun CallScreenJoiningOverlayWaitingCameraOffPreview() { + Previews.Preview { + CallScreenJoiningOverlay( + callRecipient = Recipient(systemContactName = "Test User"), + callStatus = "Waiting to be let in...", + localParticipant = CallParticipant.EMPTY, + isLocalVideoEnabled = false, + isMoreThanOneCameraAvailable = false, + isWaitingToBeLetIn = true + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt index 9f0dc45af1..53c7860f15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -35,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.rememberRecipientField import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import kotlin.math.max /** * Pre-join call screen overlay. @@ -84,55 +85,35 @@ fun CallScreenPreJoinOverlay( .background(color = Color(0f, 0f, 0f, 0.4f)) .then(modifier) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - CallScreenTopAppBar( + val isCompactWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT + + if (!isLocalVideoEnabled) { + TopWithCenteredContentLayout( + topSlot = { + PreJoinHeader( + callRecipient = callRecipient, + callStatus = callStatus, + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick + ) + }, + centerSlot = { + if (isCompactWidth) { + YourCameraIsOff(spacedBy = 8.dp) + } else { + YourCameraIsOffLandscape() + } + }, + modifier = Modifier + .fillMaxSize() + ) + } else { + PreJoinHeader( + callRecipient = callRecipient, + callStatus = callStatus, onNavigationClick = onNavigationClick, onCallInfoClick = onCallInfoClick ) - - AvatarImage( - recipient = callRecipient, - modifier = Modifier - .padding(top = 8.dp) - .size(96.dp) - ) - - Text( - text = callRecipient.getDisplayName(LocalContext.current), - style = MaterialTheme.typography.headlineMedium, - color = Color.White, - modifier = Modifier.padding(top = 16.dp) - ) - - if (callStatus != null) { - Text( - text = callStatus, - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - modifier = Modifier.padding(top = 8.dp) - ) - } - - if (!isLocalVideoEnabled) { - Spacer(modifier = Modifier.weight(1f)) - - val isCompactWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT - if (isCompactWidth) { - YourCameraIsOff(spacedBy = 8.dp) - } else { - Row( - horizontalArrangement = spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - YourCameraIsOff() - } - } - - Spacer(modifier = Modifier.weight(1f)) - } } // Bottom controls in a separate layer for proper screen-edge positioning @@ -177,6 +158,95 @@ fun CallScreenPreJoinOverlay( } } +@Composable +private fun PreJoinHeader( + callRecipient: Recipient, + callStatus: String?, + onNavigationClick: () -> Unit, + onCallInfoClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + CallScreenTopAppBar( + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick + ) + + AvatarImage( + recipient = callRecipient, + modifier = Modifier + .padding(top = 8.dp) + .size(96.dp) + ) + + Text( + text = callRecipient.getDisplayName(LocalContext.current), + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + modifier = Modifier.padding(top = 16.dp) + ) + + if (callStatus != null) { + Text( + text = callStatus, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + modifier = Modifier.padding(top = 8.dp) + ) + } + } +} + +private object TopWithCenteredContentLayoutId { + const val TOP = "top" + const val CENTER = "center" +} + +/** + * A layout that places content at the top and centers other content in the viewport. + * If the centered content would overlap with the top content, it is pushed down to stay below. + */ +@Composable +private fun TopWithCenteredContentLayout( + topSlot: @Composable () -> Unit, + centerSlot: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + Layout( + content = { + Box(modifier = Modifier.layoutId(TopWithCenteredContentLayoutId.TOP)) { topSlot() } + Box(modifier = Modifier.layoutId(TopWithCenteredContentLayoutId.CENTER)) { centerSlot() } + }, + modifier = modifier + ) { measurables, constraints -> + val looseConstraints = constraints.copy(minHeight = 0, minWidth = 0) + val topPlaceable = measurables.first { it.layoutId == TopWithCenteredContentLayoutId.TOP }.measure(looseConstraints) + val centerPlaceable = measurables.first { it.layoutId == TopWithCenteredContentLayoutId.CENTER }.measure(looseConstraints) + + val layoutHeight = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + topPlaceable.height + centerPlaceable.height + } + + layout(constraints.maxWidth, layoutHeight) { + topPlaceable.placeRelative( + x = (constraints.maxWidth - topPlaceable.width) / 2, + y = 0 + ) + + val viewportCenterY = (layoutHeight - centerPlaceable.height) / 2 + val minY = topPlaceable.height + centerPlaceable.placeRelative( + x = (constraints.maxWidth - centerPlaceable.width) / 2, + y = max(viewportCenterY, minY) + ) + } + } +} + @Composable private fun CallLinkInfoCard( modifier: Modifier = Modifier @@ -212,21 +282,51 @@ private fun CallLinkInfoCard( @Composable private fun YourCameraIsOff( - spacedBy: Dp = 0.dp + spacedBy: Dp = 0.dp, + modifier: Modifier = Modifier ) { - Icon( - painter = painterResource( - id = R.drawable.symbol_video_slash_24 - ), - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(bottom = spacedBy) - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Icon( + painter = painterResource( + id = R.drawable.symbol_video_slash_24 + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(bottom = spacedBy) + ) - Text( - text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), - color = Color.White - ) + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) + } +} + +@Composable +private fun YourCameraIsOffLandscape( + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Icon( + painter = painterResource( + id = R.drawable.symbol_video_slash_24 + ), + contentDescription = null, + tint = Color.White + ) + + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) + } } @Composable