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,
+ )
+ }
+ }
+ }
+ }
+ }
+}