mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-17 13:51:00 +01:00
Migrate VerifyScanFragment to compose.
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user