Migrate VerifyScanFragment to compose.

This commit is contained in:
Alex Hart
2026-04-21 12:21:49 -03:00
parent 552361dff4
commit 9fa587b7e4
4 changed files with 228 additions and 95 deletions
@@ -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<ScanListener>()?.onQrDataFound(qrData)
LaunchedEffect(viewModel) {
viewModel.qrCodeDetected.collect {
findListener<ScanListener>()?.onQrDataFound(it)
}
}
VerifyScanScreen(
state = state,
emitter = viewModel::onEvent
)
}
}
@@ -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 = {}
)
}
}
@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView android:id="@+id/information"
android:layout_width="match_parent"
android:layout_height="60dp"
android:gravity="center"
android:text="@string/verify_scan_fragment__scan_the_qr_code_on_your_contact"
style="@style/TextAppearance.Signal.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<org.signal.qr.QrScannerView
android:id="@+id/scanner"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/information" />
<FrameLayout android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/information"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView android:id="@+id/camera_marks"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_gravity="center"
app:srcCompat="@drawable/ic_camera_outline" />
<org.thoughtcrime.securesms.components.ShapeScrim
android:id="@+id/camera_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:radius="0.3"
app:shape="square" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -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()
)
}
}