From 23698dbc284d4a0de5398d232c5ef79652dcfbc7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 4 May 2026 14:02:58 -0400 Subject: [PATCH] Switch linked device scanner to use CameraScreen. --- .../linkdevice/AddLinkDeviceFragment.kt | 41 ++-- .../linkdevice/LinkDeviceQrScanScreen.kt | 187 +++++++++++++++--- .../linkdevice/LinkDeviceSettingsState.kt | 1 - .../linkdevice/LinkDeviceViewModel.kt | 21 +- .../java/org/signal/camera/CameraScreen.kt | 20 +- 5 files changed, 208 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt index 9f1b42ea75..03780c1fcc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt @@ -8,22 +8,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.fragment.app.activityViewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenState +import org.signal.camera.CameraScreenViewModel import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews @@ -48,6 +54,9 @@ class AddLinkDeviceFragment : ComposeFragment() { @Composable override fun FragmentContent() { val state by viewModel.state.collectAsStateWithLifecycle() + val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() } + val cameraState by cameraViewModel.state + val context = LocalContext.current val navController: NavController by remember { mutableStateOf(findNavController()) } val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) @@ -60,18 +69,23 @@ class AddLinkDeviceFragment : ComposeFragment() { navController.popBackStack() } - MainScreen( - state = state, - navController = navController, - hasPermissions = cameraPermissionState.status.isGranted, - onRequestPermissions = { askPermissions() }, - onShowFrontCamera = { viewModel.showFrontCamera() }, - onQrCodeScanned = { data -> + LaunchedEffect(cameraViewModel) { + cameraViewModel.qrCodeDetected.collect { data -> if (VibrateUtil.isHapticFeedbackEnabled(requireContext())) { VibrateUtil.vibrate(requireContext(), VIBRATE_DURATION_MS) } viewModel.onQrCodeScanned(data) - }, + } + } + + MainScreen( + state = state, + cameraState = cameraState, + cameraEmitter = cameraViewModel::onEvent, + navController = navController, + hasPermissions = cameraPermissionState.status.isGranted, + onRequestPermissions = { askPermissions() }, + onSwitchCamera = { cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context)) }, onQrCodeApproved = { navController.popBackStack() viewModel.addDevice(shouldSync = false) @@ -102,11 +116,12 @@ class AddLinkDeviceFragment : ComposeFragment() { @Composable private fun MainScreen( state: LinkDeviceSettingsState, + cameraState: CameraScreenState = CameraScreenState(), + cameraEmitter: (CameraScreenEvents) -> Unit = {}, navController: NavController? = null, hasPermissions: Boolean = false, onRequestPermissions: () -> Unit = {}, - onShowFrontCamera: () -> Unit = {}, - onQrCodeScanned: (String) -> Unit = {}, + onSwitchCamera: () -> Unit = {}, onQrCodeApproved: () -> Unit = {}, onQrCodeDismissed: () -> Unit = {}, onLinkDeviceSuccess: () -> Unit = {}, @@ -118,7 +133,7 @@ private fun MainScreen( navigationIcon = ImageVector.vectorResource(id = R.drawable.ic_x), navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close), actions = { - IconButton(onClick = { onShowFrontCamera() }) { + IconButton(onClick = onSwitchCamera) { Icon(painterResource(id = R.drawable.symbol_switch_24), contentDescription = null) } } @@ -126,9 +141,9 @@ private fun MainScreen( LinkDeviceQrScanScreen( hasPermission = hasPermissions, onRequestPermissions = onRequestPermissions, - showFrontCamera = state.showFrontCamera, + cameraState = cameraState, + cameraEmitter = cameraEmitter, qrCodeState = state.qrCodeState, - onQrCodeScanned = onQrCodeScanned, onQrCodeAccepted = onQrCodeApproved, onQrCodeDismissed = onQrCodeDismissed, linkDeviceResult = state.linkDeviceResult, diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt index 255c865a96..5bdb67d736 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt @@ -2,25 +2,46 @@ package org.thoughtcrime.securesms.linkdevice import android.content.Context import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import org.signal.camera.CameraCaptureMode +import org.signal.camera.CameraScreen +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenState +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Dialogs -import org.signal.qr.QrScannerView +import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult -import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig -import org.thoughtcrime.securesms.qr.QrScanScreens import org.thoughtcrime.securesms.util.navigation.safeNavigate -import java.util.concurrent.TimeUnit /** * A screen that allows you to scan a QR code to link a device @@ -29,9 +50,9 @@ import java.util.concurrent.TimeUnit fun LinkDeviceQrScanScreen( hasPermission: Boolean, onRequestPermissions: () -> Unit, - showFrontCamera: Boolean?, + cameraState: CameraScreenState, + cameraEmitter: (CameraScreenEvents) -> Unit, qrCodeState: LinkDeviceSettingsState.QrCodeState, - onQrCodeScanned: (String) -> Unit, onQrCodeAccepted: () -> Unit, onQrCodeDismissed: () -> Unit, linkDeviceResult: LinkDeviceResult, @@ -40,7 +61,6 @@ fun LinkDeviceQrScanScreen( navController: NavController?, modifier: Modifier = Modifier ) { - val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current when (qrCodeState) { @@ -92,32 +112,145 @@ fun LinkDeviceQrScanScreen( modifier = Modifier .fillMaxWidth() .weight(1f, true) + .background(Color.Black) ) { - QrScanScreens.QrScanScreen( - factory = { factoryContext -> - val view = QrScannerView(factoryContext) - view.qrData - .throttleFirst(3000, TimeUnit.MILLISECONDS) - .subscribe { data -> - onQrCodeScanned(data) - } - view - }, - update = { view: QrScannerView -> - view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted()) - if (showFrontCamera != null) { - view.toggleCamera() + if (hasPermission) { + val crosshairPath = remember { Path() } + + CameraScreen( + state = cameraState, + emitter = cameraEmitter, + enableQrScanning = true, + captureMode = CameraCaptureMode.ImageOnly, + roundCorners = false, + fillViewport = true, + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithContent { + drawContent() + drawQrCrosshair(crosshairPath) + } + ) + + Text( + text = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 24.dp) + .background(color = Color.Black.copy(alpha = 0.5f), shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } else { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .align(Alignment.Center) + .padding(48.dp) + ) { + Text( + text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = Color.White + ) + Buttons.MediumTonal( + colors = ButtonDefaults.filledTonalButtonColors(), + onClick = onRequestPermissions + ) { + Text(stringResource(R.string.CameraXFragment_allow_access)) } - }, - hasPermission = hasPermission, - onRequestPermissions = onRequestPermissions, - qrHeaderLabelString = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code) - ) + } + } } } } +private fun DrawScope.drawQrCrosshair(path: Path) { + val crosshairWidth: Float = size.minDimension * 0.6f + val crosshairLineLength = crosshairWidth * 0.125f + + val topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2) + val topRight = center + Offset(crosshairWidth / 2, -crosshairWidth / 2) + val bottomRight = center + Offset(crosshairWidth / 2, crosshairWidth / 2) + val bottomLeft = center + Offset(-crosshairWidth / 2, crosshairWidth / 2) + + path.reset() + + drawPath( + path = path.apply { + moveTo(topLeft.x, topLeft.y + crosshairLineLength) + lineTo(topLeft.x, topLeft.y) + lineTo(topLeft.x + crosshairLineLength, topLeft.y) + + moveTo(topRight.x - crosshairLineLength, topRight.y) + lineTo(topRight.x, topRight.y) + lineTo(topRight.x, topRight.y + crosshairLineLength) + + moveTo(bottomRight.x, bottomRight.y - crosshairLineLength) + lineTo(bottomRight.x, bottomRight.y) + lineTo(bottomRight.x - crosshairLineLength, bottomRight.y) + + moveTo(bottomLeft.x + crosshairLineLength, bottomLeft.y) + lineTo(bottomLeft.x, bottomLeft.y) + lineTo(bottomLeft.x, bottomLeft.y - crosshairLineLength) + }, + color = Color.White, + style = Stroke( + width = 3.dp.toPx(), + pathEffect = PathEffect.cornerPathEffect(10.dp.toPx()) + ) + ) +} + private fun makeToast(context: Context, messageId: Int, onLinkDeviceFailure: () -> Unit) { Toast.makeText(context, messageId, Toast.LENGTH_LONG).show() onLinkDeviceFailure() } + +@DayNightPreviews +@Composable +private fun LinkDeviceQrScanScreenPreview() { + Previews.Preview { + LinkDeviceQrScanScreen( + hasPermission = true, + onRequestPermissions = {}, + cameraState = CameraScreenState(), + cameraEmitter = {}, + qrCodeState = LinkDeviceSettingsState.QrCodeState.NONE, + onQrCodeAccepted = {}, + onQrCodeDismissed = {}, + linkDeviceResult = LinkDeviceResult.None, + onLinkDeviceSuccess = {}, + onLinkDeviceFailure = {}, + navController = null + ) + } +} + +@DayNightPreviews +@Composable +private fun LinkDeviceQrScanScreenNoPermissionPreview() { + Previews.Preview { + LinkDeviceQrScanScreen( + hasPermission = false, + onRequestPermissions = {}, + cameraState = CameraScreenState(), + cameraEmitter = {}, + qrCodeState = LinkDeviceSettingsState.QrCodeState.NONE, + onQrCodeAccepted = {}, + onQrCodeDismissed = {}, + linkDeviceResult = LinkDeviceResult.None, + onLinkDeviceSuccess = {}, + onLinkDeviceFailure = {}, + navController = null + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index 8efb5ab379..989b8dde2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -13,7 +13,6 @@ data class LinkDeviceSettingsState( val dialogState: DialogState = DialogState.None, val deviceListLoading: Boolean = false, val oneTimeEvent: OneTimeEvent = OneTimeEvent.None, - val showFrontCamera: Boolean? = null, val qrCodeState: QrCodeState = QrCodeState.NONE, val linkUri: Uri? = null, val linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None, diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 27295f69d9..8466fe54e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -105,8 +105,7 @@ class LinkDeviceViewModel : ViewModel() { private fun loadDevices(initialLoad: Boolean = false) { _state.value = _state.value.copy( - deviceListLoading = true, - showFrontCamera = null + deviceListLoading = true ) viewModelScope.launch(Dispatchers.IO) { @@ -153,21 +152,11 @@ class LinkDeviceViewModel : ViewModel() { pollJob?.cancel() } - fun showFrontCamera() { - _state.update { - val frontCamera = it.showFrontCamera - it.copy( - showFrontCamera = if (frontCamera == null) true else !frontCamera - ) - } - } - fun markQrEducationSheetSeen() { SignalStore.uiHints.markHasSeenLinkDeviceQrEducationSheet() _state.update { it.copy( - seenQrEducationSheet = true, - showFrontCamera = null + seenQrEducationSheet = true ) } } @@ -182,16 +171,14 @@ class LinkDeviceViewModel : ViewModel() { _state.update { it.copy( qrCodeState = if (uri.supportsLinkAndSync()) QrCodeState.VALID_WITH_SYNC else QrCodeState.VALID_WITHOUT_SYNC, - linkUri = uri, - showFrontCamera = null + linkUri = uri ) } } else { _state.update { it.copy( qrCodeState = QrCodeState.INVALID, - linkUri = uri, - showFrontCamera = null + linkUri = uri ) } } diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt index ae3061addf..59d0183258 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -66,6 +67,8 @@ import androidx.camera.core.Preview as CameraPreview * @param modifier Modifier to apply to the camera container. * @param roundCorners Whether to apply rounded corners to the camera viewfinder. Defaults to true. * @param contentAlignment The alignment of the camera viewfinder within the available space. Defaults to center. + * @param fillViewport When true, the viewfinder fills all available space, cropping the camera frame + * if necessary to avoid letterbox bars. Defaults to false (letterbox to a 9:16 / 16:9 aspect ratio). * @param content Composable content to overlay on top of the camera surface. The content is placed in a Box * with the same size and position as the camera surface. */ @@ -78,6 +81,7 @@ fun CameraScreen( contentAlignment: Alignment = Alignment.Center, captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning: Boolean = false, + fillViewport: Boolean = false, content: @Composable BoxScope.() -> Unit = {} ) { val context = LocalContext.current @@ -92,8 +96,10 @@ fun CameraScreen( val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val aspectRatio = if (isLandscape) 16f / 9f else 9f / 16f - // Bind camera and setup surface provider - LaunchedEffect(lifecycleOwner, state.lensFacing) { + // Bind camera and setup surface provider. Re-bind on orientation changes so the + // Preview's target rotation matches the new display rotation — otherwise the surface + // is set up for the previous orientation and the frame ends up rotated/stretched. + LaunchedEffect(lifecycleOwner, state.lensFacing, configuration.orientation) { val cameraProvider = ProcessCameraProvider.getInstance(context).get() val surfaceProvider = CameraPreview.SurfaceProvider { request -> @@ -120,9 +126,14 @@ fun CameraScreen( val availableAspectRatio = maxWidth / maxHeight val matchHeightFirst = availableAspectRatio > aspectRatio + val viewfinderBoxModifier = if (fillViewport) { + Modifier.fillMaxSize() + } else { + Modifier.aspectRatio(aspectRatio, matchHeightConstraintsFirst = matchHeightFirst) + } + Box( - modifier = Modifier - .aspectRatio(aspectRatio, matchHeightConstraintsFirst = matchHeightFirst) + modifier = viewfinderBoxModifier ) { val cornerShape = if (roundCorners) RoundedCornerShape(16.dp) else RoundedCornerShape(0.dp) @@ -143,6 +154,7 @@ fun CameraScreen( CameraXViewfinder( surfaceRequest = currentSurfaceRequest, coordinateTransformer = coordinateTransformer, + contentScale = if (fillViewport) ContentScale.Crop else ContentScale.Fit, modifier = Modifier .fillMaxSize() .clip(cornerShape)