diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a1a19115f4..7f9cb6340b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -235,8 +235,6 @@ dependencies { implementation(libs.androidx.browser) implementation(libs.androidx.biometrics) implementation(libs.androidx.camera.camera2) - implementation(libs.androidx.camera.lifecycle) - implementation(libs.androidx.camera.view) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.material3) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt index 27590313bd..97bc8c9e3d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt @@ -1,23 +1,13 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Canvas 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -26,34 +16,23 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LocalLifecycleOwner import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect import com.bitwarden.ui.platform.base.util.annotatedStringResource import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.camera.CameraPreview +import com.bitwarden.ui.platform.components.camera.QrCodeSquare import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer @@ -65,9 +44,6 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme import com.bitwarden.ui.platform.util.rememberWindowSize -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine /** * The screen to scan QR codes for the application. @@ -221,184 +197,6 @@ private fun QrCodeContentMedium( } } -@Suppress("LongMethod", "TooGenericExceptionCaught") -@Composable -private fun CameraPreview( - cameraErrorReceive: () -> Unit, - qrCodeAnalyzer: QrCodeAnalyzer, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } - - val previewView = remember { - PreviewView(context).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - layoutParams = ViewGroup.LayoutParams( - MATCH_PARENT, - MATCH_PARENT, - ) - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - } - - val imageAnalyzer = remember(qrCodeAnalyzer) { - ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .apply { - setAnalyzer( - Executors.newSingleThreadExecutor(), - qrCodeAnalyzer, - ) - } - } - - val preview = Preview.Builder() - .build() - .apply { surfaceProvider = previewView.surfaceProvider } - - // Unbind from the camera provider when we leave the screen. - DisposableEffect(Unit) { - onDispose { - cameraProvider?.unbindAll() - } - } - - // Set up the camera provider on a background thread. This is necessary because - // ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see - // https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85 - LaunchedEffect(imageAnalyzer) { - try { - cameraProvider = suspendCoroutine { continuation -> - ProcessCameraProvider.getInstance(context).also { future -> - future.addListener( - { continuation.resume(future.get()) }, - ContextCompat.getMainExecutor(context), - ) - } - } - - cameraProvider?.unbindAll() - if (cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) == true) { - cameraProvider?.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageAnalyzer, - ) - } else { - cameraErrorReceive() - } - } catch (_: Exception) { - cameraErrorReceive() - } - } - - AndroidView( - factory = { previewView }, - modifier = modifier, - ) -} - -/** - * UI for the blue QR code square that is drawn onto the screen. - */ -@Suppress("MagicNumber", "LongMethod") -@Composable -private fun QrCodeSquare( - modifier: Modifier = Modifier, - squareOutlineSize: Dp, -) { - val color = BitwardenTheme.colorScheme.text.primary - - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - Canvas( - modifier = Modifier - .padding(8.dp) - .size(squareOutlineSize), - ) { - val strokeWidth = 3.dp.toPx() - - val squareSize = size.width - val strokeOffset = strokeWidth / 2 - val sideLength = (1f / 6) * squareSize - - drawIntoCanvas { canvas -> - canvas.nativeCanvas.apply { - // Draw upper top left. - drawLine( - color = color, - start = Offset(0f, strokeOffset), - end = Offset(sideLength, strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw lower top left. - drawLine( - color = color, - start = Offset(strokeOffset, strokeOffset), - end = Offset(strokeOffset, sideLength), - strokeWidth = strokeWidth, - ) - - // Draw upper top right. - drawLine( - color = color, - start = Offset(squareSize - sideLength, strokeOffset), - end = Offset(squareSize - strokeOffset, strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw lower top right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, 0f), - end = Offset(squareSize - strokeOffset, sideLength), - strokeWidth = strokeWidth, - ) - - // Draw upper bottom right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, squareSize), - end = Offset(squareSize - strokeOffset, squareSize - sideLength), - strokeWidth = strokeWidth, - ) - - // Draw lower bottom right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, squareSize - strokeOffset), - end = Offset(squareSize - sideLength, squareSize - strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw upper bottom left. - drawLine( - color = color, - start = Offset(strokeOffset, squareSize), - end = Offset(strokeOffset, squareSize - sideLength), - strokeWidth = strokeWidth, - ) - - // Draw lower bottom left. - drawLine( - color = color, - start = Offset(0f, squareSize - strokeOffset), - end = Offset(sideLength, squareSize - strokeOffset), - strokeWidth = strokeWidth, - ) - } - } - } - } -} - @Composable private fun EnterKeyManuallyText( onEnterKeyManuallyClick: () -> Unit, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt index d62a8677f9..0604d5234f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt @@ -55,14 +55,6 @@ class QrCodeScanScreenTest : BitwardenComposeTest() { assertTrue(onNavigateToManualCodeEntryScreenCalled) } - @Test - fun `when unable to setup camera CameraErrorReceive will be sent`() = runTest { - // Because the camera is not set up in the tests, this will always be triggered - verify { - viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) - } - } - @Test fun `when a scan is successful a result will be sent`() = runTest { val result = "testCode" diff --git a/authenticator/build.gradle.kts b/authenticator/build.gradle.kts index a33a67d912..5377556cbc 100644 --- a/authenticator/build.gradle.kts +++ b/authenticator/build.gradle.kts @@ -190,8 +190,6 @@ dependencies { implementation(libs.androidx.browser) implementation(libs.androidx.biometrics) implementation(libs.androidx.camera.camera2) - implementation(libs.androidx.camera.lifecycle) - implementation(libs.androidx.camera.view) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.material3) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt index f7c846e08e..46dd987d67 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt @@ -1,23 +1,13 @@ package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Canvas 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -25,30 +15,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.authenticator.ui.platform.util.isPortrait import com.bitwarden.ui.platform.base.util.EventsEffect @@ -57,6 +36,8 @@ import com.bitwarden.ui.platform.base.util.annotatedStringResource import com.bitwarden.ui.platform.base.util.spanStyleOf import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.camera.CameraPreview +import com.bitwarden.ui.platform.components.camera.QrCodeSquare import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer @@ -66,9 +47,6 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine /** * The screen to scan QR codes for the application. @@ -258,180 +236,6 @@ private fun LandscapeQRCodeContent( } } -@Suppress("LongMethod", "TooGenericExceptionCaught") -@Composable -private fun CameraPreview( - cameraErrorReceive: () -> Unit, - qrCodeAnalyzer: QrCodeAnalyzer, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } - - val previewView = remember { - PreviewView(context).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - layoutParams = ViewGroup.LayoutParams( - MATCH_PARENT, - MATCH_PARENT, - ) - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - } - - val imageAnalyzer = remember(qrCodeAnalyzer) { - ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .apply { - setAnalyzer( - Executors.newSingleThreadExecutor(), - qrCodeAnalyzer, - ) - } - } - - val preview = Preview.Builder() - .build() - .apply { surfaceProvider = previewView.surfaceProvider } - - // Unbind from the camera provider when we leave the screen. - DisposableEffect(Unit) { - onDispose { - cameraProvider?.unbindAll() - } - } - - // Set up the camera provider on a background thread. This is necessary because - // ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see - // https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85 - LaunchedEffect(imageAnalyzer) { - try { - cameraProvider = suspendCoroutine { continuation -> - ProcessCameraProvider.getInstance(context).also { future -> - future.addListener( - { continuation.resume(future.get()) }, - Executors.newSingleThreadExecutor(), - ) - } - } - - cameraProvider?.unbindAll() - cameraProvider?.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageAnalyzer, - ) - } catch (_: Exception) { - cameraErrorReceive() - } - } - - AndroidView( - factory = { previewView }, - modifier = modifier, - ) -} - -/** - * UI for the blue QR code square that is drawn onto the screen. - */ -@Suppress("MagicNumber", "LongMethod") -@Composable -private fun QrCodeSquare( - modifier: Modifier = Modifier, - squareOutlineSize: Dp, -) { - val color = BitwardenTheme.colorScheme.text.primary - - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - Canvas( - modifier = Modifier - .size(squareOutlineSize) - .padding(8.dp), - ) { - val strokeWidth = 3.dp.toPx() - - val squareSize = size.width - val strokeOffset = strokeWidth / 2 - val sideLength = (1f / 6) * squareSize - - drawIntoCanvas { canvas -> - canvas.nativeCanvas.apply { - // Draw upper top left. - drawLine( - color = color, - start = Offset(0f, strokeOffset), - end = Offset(sideLength, strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw lower top left. - drawLine( - color = color, - start = Offset(strokeOffset, strokeOffset), - end = Offset(strokeOffset, sideLength), - strokeWidth = strokeWidth, - ) - - // Draw upper top right. - drawLine( - color = color, - start = Offset(squareSize - sideLength, strokeOffset), - end = Offset(squareSize - strokeOffset, strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw lower top right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, 0f), - end = Offset(squareSize - strokeOffset, sideLength), - strokeWidth = strokeWidth, - ) - - // Draw upper bottom right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, squareSize), - end = Offset(squareSize - strokeOffset, squareSize - sideLength), - strokeWidth = strokeWidth, - ) - - // Draw lower bottom right. - drawLine( - color = color, - start = Offset(squareSize - strokeOffset, squareSize - strokeOffset), - end = Offset(squareSize - sideLength, squareSize - strokeOffset), - strokeWidth = strokeWidth, - ) - - // Draw upper bottom left. - drawLine( - color = color, - start = Offset(strokeOffset, squareSize), - end = Offset(strokeOffset, squareSize - sideLength), - strokeWidth = strokeWidth, - ) - - // Draw lower bottom left. - drawLine( - color = color, - start = Offset(0f, squareSize - strokeOffset), - end = Offset(sideLength, squareSize - strokeOffset), - strokeWidth = strokeWidth, - ) - } - } - } - } -} - @Composable private fun BottomClickableText( onEnterCodeManuallyClick: () -> Unit, diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 634e260aac..699544639d 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/CameraPreview.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/CameraPreview.kt new file mode 100644 index 0000000000..24bebb5a8d --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/CameraPreview.kt @@ -0,0 +1,108 @@ +package com.bitwarden.ui.platform.components.camera + +import android.content.Context +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * A composable for displaying the camera preview. + * + * @param qrCodeAnalyzer The [QrCodeAnalyzer]. + * @param cameraErrorReceive A callback invoked when an error occurs. + * @param modifier The [Modifier] for this composable. + * @param context The local context. + * @param lifecycleOwner The current lifecycle owner. + */ +@Composable +fun CameraPreview( + qrCodeAnalyzer: QrCodeAnalyzer, + cameraErrorReceive: (Exception) -> Unit, + modifier: Modifier = Modifier, + context: Context = LocalContext.current, + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, +) { + var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(value = null) } + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + val imageAnalyzer = remember(qrCodeAnalyzer) { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { setAnalyzer(Executors.newSingleThreadExecutor(), qrCodeAnalyzer) } + } + val preview = Preview.Builder() + .build() + .apply { surfaceProvider = previewView.surfaceProvider } + + // Unbind from the camera provider when we leave the screen. + DisposableEffect(Unit) { + onDispose { + cameraProvider?.unbindAll() + } + } + + // Set up the camera provider on a background thread. This is necessary because + // ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see + // https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85 + LaunchedEffect(imageAnalyzer) { + try { + cameraProvider = suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(context).also { future -> + future.addListener( + { continuation.resume(value = future.get()) }, + ContextCompat.getMainExecutor(context), + ) + } + } + + cameraProvider + ?.let { + it.unbindAll() + if (it.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) { + it.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, + useCases = arrayOf(preview, imageAnalyzer), + ) + } else { + cameraErrorReceive(IllegalStateException("Missing Back Camera")) + } + } + ?: cameraErrorReceive(IllegalStateException("Missing Camera Provider")) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + cameraErrorReceive(e) + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier, + ) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/QrCodeSquare.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/QrCodeSquare.kt new file mode 100644 index 0000000000..a139f2aa43 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/camera/QrCodeSquare.kt @@ -0,0 +1,115 @@ +package com.bitwarden.ui.platform.components.camera + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * The UI for the QR code square overlay that is drawn onto the screen. + */ +@Composable +fun QrCodeSquare( + squareOutlineSize: Dp, + modifier: Modifier = Modifier, + color: Color = BitwardenTheme.colorScheme.text.primary, + strokeWidth: Dp = 3.dp, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + QrCodeSquareCanvas( + color = color, + strokeWidth = strokeWidth, + modifier = Modifier + .padding(all = 8.dp) + .size(size = squareOutlineSize), + ) + } +} + +@Suppress("MagicNumber", "LongMethod") +@Composable +private fun QrCodeSquareCanvas( + color: Color, + strokeWidth: Dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier = modifier) { + val strokeWidthPx = strokeWidth.toPx() + val squareSize = size.width + val strokeOffset = strokeWidthPx / 2 + val sideLength = (1f / 6) * squareSize + drawIntoCanvas { canvas -> + canvas.nativeCanvas.apply { + // Draw upper top left. + drawLine( + color = color, + start = Offset(x = 0f, y = strokeOffset), + end = Offset(x = sideLength, y = strokeOffset), + strokeWidth = strokeWidthPx, + ) + // Draw lower top left. + drawLine( + color = color, + start = Offset(x = strokeOffset, y = strokeOffset), + end = Offset(x = strokeOffset, y = sideLength), + strokeWidth = strokeWidthPx, + ) + // Draw upper top right. + drawLine( + color = color, + start = Offset(x = squareSize - sideLength, y = strokeOffset), + end = Offset(x = squareSize - strokeOffset, y = strokeOffset), + strokeWidth = strokeWidthPx, + ) + // Draw lower top right. + drawLine( + color = color, + start = Offset(x = squareSize - strokeOffset, y = 0f), + end = Offset(x = squareSize - strokeOffset, y = sideLength), + strokeWidth = strokeWidthPx, + ) + // Draw upper bottom right. + drawLine( + color = color, + start = Offset(x = squareSize - strokeOffset, y = squareSize), + end = Offset(x = squareSize - strokeOffset, y = squareSize - sideLength), + strokeWidth = strokeWidthPx, + ) + // Draw lower bottom right. + drawLine( + color = color, + start = Offset(x = squareSize - strokeOffset, y = squareSize - strokeOffset), + end = Offset(x = squareSize - sideLength, y = squareSize - strokeOffset), + strokeWidth = strokeWidthPx, + ) + // Draw upper bottom left. + drawLine( + color = color, + start = Offset(x = strokeOffset, y = squareSize), + end = Offset(x = strokeOffset, y = squareSize - sideLength), + strokeWidth = strokeWidthPx, + ) + // Draw lower bottom left. + drawLine( + color = color, + start = Offset(x = 0f, y = squareSize - strokeOffset), + end = Offset(x = sideLength, y = squareSize - strokeOffset), + strokeWidth = strokeWidthPx, + ) + } + } + } +}