diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt index c1b4eed4f3..faf735c9a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -7,7 +7,6 @@ import android.app.Activity import android.content.Intent import android.graphics.Bitmap import android.os.Bundle -import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.AnimatedVisibility @@ -49,8 +48,7 @@ import androidx.core.app.ShareCompat import androidx.core.app.TaskStackBuilder import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -58,8 +56,10 @@ import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenState +import org.signal.camera.CameraScreenViewModel import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.compose.DayNightPreviews @@ -69,7 +69,6 @@ import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.permissions.Permissions -import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData @@ -85,7 +84,6 @@ import java.util.UUID class UsernameLinkSettingsFragment : ComposeFragment() { private val viewModel: UsernameLinkSettingsViewModel by viewModels() - private val disposables: LifecycleDisposable = LifecycleDisposable() private lateinit var galleryLauncher: ActivityResultLauncher @@ -115,21 +113,29 @@ class UsernameLinkSettingsFragment : ComposeFragment() { val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code) + val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() } + val cameraState by cameraViewModel.state + val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) { viewModel.onTabSelected(ActiveTab.Scan) } + LaunchedEffect(cameraViewModel) { + cameraViewModel.qrCodeDetected.collect { data -> + viewModel.onQrCodeScanned(data) + } + } + MainScreen( state = state, navController = navController, - lifecycleOwner = viewLifecycleOwner, - disposables = disposables.disposables, + cameraState = cameraState, + cameraEmitter = cameraViewModel::onEvent, cameraPermissionState = cameraPermissionState, onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) }, onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) }, onUsernameLinkResetResultHandled = { viewModel.onUsernameLinkResetResultHandled() }, onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) }, - onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, onQrResultHandled = { viewModel.onQrResultHandled() }, onOpenCameraClicked = { askCameraPermissions() }, onOpenGalleryClicked = { galleryLauncher.launch(Unit) }, @@ -143,10 +149,6 @@ class UsernameLinkSettingsFragment : ComposeFragment() { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - disposables.bindTo(viewLifecycleOwner) - } - override fun onResume() { super.onResume() viewModel.onResume() @@ -167,14 +169,13 @@ class UsernameLinkSettingsFragment : ComposeFragment() { private fun MainScreen( state: UsernameLinkSettingsState, navController: NavController? = null, - lifecycleOwner: LifecycleOwner = previewLifecycleOwner, - disposables: CompositeDisposable = CompositeDisposable(), + cameraState: CameraScreenState = CameraScreenState(), + cameraEmitter: (CameraScreenEvents) -> Unit = {}, cameraPermissionState: PermissionState = previewPermissionState(), onCodeTabSelected: () -> Unit = {}, onScanTabSelected: () -> Unit = {}, onUsernameLinkResetResultHandled: () -> Unit = {}, onShareBadge: () -> Unit = {}, - onQrCodeScanned: (String) -> Unit = {}, onQrResultHandled: () -> Unit = {}, onOpenCameraClicked: () -> Unit = {}, onOpenGalleryClicked: () -> Unit = {}, @@ -238,10 +239,9 @@ private fun MainScreen( exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) ) { UsernameQrScanScreen( - lifecycleOwner = lifecycleOwner, - disposables = disposables, qrScanResult = state.qrScanResult, - onQrCodeScanned = onQrCodeScanned, + cameraState = cameraState, + cameraEmitter = cameraEmitter, onQrResultHandled = onQrResultHandled, onOpenCameraClicked = onOpenCameraClicked, onOpenGalleryClicked = onOpenGalleryClicked, @@ -394,11 +394,6 @@ private fun previewPermissionState(): PermissionState { } } -private val previewLifecycleOwner: LifecycleOwner = object : LifecycleOwner { - override val lifecycle: Lifecycle - get() = throw UnsupportedOperationException("Only for tests") -} - private fun shareQrBadge(activity: Activity, badge: Bitmap?) { if (badge == null) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt index 51d193398a..3d39a44e85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt @@ -1,45 +1,50 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main import androidx.compose.foundation.Image +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.Row 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.FloatingActionButton 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.graphics.Color import androidx.compose.ui.graphics.ColorFilter 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 androidx.lifecycle.LifecycleOwner -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.plusAssign +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.core.ui.compose.Previews import org.signal.core.ui.compose.theme.SignalTheme -import org.signal.qr.QrScannerView import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig -import org.thoughtcrime.securesms.qr.QrScanScreens +import org.thoughtcrime.securesms.qr.QrCrosshair import org.thoughtcrime.securesms.recipients.Recipient -import java.util.concurrent.TimeUnit /** * A screen that allows you to scan a QR code to start a chat. */ @Composable fun UsernameQrScanScreen( - lifecycleOwner: LifecycleOwner, - disposables: CompositeDisposable, qrScanResult: QrScanResult?, - onQrCodeScanned: (String) -> Unit, + cameraState: CameraScreenState, + cameraEmitter: (CameraScreenEvents) -> Unit, onQrResultHandled: () -> Unit, onOpenCameraClicked: () -> Unit, onOpenGalleryClicked: () -> Unit, @@ -88,26 +93,49 @@ fun UsernameQrScanScreen( modifier = Modifier .fillMaxWidth() .weight(1f, true) + .background(Color.Black) ) { - QrScanScreens.QrScanScreen( - factory = { context -> - val view = QrScannerView(context) - disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data -> - onQrCodeScanned(data) + if (hasCameraPermission) { + CameraScreen( + state = cameraState, + emitter = cameraEmitter, + enableQrScanning = true, + captureMode = CameraCaptureMode.ImageOnly, + roundCorners = false, + fillViewport = true, + modifier = Modifier.fillMaxSize() + ) { + QrCrosshair(modifier = Modifier.fillMaxSize()) + } + } 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 = onOpenCameraClicked + ) { + Text(stringResource(R.string.CameraXFragment_allow_access)) } - view - }, - update = { view -> - view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted()) - }, - hasPermission = hasCameraPermission, - onRequestPermissions = onOpenCameraClicked, - qrHeaderLabelString = "" - ) + } + } + FloatingActionButton( shape = CircleShape, containerColor = SignalTheme.colors.colorSurface1, - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), onClick = onOpenGalleryClicked ) { Image( @@ -143,3 +171,37 @@ private fun QrScanResultDialog(title: String? = null, message: String, onDismiss onDismiss = onDismiss ) } + +@DayNightPreviews +@Composable +private fun UsernameQrScanScreenPreview() { + Previews.Preview { + UsernameQrScanScreen( + qrScanResult = null, + cameraState = CameraScreenState(), + cameraEmitter = {}, + onQrResultHandled = {}, + onOpenCameraClicked = {}, + onOpenGalleryClicked = {}, + onRecipientFound = {}, + hasCameraPermission = true + ) + } +} + +@DayNightPreviews +@Composable +private fun UsernameQrScanScreenNoPermissionPreview() { + Previews.Preview { + UsernameQrScanScreen( + qrScanResult = null, + cameraState = CameraScreenState(), + cameraEmitter = {}, + onQrResultHandled = {}, + onOpenCameraClicked = {}, + onOpenGalleryClicked = {}, + onRecipientFound = {}, + hasCameraPermission = false + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt index 0375932fe4..d6a060bcfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt @@ -24,22 +24,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberPermissionState -import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenState +import org.signal.camera.CameraScreenViewModel import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.permissions.Permissions -import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getParcelableExtraCompat import org.signal.core.util.permissions.PermissionCompat import org.thoughtcrime.securesms.R @@ -57,7 +59,6 @@ class UsernameQrScannerActivity : AppCompatActivity() { } private val viewModel: UsernameQrScannerViewModel by viewModels() - private val disposables = LifecycleDisposable() @SuppressLint("MissingSuperCall") override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { @@ -66,7 +67,6 @@ class UsernameQrScannerActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - disposables.bindTo(this) val galleryLauncher = registerForActivityResult(QrImageSelectionActivity.Contract()) { uri -> if (uri != null) { @@ -86,14 +86,22 @@ class UsernameQrScannerActivity : AppCompatActivity() { val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) val state by viewModel.state + val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() } + val cameraState by cameraViewModel.state + + LaunchedEffect(cameraViewModel) { + cameraViewModel.qrCodeDetected.collect { url -> + viewModel.onQrScanned(url) + } + } + SignalTheme { Content( - lifecycleOwner = this, - diposables = disposables.disposables, state = state, + cameraState = cameraState, + cameraEmitter = cameraViewModel::onEvent, galleryPermissionsState = galleryPermissionState, cameraPermissionState = cameraPermissionState, - onQrScanned = { url -> viewModel.onQrScanned(url) }, onQrResultHandled = { finish() }, @@ -143,12 +151,11 @@ class UsernameQrScannerActivity : AppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun Content( - lifecycleOwner: LifecycleOwner, - diposables: CompositeDisposable, state: UsernameQrScannerViewModel.ScannerState, + cameraState: CameraScreenState, + cameraEmitter: (CameraScreenEvents) -> Unit, galleryPermissionsState: MultiplePermissionsState, cameraPermissionState: PermissionState, - onQrScanned: (String) -> Unit, onQrResultHandled: () -> Unit, onOpenCameraClicked: () -> Unit, onOpenGalleryClicked: () -> Unit, @@ -173,10 +180,9 @@ fun Content( } ) { contentPadding -> UsernameQrScanScreen( - lifecycleOwner = lifecycleOwner, - disposables = diposables, qrScanResult = state.qrScanResult, - onQrCodeScanned = onQrScanned, + cameraState = cameraState, + cameraEmitter = cameraEmitter, onQrResultHandled = onQrResultHandled, onOpenCameraClicked = onOpenCameraClicked, onOpenGalleryClicked = onOpenGalleryClicked, 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 5bdb67d736..c09a549375 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt @@ -16,16 +16,9 @@ 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.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -41,6 +34,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult +import org.thoughtcrime.securesms.qr.QrCrosshair import org.thoughtcrime.securesms.util.navigation.safeNavigate /** @@ -115,8 +109,6 @@ fun LinkDeviceQrScanScreen( .background(Color.Black) ) { if (hasPermission) { - val crosshairPath = remember { Path() } - CameraScreen( state = cameraState, emitter = cameraEmitter, @@ -126,14 +118,7 @@ fun LinkDeviceQrScanScreen( fillViewport = true, modifier = Modifier.fillMaxSize() ) { - Box( - modifier = Modifier - .fillMaxSize() - .drawWithContent { - drawContent() - drawQrCrosshair(crosshairPath) - } - ) + QrCrosshair(modifier = Modifier.fillMaxSize()) Text( text = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code), @@ -173,43 +158,6 @@ fun LinkDeviceQrScanScreen( } } -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCrosshair.kt b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCrosshair.kt new file mode 100644 index 0000000000..02cec7ea1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCrosshair.kt @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.qr + +import androidx.compose.foundation.Canvas +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.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +/** + * Four-corner crosshair overlay used to frame the QR scan target in camera viewfinders. + * The crosshair is sized to 60% of the smaller dimension of its layout. + */ +@Composable +fun QrCrosshair(modifier: Modifier = Modifier) { + val path = remember { Path() } + + Canvas(modifier = modifier) { + val crosshairWidth = 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()) + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrScanScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/qr/QrScanScreens.kt deleted file mode 100644 index 2914767482..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/QrScanScreens.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.thoughtcrime.securesms.qr - -import android.content.Context -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.viewinterop.NoOpUpdate -import org.signal.core.ui.compose.Buttons -import org.signal.qr.QrScannerView -import org.thoughtcrime.securesms.R - -object QrScanScreens { - /** - * Full-screen qr scanning screen with permission-asking UI - */ - @Composable - fun QrScanScreen( - factory: (Context) -> QrScannerView, - update: (QrScannerView) -> Unit = NoOpUpdate, - hasPermission: Boolean, - onRequestPermissions: () -> Unit = {}, - qrHeaderLabelString: String - ) { - val path = remember { Path() } - - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f, true) - ) { - AndroidView( - factory = factory, - update = update, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .drawWithContent { - drawContent() - if (hasPermission) { - drawQrCrosshair(path) - } - } - ) - if (!hasPermission) { - 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)) - } - } - } else { - Text( - text = qrHeaderLabelString, - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 24.dp).fillMaxWidth() - ) - } - } - } - } - - 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()) - ) - ) - } -}