diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt index 754898b150..026e9e4e24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt @@ -1,56 +1,35 @@ package org.thoughtcrime.securesms.verify -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.core.view.OneShotPreDrawListener -import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import org.signal.core.util.concurrent.LifecycleDisposable -import org.signal.qr.QrScannerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.viewmodel.compose.viewModel +import org.signal.camera.CameraScreenViewModel +import org.signal.core.ui.compose.ComposeFragment import org.signal.qr.kitkat.ScanListener -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ShapeScrim -import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig -import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.fragments.findListener /** * QR Scanner for identity verification */ -class VerifyScanFragment : Fragment() { - private val lifecycleDisposable = LifecycleDisposable() - - private lateinit var cameraView: QrScannerView - private lateinit var cameraScrim: ShapeScrim - private lateinit var cameraMarks: ImageView - - override fun onCreateView(inflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? { - return ViewUtil.inflate(inflater, viewGroup!!, R.layout.verify_scan_fragment) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cameraView = view.findViewById(R.id.scanner) - cameraScrim = view.findViewById(R.id.camera_scrim) - cameraMarks = view.findViewById(R.id.camera_marks) - OneShotPreDrawListener.add(cameraScrim) { - val width = cameraScrim.scrimWidth - val height = cameraScrim.scrimHeight - ViewUtil.updateLayoutParams(cameraMarks, width, height) +class VerifyScanFragment : ComposeFragment() { + @Composable + override fun FragmentContent() { + val viewModel = viewModel { + CameraScreenViewModel() } - cameraView.start(viewLifecycleOwner, CameraXRemoteConfig.isBlocklisted()) + val state by viewModel.state - lifecycleDisposable.bindTo(viewLifecycleOwner) - - lifecycleDisposable += cameraView - .qrData - .distinctUntilChanged() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { qrData: String -> - findListener()?.onQrDataFound(qrData) + LaunchedEffect(viewModel) { + viewModel.qrCodeDetected.collect { + findListener()?.onQrDataFound(it) } + } + + VerifyScanScreen( + state = state, + emitter = viewModel::onEvent + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanScreen.kt new file mode 100644 index 0000000000..a2bf703435 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanScreen.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.verify + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.camera.CameraCaptureMode +import org.signal.camera.CameraScreen +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenState +import org.signal.camera.CameraScreenViewModel +import org.signal.core.ui.compose.Cutout +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R + +/** + * Scanner screen for verifying user identities. This is meant to be utilized with an instance of + * [CameraScreenViewModel], which the parent component owns. There is a field on the ViewModel through + * which you can recieve via [qrCodeDetected] + */ +@Composable +fun VerifyScanScreen( + state: CameraScreenState, + emitter: (CameraScreenEvents) -> Unit +) { + Column { + CameraScreen( + state = state, + emitter = emitter, + roundCorners = false, + enableQrScanning = true, + captureMode = CameraCaptureMode.ImageOnly, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Cutout( + cutoutShape = RoundedCornerShape(18.dp), + cutoutPadding = PaddingValues(64.dp), + modifier = Modifier.fillMaxSize() + ) + + Image( + painter = painterResource(R.drawable.ic_camera_outline), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.heightIn(min = 60.dp) + ) { + Text( + text = stringResource(R.string.verify_scan_fragment__scan_the_qr_code_on_your_contact), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@DayNightPreviews +@Composable +private fun VerifyScanScreenPreview() { + Previews.Preview { + VerifyScanScreen( + state = CameraScreenState(), + emitter = {} + ) + } +} diff --git a/app/src/main/res/layout/verify_scan_fragment.xml b/app/src/main/res/layout/verify_scan_fragment.xml deleted file mode 100644 index f4f4f66620..0000000000 --- a/app/src/main/res/layout/verify_scan_fragment.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Cutout.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Cutout.kt new file mode 100644 index 0000000000..91c7085474 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Cutout.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.addOutline +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.theme.SignalTheme +import kotlin.math.min + +@Composable +fun Cutout( + cutoutShape: Shape, + modifier: Modifier = Modifier, + cutoutPadding: PaddingValues = PaddingValues.Zero, + fillColor: Color = SignalTheme.colors.colorTransparent4 +) { + BoxWithConstraints( + modifier = modifier + ) { + val width = maxWidth + val height = maxHeight + + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + val pxPad: Rect = remember(cutoutPadding, density, layoutDirection) { + with(density) { + Rect( + left = cutoutPadding.calculateLeftPadding(layoutDirection).roundToPx().toFloat(), + top = cutoutPadding.calculateTopPadding().roundToPx().toFloat(), + right = cutoutPadding.calculateRightPadding(layoutDirection).roundToPx().toFloat(), + bottom = cutoutPadding.calculateBottomPadding().roundToPx().toFloat() + ) + } + } + + val pxSize = remember(density, width, height, pxPad) { + with(density) { + val size = min(width.roundToPx(), height.roundToPx()).toFloat() + + Size(size - pxPad.left - pxPad.right, size - pxPad.top - pxPad.bottom) + } + } + + val path = remember(density, layoutDirection, pxSize) { + val outline = cutoutShape.createOutline(pxSize, layoutDirection, density) + Path().apply { + addOutline(outline) + } + } + + val shapeOffset = remember(pxSize) { + Offset(pxSize.width / 2f, pxSize.height / 2f) + } + + val clipPath = remember { Path() } + + Canvas(modifier = Modifier.fillMaxSize()) { + clipPath.reset() + clipPath.addPath(path, center - shapeOffset) + + clipPath(clipPath, clipOp = ClipOp.Difference) { + drawRect(color = fillColor) + } + } + } +} + +@Preview +@Composable +fun CutoutPreview() { + Previews.Preview { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf(Color.Red, Color.Green) + ) + ) + ) + + Cutout( + cutoutShape = CircleShape, + cutoutPadding = PaddingValues(24.dp), + modifier = Modifier.fillMaxSize() + ) + } +}