Switch linked device scanner to use CameraScreen.

This commit is contained in:
Greyson Parrelli
2026-05-04 14:02:58 -04:00
parent 0542262c49
commit 23698dbc28
5 changed files with 208 additions and 62 deletions
@@ -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,
@@ -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
)
}
}
@@ -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,
@@ -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
)
}
}
@@ -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)