diff --git a/WalletSdk/build.gradle.kts b/WalletSdk/build.gradle.kts index 834b48d..f9d3630 100644 --- a/WalletSdk/build.gradle.kts +++ b/WalletSdk/build.gradle.kts @@ -102,6 +102,13 @@ android { jvmTarget = "1.8" } + buildFeatures { + compose = true + viewBinding = true + } + + composeOptions { kotlinCompilerExtensionVersion = "1.5.11" } + publishing { singleVariant("release") { withSourcesJar() @@ -114,6 +121,14 @@ dependencies { api("com.spruceid.wallet.sdk.rs:walletsdkrs:0.0.25") //noinspection GradleCompatible implementation("com.android.support:appcompat-v7:28.0.0") + /* Begin UI dependencies */ + implementation("androidx.compose.material3:material3:1.2.1") + implementation("androidx.camera:camera-camera2:1.3.2") + implementation("androidx.camera:camera-lifecycle:1.3.2") + implementation("androidx.camera:camera-view:1.3.2") + implementation("com.google.zxing:core:3.3.3") + implementation("com.google.accompanist:accompanist-permissions:0.34.0") + /* End UI dependencies */ testImplementation("junit:junit:4.13.2") androidTestImplementation("com.android.support.test:runner:1.0.2") androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2") diff --git a/WalletSdk/src/main/AndroidManifest.xml b/WalletSdk/src/main/AndroidManifest.xml index dbf99e5..7a467ff 100644 --- a/WalletSdk/src/main/AndroidManifest.xml +++ b/WalletSdk/src/main/AndroidManifest.xml @@ -14,5 +14,6 @@ + \ No newline at end of file diff --git a/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeAnalyzer.kt b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeAnalyzer.kt new file mode 100644 index 0000000..d835eda --- /dev/null +++ b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeAnalyzer.kt @@ -0,0 +1,72 @@ +package com.spruceid.wallet.sdk.ui + +import android.graphics.ImageFormat +import android.os.Build +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import java.nio.ByteBuffer + +class QrCodeAnalyzer( + private val onQrCodeScanned: (String) -> Unit, + private val isMatch: (content: String) -> Boolean = {_ -> true}, +) : ImageAnalysis.Analyzer { + + private val supportedImageFormats = mutableListOf(ImageFormat.YUV_420_888) + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + supportedImageFormats.addAll(listOf(ImageFormat.YUV_422_888, ImageFormat.YUV_444_888)) + } + } + + override fun analyze(image: ImageProxy) { + if (image.format in supportedImageFormats) { + val bytes = image.planes.first().buffer.toByteArray() + val source = + PlanarYUVLuminanceSource( + bytes, + image.width, + image.height, + 0, + 0, + image.width, + image.height, + false, + ) + val binaryBmp = BinaryBitmap(HybridBinarizer(source)) + try { + val result = + MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to + arrayListOf( + BarcodeFormat.QR_CODE, + ), + ), + ) + }.decode(binaryBmp) + if (isMatch(result.text)) { + onQrCodeScanned(result.text) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + image.close() + } + } + } + + private fun ByteBuffer.toByteArray(): ByteArray { + rewind() + return ByteArray(remaining()).also { + get(it) + } + } +} \ No newline at end of file diff --git a/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeScanner.kt b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeScanner.kt new file mode 100644 index 0000000..9601f54 --- /dev/null +++ b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/ui/QRCodeScanner.kt @@ -0,0 +1,314 @@ +package com.spruceid.wallet.sdk.ui + +import android.util.Range +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat + +@Composable +fun QRCodeScanner( + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: (content: String) -> Unit, + isMatch: (content: String) -> Boolean = {_ -> true}, + onCancel: () -> Unit, + fontFamily: FontFamily = FontFamily.Default, + guidesColor: Color = Color.White, + readerColor: Color = Color.White, + textColor: Color = Color.White, + backgroundOpacity: Float = 0.5f, +) { + var code by remember { + mutableStateOf("") + } + val context = LocalContext.current + val cameraProviderFuture = + remember { + ProcessCameraProvider.getInstance(context) + } + val lifecycleOwner = LocalLifecycleOwner.current + + var canvasSize by remember { + mutableStateOf(Size(0f, 0f)) + } + val infiniteTransition = rememberInfiniteTransition("Infinite QR code line transition remember") + val offsetTop by infiniteTransition.animateFloat( + initialValue = canvasSize.height * .35f, + targetValue = canvasSize.height * .35f + canvasSize.width * .6f, + animationSpec = + infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + "QR code line animation", + ) + + Column( + modifier = Modifier.fillMaxSize(), + ) { + Box( + Modifier.fillMaxSize(), + ) { + AndroidView( + factory = { context -> + val screenSize = android.util.Size(1920, 1080) + val resolutionSelector = + ResolutionSelector + .Builder() + .setResolutionStrategy( + ResolutionStrategy( + screenSize, + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER + ) + ) + .build() + val previewView = PreviewView(context) + val preview = + Preview.Builder() + .setTargetFrameRate(Range(20, 45)) + .setResolutionSelector(resolutionSelector) + .build() + val selector = + CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + preview.setSurfaceProvider(previewView.surfaceProvider) + val imageAnalysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) + .setResolutionSelector(resolutionSelector) + .build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(context), + QrCodeAnalyzer( + isMatch = isMatch, + onQrCodeScanned = { result -> + onRead(result) + code = result + }), + ) + try { + cameraProviderFuture.get().bindToLifecycle( + lifecycleOwner, + selector, + preview, + imageAnalysis, + ) + } catch (e: Exception) { + e.printStackTrace() + } + previewView + }, + modifier = Modifier.fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = backgroundOpacity)) + .drawWithContent { + canvasSize = size + val canvasWidth = size.width + val canvasHeight = size.height + val width = canvasWidth * .6f + + val left = (canvasWidth - width) / 2 + val top = canvasHeight * .35f + val right = left + width + val bottom = top + width + val cornerLength = 40f + val cornerRadius = 40f + drawContent() + drawRect(Color(0x99000000)) + drawRoundRect( + topLeft = Offset(left, top), + size = Size(width, width), + color = Color.Transparent, + blendMode = BlendMode.SrcIn, + cornerRadius = CornerRadius(cornerRadius - 10f), + ) + drawRect( + topLeft = Offset(left, offsetTop), + size = Size(width, 2f), + color = readerColor, + style = Stroke(2.dp.toPx()), + ) + val path = Path() + + // top left + path.moveTo(left, (top + cornerRadius)) + path.arcTo( + Rect( + left = left, + top = top, + right = left + cornerRadius, + bottom = top + cornerRadius, + ), + 180f, + 90f, + true, + ) + path.moveTo(left + (cornerRadius / 2f), top) + path.lineTo(left + (cornerRadius / 2f) + cornerLength, top) + path.moveTo(left, top + (cornerRadius / 2f)) + path.lineTo(left, top + (cornerRadius / 2f) + cornerLength) + + // top right + path.moveTo(right - cornerRadius, top) + path.arcTo( + Rect( + left = right - cornerRadius, + top = top, + right = right, + bottom = top + cornerRadius, + ), + 270f, + 90f, + true, + ) + path.moveTo(right - (cornerRadius / 2f), top) + path.lineTo(right - (cornerRadius / 2f) - cornerLength, top) + path.moveTo(right, top + (cornerRadius / 2f)) + path.lineTo(right, top + (cornerRadius / 2f) + cornerLength) + + // bottom left + path.moveTo(left, bottom - cornerRadius) + path.arcTo( + Rect( + left = left, + top = bottom - cornerRadius, + right = left + cornerRadius, + bottom = bottom, + ), + 90f, + 90f, + true, + ) + path.moveTo(left + (cornerRadius / 2f), bottom) + path.lineTo(left + (cornerRadius / 2f) + cornerLength, bottom) + path.moveTo(left, bottom - (cornerRadius / 2f)) + path.lineTo(left, bottom - (cornerRadius / 2f) - cornerLength) + + // bottom right + path.moveTo(left, bottom - cornerRadius) + path.arcTo( + Rect( + left = right - cornerRadius, + top = bottom - cornerRadius, + right = right, + bottom = bottom, + ), + 0f, + 90f, + true, + ) + path.moveTo(right - (cornerRadius / 2f), bottom) + path.lineTo(right - (cornerRadius / 2f) - cornerLength, bottom) + path.moveTo(right, bottom - (cornerRadius / 2f)) + path.lineTo(right, bottom - (cornerRadius / 2f) - cornerLength) + + drawPath( + path, + color = guidesColor, + style = Stroke(width = 15f), + ) + }, + ) + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(top = 80.dp) + .padding(horizontal = 30.dp), + ) { + Text( + text = title, + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = textColor, + ) + Text( + text = subtitle, + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = textColor, + ) + } + + Column( + Modifier.fillMaxWidth(), + ) { + Button( + onClick = onCancel, + modifier = + Modifier + .padding(bottom = 50.dp) + .padding(horizontal = 30.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Color.Transparent, + ), + ) { + Text( + text = cancelButtonLabel, + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + color = textColor, + ) + } + } + } + } + } +}