diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67ec5d1b..8bd70030 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,10 @@ okhttp = "4.12.0" play-services-mlkit-face-detection = "17.1.0" retrofit = "2.9.0" sentry = "7.2.0" +tflite = "2.14.0" +tflite-gpu = "2.14.0" +tflite-metadata = "0.4.4" +tflite-support = "0.4.4" timber = "5.0.1" truth = "1.3.0" uiautomator = "2.3.0-beta01" @@ -104,6 +108,10 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } sentry = { module = "io.sentry:sentry" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } +tflite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tflite" } +tflite-metadata = { group = "org.tensorflow", name = "tensorflow-lite-metadata", version.ref = "tflite-metadata" } +tflite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", version.ref = "tflite-gpu" } +tflite-support = { group = "org.tensorflow", name = "tensorflow-lite-support", version.ref = "tflite-support" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } truth = { module = "com.google.truth:truth", version.ref = "truth" } uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } diff --git a/lib/lib.gradle.kts b/lib/lib.gradle.kts index 37c55e7e..e7ae7646 100644 --- a/lib/lib.gradle.kts +++ b/lib/lib.gradle.kts @@ -76,6 +76,7 @@ android { buildFeatures { compose = true buildConfig = true + mlModelBinding = true } composeOptions { @@ -206,6 +207,11 @@ dependencies { // Bundled model implementation(libs.mlkit.obj.detection) + implementation(libs.tflite) + implementation(libs.tflite.gpu) + implementation(libs.tflite.metadata) + implementation(libs.tflite.support) + testImplementation(libs.junit) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.coroutines.test) diff --git a/lib/src/main/java/com/smileidentity/compose/SmileIDExt.kt b/lib/src/main/java/com/smileidentity/compose/SmileIDExt.kt index 1dd59587..7e369038 100644 --- a/lib/src/main/java/com/smileidentity/compose/SmileIDExt.kt +++ b/lib/src/main/java/com/smileidentity/compose/SmileIDExt.kt @@ -18,6 +18,7 @@ import com.smileidentity.compose.document.OrchestratedDocumentVerificationScreen import com.smileidentity.compose.selfie.OrchestratedSelfieCaptureScreen import com.smileidentity.compose.theme.colorScheme import com.smileidentity.compose.theme.typography +import com.smileidentity.compose.transactionfraud.TransactionFraudScreen import com.smileidentity.models.IdInfo import com.smileidentity.models.JobType import com.smileidentity.results.BiometricKycResult @@ -434,3 +435,18 @@ fun SmileID.ConsentScreen( ) } } + +@Composable +fun SmileID.TransactionFraud( + modifier: Modifier = Modifier, + colorScheme: ColorScheme = SmileID.colorScheme, + typography: Typography = SmileID.typography, + onResult: SmileIDCallback = {}, +) { + MaterialTheme(colorScheme = colorScheme, typography = typography) { + TransactionFraudScreen( + modifier = modifier, + onResult = onResult, + ) + } +} diff --git a/lib/src/main/java/com/smileidentity/compose/transactionfraud/TransactionFraud.kt b/lib/src/main/java/com/smileidentity/compose/transactionfraud/TransactionFraud.kt new file mode 100644 index 00000000..f7896003 --- /dev/null +++ b/lib/src/main/java/com/smileidentity/compose/transactionfraud/TransactionFraud.kt @@ -0,0 +1,228 @@ +package com.smileidentity.compose.transactionfraud + +import android.graphics.Bitmap +import android.os.OperationCanceledException +import androidx.annotation.IntRange +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageProxy +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.graphics.scale +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions +import com.smileidentity.ml.ImQualCp20 +import com.smileidentity.results.SmileIDCallback +import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.rotated +import com.ujizin.camposer.CameraPreview +import com.ujizin.camposer.state.CamSelector +import com.ujizin.camposer.state.ImplementationMode +import com.ujizin.camposer.state.ScaleType +import com.ujizin.camposer.state.rememberCamSelector +import com.ujizin.camposer.state.rememberCameraState +import com.ujizin.camposer.state.rememberImageAnalyzer +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.tensorflow.lite.DataType +import org.tensorflow.lite.support.image.TensorImage +import timber.log.Timber + +@Composable +fun TransactionFraudScreen( + modifier: Modifier = Modifier, + onResult: SmileIDCallback = {}, +) { + val context = LocalContext.current + val imageQualityModel = remember { ImQualCp20.newInstance(context) } + // TODO: Request Permissions if not granted + Dialog( + onDismissRequest = { + onResult(SmileIDResult.Error(OperationCanceledException("User Cancelled"))) + }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + ) { + TransactionFraudScreen( + imageQualityModel = imageQualityModel, + onResult = onResult, + modifier = modifier + .height(512.dp) + .clip(MaterialTheme.shapes.large), + ) + } +} + +@Composable +private fun TransactionFraudScreen( + imageQualityModel: ImQualCp20, + modifier: Modifier = Modifier, + onResult: SmileIDCallback = {}, + viewModel: TransactionFraudViewModel = viewModel( + initializer = { TransactionFraudViewModel(imageQualityModel) }, + ), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val cameraState = rememberCameraState() + val camSelector by rememberCamSelector(CamSelector.Front) + Box(contentAlignment = BottomCenter, modifier = modifier) { + CameraPreview( + cameraState = cameraState, + camSelector = camSelector, + implementationMode = ImplementationMode.Compatible, + scaleType = ScaleType.FillCenter, + imageAnalyzer = cameraState.rememberImageAnalyzer(analyze = viewModel::analyzeImage), + isImageAnalysisEnabled = true, + modifier = Modifier.fillMaxSize(), + ) + + val textColor = if (uiState.faceQuality > 50) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.error + } + Text( + text = "Face Quality\n${uiState.faceQuality}", + textAlign = TextAlign.Center, + color = animateColorAsState(targetValue = textColor, label = "faceQualityText").value, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 64.dp), + ) + } +} + +data class TransactionFraudUiState( + @IntRange(0, 100) val faceQuality: Int = 0, +) + +@kotlin.OptIn(FlowPreview::class) +class TransactionFraudViewModel(private val imageQualityModel: ImQualCp20) : ViewModel() { + private val _uiState = MutableStateFlow(TransactionFraudUiState()) + val uiState = _uiState.asStateFlow().sample(250).stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + TransactionFraudUiState(), + ) + private val modelInputSize = intArrayOf(1, 120, 120, 3) + private val faceDetectorOptions = FaceDetectorOptions.Builder().apply { + setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) + setContourMode(FaceDetectorOptions.CONTOUR_MODE_NONE) + setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL) + }.build() + + private val faceDetector by lazy { FaceDetection.getClient(faceDetectorOptions) } + + @OptIn(ExperimentalGetImage::class) + fun analyzeImage(imageProxy: ImageProxy) { + val image = imageProxy.image ?: run { + Timber.w("ImageProxy has no image") + imageProxy.close() + return + } + + val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees) + faceDetector.process(inputImage).addOnSuccessListener { faces -> + // TODO: Add all the protections + val face = faces.firstOrNull() ?: run { + Timber.w("No face detected") + _uiState.update { it.copy(faceQuality = 0) } + return@addOnSuccessListener + } + + val bBox = face.boundingBox + + // Check that the corners of the face bounding box are within the inputImage + val faceCornersInImage = bBox.left >= 0 && bBox.right <= inputImage.width && + bBox.top >= 0 && bBox.bottom <= inputImage.height + if (!faceCornersInImage) { + Timber.w("Face bounding box not within image") + _uiState.update { it.copy(faceQuality = 0) } + return@addOnSuccessListener + } + + // face mesh returns 480ish points. take min/max of all those points. use that as + // bounding box + // Check that the corners of the face bounding box are within the inputImage + + // returns a matrix, each row is a probability of being a quality + // get 1 row if batch size is 1 + // 1st column is the actual quality + // theoretically, 2nd column is 1-(1st_column) + + // model is trained on *face mesh* crop (different from face detection potentially) + + val startTime = System.nanoTime() + val bitmap = with(imageProxy.toBitmap().rotated(imageProxy.imageInfo.rotationDegrees)) { + if (bBox.left + bBox.width() > this.width) { + Timber.w("Face bounding box width is greater than image width") + _uiState.update { it.copy(faceQuality = 0) } + return@addOnSuccessListener + } + + if (bBox.top + bBox.height() > this.height) { + Timber.w("Face bounding box height is greater than image height") + _uiState.update { it.copy(faceQuality = 0) } + return@addOnSuccessListener + } + + val croppedBitmap = Bitmap.createBitmap( + this, + bBox.left, + bBox.top, + bBox.width(), + bBox.height(), + // NB! bBox is not guaranteed to be square, so scale might squish the image + ).scale(modelInputSize[1], modelInputSize[2], false) + recycle() + return@with croppedBitmap + } + + // Image Quality Model Inference + val input = TensorImage(DataType.FLOAT32).apply { load(bitmap) } + val outputs = imageQualityModel.process(input.tensorBuffer) + val output = outputs.outputFeature0AsTensorBuffer.floatArray.firstOrNull() ?: run { + Timber.e("No image quality output") + return@addOnSuccessListener + } + + val elapsedTimeMs = (System.nanoTime() - startTime) / 1_000_000 + Timber.d("Face Quality: $output (model inference time: $elapsedTimeMs ms)") + + _uiState.update { it.copy(faceQuality = (output * 100).toInt()) } + }.addOnFailureListener { exception -> + Timber.e(exception, "Error detecting faces") + _uiState.update { it.copy(faceQuality = 0) } + }.addOnCompleteListener { + // Closing the proxy allows the next image to be delivered to the analyzer + imageProxy.close() + } + } +} diff --git a/lib/src/main/java/com/smileidentity/models/PrepUpload.kt b/lib/src/main/java/com/smileidentity/models/PrepUpload.kt index 1c64535d..1e4189e1 100644 --- a/lib/src/main/java/com/smileidentity/models/PrepUpload.kt +++ b/lib/src/main/java/com/smileidentity/models/PrepUpload.kt @@ -28,5 +28,4 @@ data class PrepUploadResponse( @Json(name = "ref_id") val refId: String, @Json(name = "upload_url") val uploadUrl: String, @Json(name = "smile_job_id") val smileJobId: String, - @Json(name = "camera_config") val cameraConfig: String?, ) diff --git a/lib/src/main/java/com/smileidentity/util/Util.kt b/lib/src/main/java/com/smileidentity/util/Util.kt index b0bbd113..8cd9cf40 100644 --- a/lib/src/main/java/com/smileidentity/util/Util.kt +++ b/lib/src/main/java/com/smileidentity/util/Util.kt @@ -101,6 +101,28 @@ internal fun isValidDocumentImage( uri: Uri?, ) = isImageAtLeast(context, uri, width = 1920, height = 1080) +fun Bitmap.rotated( + rotationDegrees: Int, + flipX: Boolean = false, + flipY: Boolean = false, +): Bitmap { + val matrix = Matrix() + + // Rotate the image back to straight. + matrix.postRotate(rotationDegrees.toFloat()) + + // Mirror the image along the X or Y axis. + matrix.postScale(if (flipX) -1.0f else 1.0f, if (flipY) -1.0f else 1.0f) + val rotatedBitmap = + Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) + + // Recycle the old bitmap if it has changed. + if (rotatedBitmap !== this) { + recycle() + } + return rotatedBitmap +} + /** * Post-processes the image stored in [bitmap] and saves to [file]. The image is scaled to * [maxOutputSize], but maintains the aspect ratio. The image can also converted to grayscale. diff --git a/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt b/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt index 6a91df32..a2a6f9a2 100644 --- a/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt +++ b/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt @@ -1,7 +1,5 @@ package com.smileidentity.viewmodel -import android.graphics.Bitmap -import android.graphics.Matrix import android.util.Size import androidx.annotation.OptIn import androidx.annotation.StringRes @@ -33,6 +31,7 @@ import com.smileidentity.util.createLivenessFile import com.smileidentity.util.createSelfieFile import com.smileidentity.util.getExceptionHandler import com.smileidentity.util.postProcessImageBitmap +import com.smileidentity.util.rotated import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.FlowPreview @@ -344,26 +343,4 @@ class SelfieViewModel( fun onFinished(callback: SmileIDCallback) { callback(result!!) } - - private fun Bitmap.rotated( - rotationDegrees: Int, - flipX: Boolean = false, - flipY: Boolean = false, - ): Bitmap { - val matrix = Matrix() - - // Rotate the image back to straight. - matrix.postRotate(rotationDegrees.toFloat()) - - // Mirror the image along the X or Y axis. - matrix.postScale(if (flipX) -1.0f else 1.0f, if (flipY) -1.0f else 1.0f) - val rotatedBitmap = - Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) - - // Recycle the old bitmap if it has changed. - if (rotatedBitmap !== this) { - recycle() - } - return rotatedBitmap - } } diff --git a/lib/src/main/ml/.gitkeep b/lib/src/main/ml/.gitkeep new file mode 100644 index 00000000..f180db88 --- /dev/null +++ b/lib/src/main/ml/.gitkeep @@ -0,0 +1 @@ +Placeholder for the ml directory. ML models go in this directory diff --git a/lib/src/main/res/values/strings.xml b/lib/src/main/res/values/strings.xml index 52a63b29..58b1994a 100644 --- a/lib/src/main/res/values/strings.xml +++ b/lib/src/main/res/values/strings.xml @@ -151,6 +151,9 @@ Enhanced Document Verification + + Transaction Fraud + This could be because of image quality or internet connectivity diff --git a/sample/src/main/java/com/smileidentity/sample/Screen.kt b/sample/src/main/java/com/smileidentity/sample/Screen.kt index 88650467..9fdaa7ab 100644 --- a/sample/src/main/java/com/smileidentity/sample/Screen.kt +++ b/sample/src/main/java/com/smileidentity/sample/Screen.kt @@ -2,15 +2,16 @@ package com.smileidentity.sample import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Outlined +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.outlined.List import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.List import androidx.compose.material.icons.outlined.Settings import androidx.compose.ui.graphics.vector.ImageVector @@ -59,6 +60,11 @@ enum class ProductScreen( com.smileidentity.R.string.si_enhanced_docv_product_name, com.smileidentity.R.drawable.si_doc_v_instructions_hero, ), + TransactionFraud( + "transaction_fraud", + com.smileidentity.R.string.si_transaction_fraud_product_name, + R.drawable.transaction_fraud, + ), } enum class BottomNavigationScreen( @@ -76,8 +82,8 @@ enum class BottomNavigationScreen( Jobs( "jobs", R.string.jobs, - Filled.List, - Outlined.List, + Icons.AutoMirrored.Filled.List, + Icons.AutoMirrored.Outlined.List, ), Resources( "resources", diff --git a/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt index 6ca70147..2ee15563 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt @@ -1,12 +1,11 @@ package com.smileidentity.sample.compose -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -47,6 +46,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import com.smileidentity.SmileID import com.smileidentity.compose.BiometricKYC @@ -55,6 +56,7 @@ import com.smileidentity.compose.DocumentVerification import com.smileidentity.compose.EnhancedDocumentVerificationScreen import com.smileidentity.compose.SmartSelfieAuthentication import com.smileidentity.compose.SmartSelfieEnrollment +import com.smileidentity.compose.TransactionFraud import com.smileidentity.models.IdInfo import com.smileidentity.models.JobType import com.smileidentity.sample.BottomNavigationScreen @@ -73,7 +75,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import java.net.URL -@OptIn(ExperimentalLayoutApi::class) @Composable fun MainScreen( modifier: Modifier = Modifier, @@ -81,23 +82,18 @@ fun MainScreen( factory = viewModelFactory { MainScreenViewModel() }, ), ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val privacyPolicy = remember { URL("https://usesmileid.com/privacy-policy") } val coroutineScope = rememberCoroutineScope() val navController = rememberNavController() - val currentRoute by navController - .currentBackStackEntryFlow - .collectAsStateWithLifecycle(initialValue = navController.currentBackStackEntry) - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentRoute by navController.currentBackStackEntryAsState() val bottomNavSelection = uiState.bottomNavSelection - val bottomNavItems = remember { BottomNavigationScreen.entries.toImmutableList() } - // Show up button when not on a BottomNavigationScreen - val showUpButton = currentRoute?.destination?.route?.let { route -> - bottomNavItems.none { it.route.contains(route) } - } ?: false - + val dialogDestinations = remember { + listOf(ProductScreen.SmartSelfieAuthentication.route, ProductScreen.TransactionFraud.route) + } val clipboardManager = LocalClipboardManager.current + LaunchedEffect(uiState.clipboardText) { uiState.clipboardText?.let { text -> coroutineScope.launch { @@ -109,6 +105,10 @@ fun MainScreen( modifier = modifier, snackbarHost = { Snackbar() }, topBar = { + // Show up button when not on a BottomNavigationScreen + val showUpButton = currentRoute?.destination?.route?.let { route -> + bottomNavItems.none { it.route.contains(route) } + } ?: false TopBar( showUpButton = showUpButton, onNavigateUp = navController::navigateUp, @@ -119,7 +119,13 @@ fun MainScreen( // Don't show bottom bar when navigating to any product screens val showBottomBar by remember(currentRoute) { derivedStateOf { - bottomNavItems.any { it.route.contains(currentRoute?.destination?.route ?: "") } + val isDirectlyOnBottomNavDestination = bottomNavItems.any { + it.route.contains(currentRoute?.destination?.route ?: "") + } + val isOnDialogDestination = dialogDestinations.any { + it.contains(currentRoute?.destination?.route ?: "") + } + return@derivedStateOf isDirectlyOnBottomNavDestination || isOnDialogDestination } } if (showBottomBar) { @@ -175,10 +181,13 @@ fun MainScreen( navController.popBackStack() } } - composable(ProductScreen.SmartSelfieAuthentication.route) { + dialog(ProductScreen.SmartSelfieAuthentication.route) { LaunchedEffect(Unit) { viewModel.onSmartSelfieAuthenticationSelected() } SmartSelfieAuthenticationUserIdInputDialog( - onDismiss = navController::popBackStack, + onDismiss = { + viewModel.onHomeSelected() + navController.popBackStack() + }, onConfirm = { userId -> navController.navigate( "${ProductScreen.SmartSelfieAuthentication.route}/$userId", @@ -334,6 +343,13 @@ fun MainScreen( }, ) } + dialog(ProductScreen.TransactionFraud.route) { + LaunchedEffect(Unit) { viewModel.onTransactionFraudSelected() } + SmileID.TransactionFraud { + viewModel.onTransactionFraudResult() + navController.popBackStack() + } + } } }, ) @@ -354,7 +370,7 @@ private fun TopBar( if (showUpButton) { IconButton(onClick = onNavigateUp) { Icon( - imageVector = Icons.Filled.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), ) } diff --git a/sample/src/main/java/com/smileidentity/sample/compose/ProductScreens.kt b/sample/src/main/java/com/smileidentity/sample/compose/ProductScreens.kt index 5824db55..7213e06b 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/ProductScreens.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/ProductScreens.kt @@ -2,6 +2,7 @@ package com.smileidentity.sample.compose import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -22,14 +23,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.smileidentity.SmileID import com.smileidentity.sample.BuildConfig @@ -37,15 +37,8 @@ import com.smileidentity.sample.ProductScreen import com.smileidentity.sample.R import com.smileidentity.sample.Screen -val products = listOf( - ProductScreen.SmartSelfieEnrollment, - ProductScreen.SmartSelfieAuthentication, - ProductScreen.EnhancedKyc, - ProductScreen.BiometricKyc, - ProductScreen.BvnConsent, - ProductScreen.DocumentVerification, - ProductScreen.EnhancedDocumentVerification, -) +private val products = ProductScreen.entries +private val roundedCornerShape = RoundedCornerShape(16.dp) @Composable fun ProductSelectionScreen( @@ -58,17 +51,30 @@ fun ProductSelectionScreen( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(16.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 2.dp) .weight(1f), ) { Text( stringResource(R.string.test_our_products), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), ) - ProductsGrid(onProductSelected) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + verticalArrangement = spacedBy(8.dp), + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier.clip(roundedCornerShape), + ) { + items(products) { + ProductCell( + productScreen = it, + onProductSelected = onProductSelected, + modifier = Modifier.defaultMinSize(minHeight = 164.dp), + ) + } + } } SelectionContainer { Text( @@ -79,64 +85,26 @@ fun ProductSelectionScreen( ), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.typography.labelMedium.color.copy(alpha = .5f), - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(bottom = 2.dp), ) } } } -/** - * We'd like each product cell to be the same height. However, we don't know the height of the - * tallest cell until we've measured them all. So we need to measure them all twice. Once to find - * the tallest cell, and again to actually lay them out. - * - * We do this by using a [SubcomposeLayout] with 2 subcompositions. The first one measures each cell - * and returns the tallest height. The second subcomposition creates the actual grid layout. - */ -@Composable -private fun ProductsGrid(onProductSelected: (Screen) -> Unit) { - SubcomposeLayout { constraints -> - // The true/false passed to subcompose is merely because we need 2 unique keys - val maxHeight = subcompose(true) { - products.map { ProductCell(it, onProductSelected, 0.dp) } - }.maxOf { it.measure(constraints).measuredHeight.toDp() } - - val contentPlaceable = subcompose(false) { - LazyVerticalGrid(columns = GridCells.Fixed(2)) { - items(products) { - ProductCell(it, onProductSelected, maxHeight) - } - } - }.first().measure(constraints) - - layout(contentPlaceable.measuredWidth, maxHeight.roundToPx()) { - contentPlaceable.place(0, 0) - } - } -} - -/** - * [minHeight] is used so that 0.dp can be passed to the [ProductCell] composable as part of an - * initial pass to measure the final max height. Once a max height is determined, the minHeight - * is guaranteed to be greater than 0.dp - */ @Composable private fun ProductCell( productScreen: ProductScreen, onProductSelected: (Screen) -> Unit, - minHeight: Dp, + modifier: Modifier = Modifier, ) { FilledTonalButton( onClick = { onProductSelected(productScreen) }, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), + shape = roundedCornerShape, + modifier = modifier.fillMaxWidth(), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceAround, - modifier = Modifier.defaultMinSize(minHeight = minHeight), ) { Image( painterResource(productScreen.icon), diff --git a/sample/src/main/java/com/smileidentity/sample/compose/ResourcesScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/ResourcesScreen.kt index f3473e38..f6457850 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/ResourcesScreen.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/ResourcesScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Star @@ -73,7 +73,7 @@ fun ResourcesScreen( ListItem( headlineContent = { Text(it.first) }, supportingContent = { Text(it.second) }, - trailingContent = { Icon(Icons.Default.ArrowForward, null) }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) }, modifier = Modifier.clickable(onClick = it.third), ) Divider() @@ -82,7 +82,7 @@ fun ResourcesScreen( ListItem( headlineContent = { Text(stringResource(it.first)) }, leadingContent = { Icon(it.second, null) }, - trailingContent = { Icon(Icons.Default.ArrowForward, null) }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) }, modifier = Modifier.clickable(onClick = it.third), ) Divider() diff --git a/sample/src/main/java/com/smileidentity/sample/compose/SettingsScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/SettingsScreen.kt index 369112c8..bea91bec 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/SettingsScreen.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/SettingsScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Divider import androidx.compose.material3.Icon @@ -68,7 +68,7 @@ fun SettingsScreen( ListItem( headlineContent = { Text(stringResource(it.first)) }, leadingContent = { Icon(it.second, null) }, - trailingContent = { Icon(Icons.Default.ArrowForward, null) }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) }, modifier = Modifier.clickable(onClick = it.third), ) Divider() diff --git a/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt index c87add9e..0c3cc494 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt @@ -102,8 +102,8 @@ fun JobsListScreen( userId = it.userId, jobId = it.jobId, smileJobId = it.smileJobId, - resultCode = it.resultCode?.toString(), - code = it.code?.toString(), + resultCode = it.resultCode, + code = it.code, ) } } @@ -154,11 +154,11 @@ private fun JobListItem( verticalArrangement = Arrangement.spacedBy(16.dp), ) { val halfCircleRotationDegrees = 180f - val animatedProgress = animateFloatAsState( + val animatedProgress by animateFloatAsState( targetValue = if (expanded) halfCircleRotationDegrees else 0f, animationSpec = spring(), label = "Dropdown Icon Rotation", - ).value + ) Icon( imageVector = Icons.Filled.KeyboardArrowDown, contentDescription = null, diff --git a/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt b/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt index 2c63b25a..913fb9c0 100644 --- a/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt +++ b/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt @@ -96,6 +96,7 @@ class MainScreenViewModel : ViewModel() { BiometricKyc -> SmileID.api.pollBiometricKycJobStatus(request) EnhancedDocumentVerification -> SmileID.api.pollEnhancedDocumentVerificationJobStatus(request) + else -> { Timber.e("Unexpected pending job: $job") throw IllegalStateException("Unexpected pending job: $job") @@ -172,6 +173,7 @@ class MainScreenViewModel : ViewModel() { } fun onHomeSelected() { + Timber.v("onHomeSelected") _uiState.update { it.copy( appBarTitle = R.string.app_name, @@ -208,12 +210,7 @@ class MainScreenViewModel : ViewModel() { } fun onSmartSelfieEnrollmentSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.SmartSelfieEnrollment.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.SmartSelfieEnrollment.label) } } fun onSmartSelfieEnrollmentResult( @@ -261,12 +258,7 @@ class MainScreenViewModel : ViewModel() { } fun onSmartSelfieAuthenticationSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.SmartSelfieAuthentication.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.SmartSelfieAuthentication.label) } } fun onSmartSelfieAuthenticationResult( @@ -308,12 +300,7 @@ class MainScreenViewModel : ViewModel() { } fun onEnhancedKycSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.EnhancedKyc.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.EnhancedKyc.label) } } fun onEnhancedKycResult(result: SmileIDResult) { @@ -346,12 +333,7 @@ class MainScreenViewModel : ViewModel() { } fun onBiometricKycSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.BiometricKyc.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.BiometricKyc.label) } } fun onBiometricKycResult( @@ -389,12 +371,7 @@ class MainScreenViewModel : ViewModel() { } fun onDocumentVerificationSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.DocumentVerification.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.DocumentVerification.label) } } fun onDocumentVerificationResult( @@ -431,33 +408,19 @@ class MainScreenViewModel : ViewModel() { } fun onBvnConsentSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.BvnConsent.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.BvnConsent.label) } } fun onConsentDenied() { - _uiState.update { - it.copy(snackbarMessage = "Consent Denied") - } + _uiState.update { it.copy(snackbarMessage = "Consent Denied") } } fun onSuccessfulBvnConsent() { - _uiState.update { - it.copy(snackbarMessage = "BVN Consent Successful") - } + _uiState.update { it.copy(snackbarMessage = "BVN Consent Successful") } } fun onEnhancedDocumentVerificationSelected() { - _uiState.update { - it.copy( - appBarTitle = ProductScreen.EnhancedDocumentVerification.label, - bottomNavSelection = BottomNavigationScreen.Home, - ) - } + _uiState.update { it.copy(appBarTitle = ProductScreen.EnhancedDocumentVerification.label) } } fun onEnhancedDocumentVerificationResult( @@ -493,6 +456,14 @@ class MainScreenViewModel : ViewModel() { } } + fun onTransactionFraudSelected() { + _uiState.update { it.copy(appBarTitle = ProductScreen.TransactionFraud.label) } + } + + fun onTransactionFraudResult() { + onHomeSelected() + } + fun clearJobs() { viewModelScope.launch { DataStoreRepository.clearJobs(SmileID.config.partnerId, !SmileID.useSandbox) diff --git a/sample/src/main/res/drawable/transaction_fraud.xml b/sample/src/main/res/drawable/transaction_fraud.xml new file mode 100644 index 00000000..61e2be7e --- /dev/null +++ b/sample/src/main/res/drawable/transaction_fraud.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +